Audit Logging
intermediateStructured audit trail for compliance and debugging with immutable logs.
auditloggingcompliancesecuritypino
Tested on⬢20▲16⚛19TS5.9
$ bunx sinew add security/audit-loggingInteractive 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 pino5Configuration
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