Audit Logging

intermediate

Structured audit trail for compliance and debugging with immutable logs.

auditloggingcompliancesecuritypino
Tested on201619TS5.9
$ bunx sinew add security/audit-logging
Interactive demo coming soon

1The Problem

Your application needs a reliable audit trail for:

  • Compliance requirements (SOC2, HIPAA, GDPR)
  • Security incident investigation
  • User activity tracking
  • Change history for sensitive data

2The Solution

Use structured logging with Pino plus append-only database storage to capture who did what, when, and to which resources.

3Files

lib/audit/types.ts

lib/audit/types.tsTypeScript
export type AuditAction =
  | "create"
  | "read"
  | "update"
  | "delete"
  | "login"
  | "logout"
  | "permission_change";

export interface AuditActor {
  id: string;
  type: "user" | "system" | "api_key";
  email?: string;
  ipAddress?: string;
}

export interface AuditResource {
  type: string;
  id: string;
  name?: string;
}

export interface AuditEvent {
  id: string;
  timestamp: Date;
  action: AuditAction;
  status: "success" | "failure";
  actor: AuditActor;
  resource: AuditResource;
  changes?: {
    before?: Record<string, unknown>;
    after?: Record<string, unknown>;
  };
}

lib/audit/logger.ts

lib/audit/logger.tsTypeScript
import pino from "pino";
import { randomUUID } from "crypto";
import { prisma } from "@/lib/prisma";
import type { AuditEvent, AuditActor, AuditResource } from "./types";

// Pino already emits a "time" field; we only reshape the level.
const logger = pino({
  name: "audit",
  level: "info",
  formatters: {
    level: (label) => ({ level: label }),
  },
});

export async function audit(input: {
  action: AuditEvent["action"];
  status?: AuditEvent["status"];
  actor: Partial<AuditActor> & { id: string };
  resource: AuditResource;
  changes?: AuditEvent["changes"];
}): Promise<AuditEvent> {
  const event: AuditEvent = {
    id: randomUUID(),
    timestamp: new Date(),
    action: input.action,
    status: input.status ?? "success",
    // Spread first, then apply the default so an explicit `type: undefined`
    // from the caller cannot clobber it.
    actor: { ...input.actor, type: input.actor.type ?? "user" },
    resource: input.resource,
    changes: input.changes,
  };

  logger.info(
    {
      auditId: event.id,
      action: event.action,
      actorId: event.actor.id,
      resourceType: event.resource.type,
      resourceId: event.resource.id,
    },
    `${event.action}:${event.resource.type}:${event.resource.id}`
  );

  // Append-only persistence (grant the app role INSERT/SELECT only).
  await prisma.auditEvent.create({
    data: {
      id: event.id,
      timestamp: event.timestamp,
      action: event.action,
      status: event.status,
      actorId: event.actor.id,
      actorType: event.actor.type,
      resourceType: event.resource.type,
      resourceId: event.resource.id,
      changes: event.changes as object | undefined,
    },
  });

  return event;
}

4Dependencies

$ bun add pino

5Configuration

audit() writes to this table by default. Grant the app DB role INSERT/SELECT only (no UPDATE/DELETE) to keep the trail append-only:

6Usage

Log a User Action

import { audit } from "@/lib/audit/logger";

await audit({
  action: "update",
  actor: { id: userId, email: user.email },
  resource: { type: "user", id: targetUserId },
  changes: {
    before: { role: "member" },
    after: { role: "admin" },
  },
});
TypeScript

Query Audit Trail

const { events } = await queryAuditEvents({
  resourceType: "user",
  resourceId: userId,
  limit: 50,
});
TypeScript

Related patterns