Naalya Handbook
Auditing Actions

The @Audit Decorator

How a one-line decorator plus a global interceptor capture the actor, action, and redacted before/after snapshots — and how to audit a mutation.

@Audit is how you opt a write into the audit log. The decorator itself does almost nothing; the real work lives in a global interceptor that fetches the prior state, reads the actor off the JWT, and assembles a redacted snapshot. This page traces that capture path in the order it actually runs, then shows you how to wire a mutation.

The decorator

@Audit(options) just stamps the handler with metadata under a known key. That's the whole decorator.

apps/server/src/app/audit-log/decorators/audit.decorator.ts
export const Audit = (options: AuditOptions) =>
  SetMetadata(AUDIT_METADATA_KEY, options);

The work happens in the interceptor, which is registered globally in the audit module via APP_INTERCEPTOR. Being global means it sits in front of every handler in the app — but it does nothing unless the metadata is present.

apps/server/src/app/audit-log/audit-log.module.ts
providers: [
  AuditLogService,
  AuditLogRepository,
  { provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
],

Zero overhead on unannotated routes

The interceptor's first move is reflector.get(AUDIT_METADATA_KEY, handler). If there's no @Audit, it returns next.handle() immediately and adds nothing. So a route you never decorate pays nothing. You opt routes in — auditing is never silently on.

loadBefore

For an UPDATE or DELETE, the "after" snapshot isn't enough — you want to know what the record looked like before. The interceptor handles that with an optional loadBefore callback that runs before the handler. Notice it's wrapped in a try/catch: if the before-fetch throws, the request still proceeds, the snapshot just lacks beforeState.

apps/server/src/app/audit-log/interceptors/audit.interceptor.ts
const options = this.reflector.get<AuditOptions>(AUDIT_METADATA_KEY, ctx.getHandler());
if (!options) return next.handle();

let beforeState: JsonSnapshot | null = null;
if (options.loadBefore) {
  try {
    beforeState = await options.loadBefore(ctx, this.moduleRef);
  } catch (err) {
    this.logger.debug(`audit loadBefore failed: ${(err as Error).message}`);
    beforeState = null; // swallow — proceed without a before-snapshot
  }
}
return next.handle().pipe(tap({ next: (rv) => this.safeEnqueue(ctx, options, beforeState, rv) }));

You rarely write loadBefore by hand. The byIdFrom helper builds it for you: it resolves a service from the DI container, reads the id from req.params, and calls a finder method. The fourth argument is an optional map that shapes the result into a plain object.

apps/server/src/app/audit-log/decorators/load-before.helpers.ts
export function byIdFrom<S>(service, method, param = 'id', map?) {
  return (ctx, moduleRef) => {
    const id = ctx.switchToHttp().getRequest().params?.[param];
    if (!id) return Promise.resolve(null);
    const instance = moduleRef.get(service, { strict: false });
    return Promise.resolve(instance[method](id)).then((r) => (map ? map(r) : r));
  };
}

loadBefore must return a PLAIN object, never a raw entity

The value loadBefore resolves to becomes a JSONB snapshot. A raw TypeORM entity can carry circular relations that break serialization — and it may carry lazy relations you never meant to record. Always return a DTO or a hand-mapped object (that's what the map argument is for). The same rule applies to the handler's return value used as the after-snapshot.

Resolving the after-state

When the handler returns successfully, the tap fires and safeEnqueue assembles the payload — wrapped in a try/catch so a problem building the audit entry can never bubble into the user's response. For a DELETE (or captureAfter: false) the after-snapshot is null. If you supplied afterFrom, that function shapes the return value. Otherwise, if the return value looks like an entity (an object with an id), it's used as-is.

apps/server/src/app/audit-log/interceptors/audit.interceptor.ts
private resolveAfter(options, returnValue, isDelete) {
  if (isDelete || options.captureAfter === false) return null;
  if (options.afterFrom) return options.afterFrom(returnValue, undefined as never);
  if (returnValue && typeof returnValue === 'object' && 'id' in returnValue) {
    return returnValue as JsonSnapshot;   // raw return value — shape it with afterFrom
  }
  return null;
}

Resolving the actor

Then it resolves the actor from req.user — the JWT principal that the auth guard attached to the request. The token gives the actor's identity (sub, email, type) and tenancy (schoolId, campusId); the act claim, when present, is the real admin id during impersonation (read by extractRequestMeta as actorId). The tenant is read from the AsyncLocalStorage store first, falling back to the token.

apps/server/src/app/audit-log/interceptors/audit.interceptor.ts
const user = req.user as JwtPayload | undefined;
const meta = extractRequestMeta(req); // ip, userAgent, requestId, method, path, actorId(=user.act)

const payload: AuditLogJobPayload = {
  type: AuditLogType.ACTION,
  action: options.action,
  resourceType: options.resource,
  resourceId: this.resolveResourceId(options, ctx, after),
  resourceLabel: this.resolveLabel(options, after, beforeState),
  decision: 'allow',
  userId: user?.sub,
  actorId: meta.actorId,            // the real admin during impersonation
  actorEmail: user?.email,
  actorType: user?.type,
  schoolId: getTenantContext()?.schoolId ?? user?.schoolId ?? null,
  beforeState: redactSnapshot(beforeState),
  afterState: redactSnapshot(after),
  // ...ipAddress, userAgent, requestId, method, path from meta
};
this.auditLogService.log(payload);

The resourceId falls back through three sources in order: an idFrom override, then req.params[idParam ?? 'id'], then the id on the after-snapshot. The resourceLabel (the human-readable "Jane Doe", "Finance Read-Only") comes from labelFrom, or from labelField picked off the after- (or before-) snapshot.

The actor comes from the JWT principal — and API keys are special

The interceptor reads the actor from req.user, which the JWT guard populates. Public, API-key-authenticated requests carry no JWT actorApiKeyGuard instead attaches request.apiKey = { id, name } so the integration can be identified, and pins the tenant. Because there's no user.sub, those requests are not captured by @Audit's automatic actor resolution; audit them manually with log() and put the key's identity in context. (See API Tokens for the key guard.)

Step 1: Audit a mutation

Say you're adding a REVOKE endpoint to a resource. Stack @Audit alongside @RequirePermissions. The minimum is action and resource; everything else is an optional refinement.

apps/server/src/app/api-key/api-key.controller.ts
@RequirePermissions({ action: Action.UPDATE, resource: Resource.API_KEY })
@Audit({
  action: ActionAuditAction.REVOKE,
  resource: AuditResourceType.API_KEY,
  labelField: 'name',                                   // human label from after-state
  afterFrom: (r) => apiKeySnapshot(r as Partial<ApiKeyEntity>), // shape a safe snapshot
})
@Patch(':id/revoke')
revoke(@Param('id') id: string) {
  return this.apiKeyService.revoke(id);
}

Two choices to understand here:

  • labelField vs afterFrom. labelField: 'name' is the easy path — pick one string field off the after-snapshot for the human-readable label. afterFrom is the powerful one — a function that maps the raw return value into the exact plain object you want stored as afterState. Use afterFrom whenever the handler returns a raw entity, returns more than you want recorded, or returns something whose secret-bearing fields you'd rather drop entirely (note how apiKeySnapshot never includes the raw key or its hash).
  • labelFrom is the function form of labelField, for when the label is computed (e.g. joining firstName + lastName).

Step 2: Add a before-snapshot

A REVOKE or DELETE is more useful with a before-state, so the reader can see what was lost. Add loadBefore with byIdFrom, reusing the same snapshot mapper so before and after have matching shapes — that's what makes the read-time diff clean.

apps/server/src/app/api-key/api-key.controller.ts
@Audit({
  action: ActionAuditAction.DELETE,
  resource: AuditResourceType.API_KEY,
  labelField: 'name',
  loadBefore: byIdFrom(ApiKeyService, 'findById', 'id', (k) =>
    apiKeySnapshot(k as Partial<ApiKeyEntity>),
  ),
})
@Delete(':id')
remove(@Param('id') id: string) {
  return this.apiKeyService.remove(id);
}

For a DELETE, the interceptor forces the after-snapshot to null automatically, so you'll get a before-state and a diff that marks every field removed. There's nothing more to configure.

Where to go next

On this page