CentercodePorygon
DittoPorygonAPI

Audit Logging

Track who did what when across the platform

SafeReliable

Overview

Audit logging captures every data mutation with full context - who performed the action, what changed, and when. This enables customer compliance, support investigations, and platform accountability.

What it is

An append-only record of all create, update, and delete operations across the platform, stored in the AuditLog table.

Why we use it

Customer compliance requirements, support investigations, security monitoring, and AI agent accountability.

When to use

Every mutation in a repository function must include an audit.log() call with the Actor who performed it.

Key Features

  • Automatic actor tracking with PII masking (J. Smith instead of John Smith)
  • Scope-aware logging (platform, program, project)
  • TypeScript-enforced - functions require Actor parameter, won't compile without it
  • ESLint blocks direct Prisma imports outside repositories

Quick Start

Adding Audit to a Repository

Every repository mutation follows this pattern: perform operation, then log to audit trail.

// Adding audit logging to a repository function
import { prisma } from '@/lib/db';
import { audit, type Actor } from '@/lib/audit';

export async function createProject(
  data: CreateProjectInput,
  actor: Actor
): Promise<Project> {
  const project = await prisma.project.create({ data });

  await audit.log({
    action: 'project.create',
    category: 'DATA',
    actorId: actor.id,
    actorDisplayHint: actor.displayHint,
    actorType: actor.type,
    scopeType: 'PROGRAM',
    scopeId: project.programId,
    targetType: 'Project',
    targetId: project.id,
    targetDisplayHint: project.name,
  });

  return project;
}

Patterns

The Actor Pattern

Create actors from users, agents, or system operations.

// Creating actors from different sources
import { toActor, agentActor, SYSTEM_ACTOR } from '@/lib/audit';

// From authenticated user (most common)
const actor = toActor({
  id: session.user.id,
  name: session.user.name
});
// Result: { id: "usr_123", displayHint: "J. Smith", type: "USER" }

// From AI agent
const actor = agentActor(agentId, agentName);
// Result: { id: "agent_123", displayHint: "Agent: Feedback Analyzer", type: "AGENT" }

// For system operations (cron jobs, migrations)
const actor = SYSTEM_ACTOR;
// Result: { id: "system", displayHint: "System", type: "SYSTEM" }

Cascade Delete

Audit parent and all children when deleting - database cascades are invisible.

// Handling cascade deletes with audit trail
export async function deleteProject(id: string, actor: Actor): Promise<void> {
  const project = await prisma.project.findUnique({
    where: { id },
    include: { resources: { select: { id: true, title: true } } },
  });

  // Soft delete children first
  await prisma.resource.updateMany({
    where: { projectId: id },
    data: { deletedAt: new Date() },
  });

  // Audit each child deletion
  for (const resource of project.resources) {
    await audit.log({
      action: 'resource.delete',
      category: 'DATA',
      actorId: actor.id,
      actorDisplayHint: actor.displayHint,
      actorType: actor.type,
      scopeType: 'PROJECT',
      scopeId: id,
      targetType: 'Resource',
      targetId: resource.id,
      targetDisplayHint: resource.title,
      metadata: { reason: 'parent_project_deleted' },
    });
  }

  // Soft delete parent and audit
  await prisma.project.update({
    where: { id },
    data: { deletedAt: new Date() },
  });

  await audit.log({
    action: 'project.delete',
    category: 'DATA',
    actorId: actor.id,
    actorDisplayHint: actor.displayHint,
    actorType: actor.type,
    scopeType: 'PROGRAM',
    scopeId: project.programId,
    targetType: 'Project',
    targetId: id,
    targetDisplayHint: project.name,
    metadata: {
      childrenDeleted: { resources: project.resources.length },
    },
  });
}

Bulk Operations

Use audit.logMany() for efficient batch logging of multiple operations.

// Efficient batch logging with audit.logMany()
import { audit } from '@/lib/audit';

await audit.logMany(
  items.map((item) => ({
    action: 'item.create',
    category: 'DATA',
    actorId: actor.id,
    actorDisplayHint: actor.displayHint,
    actorType: actor.type,
    scopeType: 'PROJECT',
    scopeId: projectId,
    targetType: 'Item',
    targetId: item.id,
    targetDisplayHint: item.name,
  }))
);

Watch Out

Never import prisma directly outside repositories - bypasses audit trail

Don't

// Direct Prisma import (bypasses audit)
import { prisma } from '@/lib/db';

// This mutation is invisible to audit trail!
await prisma.project.update({
  where: { id },
  data: { name: 'New Name' },
});

Do

// Through repository (audited)
import * as repository from './repository';
import { toActor } from '@/lib/audit';

const actor = toActor({
  id: session.user.id,
  name: session.user.name
});
await repository.updateProject(id, { name: 'New Name' }, actor);

Actor parameter is required - TypeScript will error without it

Don't

// Missing actor parameter
export async function updateProject(id: string, data: UpdateInput) {
  return prisma.project.update({ where: { id }, data });
  // No audit - who did this?
}

Do

// Actor parameter required
export async function updateProject(
  id: string,
  data: UpdateInput,
  actor: Actor
) {
  const result = await prisma.project.update({ where: { id }, data });
  await audit.log({
    action: 'project.update',
    actorId: actor.id,
    actorDisplayHint: actor.displayHint,
    actorType: actor.type,
    // ... rest of audit entry
  });
  return result;
}
  • Never log full names or emails - use toActor() helper for automatic masking
  • Database cascades are invisible - handle deletes explicitly in repository code

Related

Repository Pattern

Data access layer where audit logging lives

Error Handling

Typed error classes and responses

Logging

Operational logs (different from audit)