Naalya Handbook
Auditing Actions

Reading the Log

The read-only audit API and read-time diffing, how redaction masks secrets, when to log manually, and the traps that bite people.

The write path is done; now for reading. The audit log is a readonly resource — there's no write API — and the before/after diff is computed when you read, not stored. This page covers the read endpoints, how redaction keeps secrets out of snapshots, when you have to log by hand, and the gotchas.

The read API

AUDIT_LOG is a readonly resource with only two endpoints, both permission-gated.

RouteOperationPermission
GET /api/v1/audit-logsList, cursor-paginated, filterableLIST on AUDIT_LOG
GET /api/v1/audit-logs/:idDetail with snapshots + computed diffREAD on AUDIT_LOG

Read-time diffing

The clever part is that the diff is computed at read time, not stored. computeDiff runs against the two stored snapshots and returns a FieldChange[] ({ field, from, to, kind }) without touching the DB again. Writes stay cheap, and the diff logic can evolve without a re-migration.

apps/server/src/app/audit-log/audit-log.service.ts
private toDto(row: AuditLogEntity, includeSnapshots: boolean) {
  const diff = row.beforeState != null
    ? computeDiff(row.beforeState, row.afterState ?? null)
    : undefined;
  // list view drops the raw snapshots; detail view keeps them
}

A CREATE (before is null) yields an empty diff — the after-state is the new thing. A DELETE (after is null) yields every field as removed. Reads are tenant-scoped automatically because the entity is @TenantScoped(). (Auth & Permissions covers @RequirePermissions.)

Redaction

Snapshots can hold secrets — a passwordHash, a token, a refreshToken. redactSnapshot (in @app/shared) is the safety net. It returns a deep clone with any key whose lowercased name contains a deny-list term masked to '[REDACTED]'. The deny-list is ~25 substrings (password, token, secret, otp, pin, key, cvv, authorization, …), and because matching is substring-based, passwordHash is caught by "password" and accessToken by "token".

libs/shared/src/audit-log/redaction.ts
export const DEFAULT_DENY_LIST = ['password', 'token', 'secret', 'apikey', 'otp', 'pin', 'key', 'cvv', /* ... */];

const isDenied = (key: string) => deny.some((term) => key.toLowerCase().includes(term));
// walks objects/arrays, masks denied keys, guards cycles ('[CIRCULAR]') and depth ('[MAX_DEPTH]')

It runs twice by design — once in the interceptor, and again inside AuditLogService before enqueue, where snapshots are also size-checked. Anything over 64 KB serialized is replaced with a marker so the queue and the table never bloat.

apps/server/src/app/audit-log/audit-log.service.ts
private prepareSnapshot(snapshot) {
  if (snapshot == null) return snapshot;
  const redacted = redactSnapshot(snapshot);
  const serialized = JSON.stringify(redacted);
  if (serialized.length > MAX_SNAPSHOT_BYTES /* 64 KB */) {
    return { _truncated: true, _bytes: serialized.length };
  }
  return redacted;
}

Redaction is key-based, not value-based

A secret hidden inside a free-text string value — say someone pastes a token into a notes field — is not caught, because redaction only inspects key names. Don't rely on it to scrub arbitrary content; rely on it to mask well-named sensitive fields. If a snapshot field can hold raw secrets, shape it out in your afterFrom/map before it ever reaches the snapshot.

Declarative vs manual

The @Audit decorator is for the common case; manual logging covers everything else.

ScenarioUse
CRUD mutation that returns the affected entity@Audit + interceptor
UPDATE / DELETE needing before-state@Audit with loadBefore: byIdFrom(...)
Auth event (login, OTP, token replay)manual logWithMeta (controller)
Permission denialautomatic — PermissionsGuard calls logWithMeta
Impersonation start / stopmanual logWithMeta with IMPERSONATE_START/STOP
Background job, directory sync, API-key requestmanual log (service/processor)

The rule of thumb: if your handler returns the thing it changed, @Audit can capture it. If it doesn't return the entity — a login that returns tokens, a job that returns nothing, an action behind an API key with no JWT actor — log manually.

Logging manually

The classic manual case is an auth event, where the handler returns tokens rather than the affected entity. Log it by hand with logWithMeta so the HTTP context (ip, user-agent, request id) is merged in for you.

apps/server/src/app/auth/auth.service.ts (shape)
this.auditLogService.logWithMeta(
  { type: AuditLogType.ACCESS, action: AccessAuditAction.LOGIN_LOCAL,
    userId: user.id, resourceType: AuditResourceType.AUTH_TOKEN, decision: 'allow' },
  extractRequestMeta(req),
);

From a service, processor, or job — anywhere with no request — use log() directly and pass schoolId yourself if the tenant store isn't set. The denial path is already handled for you: PermissionsGuard logs an ACCESS_DENIED entry with decision: 'deny' whenever a permission check fails, so you never log denials by hand.

apps/server/src/app/auth/guards/permissions.guard.ts
this.auditLogService.logWithMeta(
  { type: AuditLogType.ACCESS, action: AccessAuditAction.ACCESS_DENIED,
    userId: user?.sub, resourceType: requiredPermissions[0].resource,
    decision: 'deny', reason: `Missing permissions: ${denied}` },
  meta,
);

Gotchas

These are the traps that bite people, drawn straight from how the code behaves.

  • @Audit only works on writes that return the entity. The interceptor's after-snapshot comes from the handler's return value. A handler that returns void, a redirect, or just { success: true } gives the interceptor nothing to snapshot — you'll get an audit row, but with no afterState. If the return shape isn't the entity, add afterFrom, or log manually.

  • Never pass raw TypeORM entities into a snapshot. Both loadBefore results and the handler return value can end up as JSONB. Raw entities carry circular relations that break serialization and lazy relations you didn't mean to record. Always map to a plain DTO — that's why afterFrom and the byIdFrom map argument exist.

  • Redaction is key-based only. passwordHash is masked because the key contains password. A secret pasted into a free-text notes value is not masked. Shape secret-bearing fields out in your mapper rather than trusting the deny-list to find them.

  • Never await a log call. log() and logWithMeta() return void and swallow-then-log their own errors on purpose. Awaiting them gets you nothing and couples your request's success to the audit pipeline — exactly what the design avoids.

  • loadBefore failures are silent. If the before-fetch throws (the record is already gone, a 404), the interceptor catches it and proceeds with beforeState = null. Your endpoint still succeeds. Design loadBefore to handle "not found" gracefully and don't count on a before-snapshot always being present.

  • Log after success, not before. You're recording what happened, not what was attempted. The interceptor only fires on a successful return; guards own the denial path. Don't log a manual "attempting X" entry before the work — it'll lie when the work fails.

  • API-key requests have no JWT actor. @Audit's automatic actor resolution reads req.user, which public API-key requests don't have. For those, log manually and carry the integration's identity (request.apiKey) in context.

Where to go next

On this page