DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Audit Logging with Claude Code: Who Changed What and When

"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
Enter fullscreen mode Exit fullscreen mode

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
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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
  • setImmediate non-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)