"Who modified this record?" "When was it deleted?" "Which admin changed the user's role?"
Without audit logs, you can't answer these questions. And in financial systems, medical apps, and SaaS platforms, you're required to answer them — by your users, by your compliance team, or by regulators.
Claude Code generates complete audit logging infrastructure from CLAUDE.md rules.
The CLAUDE.md Rules
## Audit Logging Rules
- All Create, Update, Delete operations must be recorded
- Admin actions require especially detailed logging (before + after values)
- Auth events must be logged: login, logout, password change, role change
- Every audit event includes:
- actor: { userId, email, role }
- action: created | updated | deleted
- resource: { entityType, entityId }
- changes: { before, after } (for updates and deletes)
- metadata: { ipAddress, userAgent, requestId }
- Audit log records are immutable — no UPDATE or DELETE allowed
- Retention policy:
- Financial systems: 7 years
- Medical systems: 5 years
- General SaaS: 3 years
With these rules in CLAUDE.md, Claude Code knows what to generate before you describe the implementation.
auditLogger.ts
import { AsyncLocalStorage } from 'async_hooks';
import { PrismaClient } from '@prisma/client';
export interface AuditActor {
userId: string;
email: string;
role: string;
}
// Carries actor context across async boundaries without prop drilling
const actorContext = new AsyncLocalStorage<AuditActor>();
export function setAuditActor(actor: AuditActor, fn: () => Promise<void>) {
return actorContext.run(actor, fn);
}
export function getAuditActor(): AuditActor | undefined {
return actorContext.getStore();
}
const prisma = new PrismaClient();
export async function logAuditEvent(params: {
action: 'created' | 'updated' | 'deleted';
entityType: string;
entityId: string;
before?: unknown;
after?: unknown;
ipAddress?: string;
userAgent?: string;
requestId?: string;
}): Promise<void> {
const actor = getAuditActor();
// Non-blocking: audit log failure must never break the main operation
setImmediate(async () => {
try {
await prisma.auditLog.create({
data: {
actorId: actor?.userId ?? 'system',
actorEmail: actor?.email ?? 'system',
actorRole: actor?.role ?? 'system',
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
before: params.before as any,
after: params.after as any,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
requestId: params.requestId,
},
});
} catch (err) {
// Alert but never throw — audit log failure is serious but must not fail the request
console.error('[AUDIT] Failed to write audit log:', err);
// In production: send to Slack or PagerDuty here
}
});
}
AsyncLocalStorage propagates the actor (the authenticated user) through the entire async call chain without passing it as a parameter everywhere. setImmediate makes the write non-blocking — the main request completes first, then the audit log is written.
Prisma Middleware: Auto-Capture
prisma.$use(async (params, next) => {
const writeActions = ['create', 'update', 'updateMany', 'delete', 'deleteMany'];
if (!writeActions.includes(params.action)) {
return next(params);
}
// Capture before value for updates and deletes
let before: unknown = undefined;
if (['update', 'updateMany', 'delete', 'deleteMany'].includes(params.action)) {
before = await (prisma as any)[params.model!.toLowerCase()].findUnique({
where: params.args.where,
});
}
const result = await next(params);
const actionMap: Record<string, 'created' | 'updated' | 'deleted'> = {
create: 'created',
update: 'updated',
updateMany: 'updated',
delete: 'deleted',
deleteMany: 'deleted',
};
await logAuditEvent({
action: actionMap[params.action],
entityType: params.model ?? 'Unknown',
entityId: result?.id ?? params.args.where?.id ?? 'unknown',
before,
after: params.action.startsWith('delete') ? undefined : result,
});
return result;
});
Every Prisma write operation is automatically intercepted. No logAuditEvent call needed in individual service methods — it happens at the ORM layer.
Prisma Schema: AuditLog Model
model AuditLog {
id String @id @default(cuid())
createdAt DateTime @default(now())
// Actor
actorId String
actorEmail String
actorRole String
// Request context
ipAddress String?
userAgent String?
requestId String?
// What happened
action String // created | updated | deleted
entityType String
entityId String
// State capture
before Json?
after Json?
@@index([entityType, entityId])
@@index([actorId])
@@index([createdAt])
}
No updatedAt field. No update or delete mutations anywhere in the codebase. The schema enforces immutability — you can only insert, never modify.
What CLAUDE.md Gives You
The pattern: rules in CLAUDE.md → Claude Code generates the full audit infrastructure in one pass.
-
AsyncLocalStorage→ actor flows through the async chain automatically, no prop drilling - Prisma middleware → every CUD operation is captured without touching individual service methods
-
setImmediatenon-blocking write → audit log failure never breaks the main request - Immutable schema → compliance-friendly by design
Without these rules, audit logging gets bolted on after the fact — incomplete, inconsistent, and usually missing the before value for updates. With CLAUDE.md, it's part of the initial generation.
Want the complete security-focused CLAUDE.md ruleset — including audit logging, input validation, rate limiting, and OWASP Top 10 coverage? It's packaged as a Security Pack on PromptWorks (¥1,480, /security-check).
What triggered the requirement for audit logging in your project? Compliance, a support request, or a production incident?
Top comments (0)