Naalya Handbook
Auditing Actions

Auditing Actions

How every write leaves a redacted, append-only trace — captured on the server, written off the request path by a separate audit microservice.

Every meaningful write in Naalya leaves a trace. Who created this staff account? Who revoked that API key, and what did the record look like before and after? The audit log answers those questions. It's an append-only, tenant-scoped table that records who did what to which resource, from where, with before/after snapshots so a reader can see exactly what changed.

This is now a hard convention, not an aspiration: every write endpoint carries @Audit (~216 uses across ~50 controllers). A new resource means one enum entry in AuditResourceType (libs/shared/src/audit-log/audit-log.types.ts — stored as varchar, no migration needed) plus the decorator on each write route. There's also an operator-facing analytics endpoint (getAnalytics: allow/deny split, by-action, top actors, daily timeline) over the same table.

Off the request path

The design has one governing idea: auditing must never slow down or break the request it's recording. A user creating a staff member shouldn't wait on an audit write, and a hiccup in the audit pipeline shouldn't fail their request. So the whole thing is built off the request path — the server captures what happened, hands a job to a queue, and moves on. A separate audit microservice does the actual database insert later.

Three backend concepts the rest of this section leans on:

  • A NestJS interceptor wraps a controller handler — it runs code before the handler and can react to the value the handler returns. Our audit capture lives in one, so it sees both the prior state (fetched before) and the new state (the return value).
  • BullMQ is a Redis-backed job queue. The server is the producer (it adds jobs); the audit app is the consumer (it runs them) — different processes that only agree on a payload shape. (Queues & Messaging goes deeper.)
  • AsyncLocalStorage is how the request's tenant (schoolId) follows the code without being threaded through every call. The audit layer reads it to stamp each row. (Multi-Tenancy explains the store.)

The mental model: capture on the server, write in the audit app

There are two halves. On the server, a decorator marks a handler and an interceptor captures the actor, the action, the resource, and redacted before/after snapshots — then enqueues a job and returns. In the audit app, a processor pulls the job off Redis and does a lean raw INSERT. The two halves only share a payload type in @app/shared. Nothing about the write blocks the user.

The audit_log table

The table is audit_log, mapped by AuditLogEntity. It is deliberately append-only: no updatedAt, no deletedAt, no soft-delete. An audit row is immutable evidence. It's also @TenantScoped(), so every row carries a schoolId and reads are filtered to the current tenant automatically.

apps/server/src/app/audit-log/entities/audit-log.entity.ts
@Entity('audit_log')
@TenantScoped()
@Index(['resourceType', 'resourceId', 'createdAt'])
export class AuditLogEntity implements AuditLog {
  // classification
  @Column({ type: 'varchar', length: 20 }) type: string;     // ACCESS | ACTION | EVENT
  @Column({ type: 'varchar', length: 50 }) action: string;   // CREATE, UPDATE, LOGIN_LOCAL, ...
  @Column({ name: 'resource_type', /* ... */ }) resourceType: string;
  // actor identity (the REAL actor — see below)
  @Column({ name: 'actor_id', /* ... */ }) actorId?: string;
  @Column({ name: 'user_id', /* ... */ }) userId?: string;
  // the evidence
  @Column({ name: 'before_state', type: 'jsonb', nullable: true }) beforeState?: JsonSnapshot | null;
  @Column({ name: 'after_state', type: 'jsonb', nullable: true }) afterState?: JsonSnapshot | null;
  // ...request context: ip, userAgent, requestId, method, path, campusId, createdAt
}

Actor vs subject

Two distinctions worth fixing in your head before you write anything.

  • Actor vs subject. userId is the record being acted upon; actorId / actorEmail / actorName / actorType is the real actor. Usually they're the same person. During impersonation they differ — an admin impersonating a user records actorId = the admin, userId = the impersonated user.
  • Three types of record. ACTION (a CRUD-style mutation), ACCESS (an auth event — login, OTP, denial), and EVENT (a background-job lifecycle). The type tells the families apart; the action is the verb within a family.

The verb vocabulary

The verbs are enums in the shared library. ActionAuditAction holds CREATE, UPDATE, DELETE, REVOKE, SUSPEND, UNSUSPEND, and the DIRECTORY_SYNC_* family; AccessAuditAction holds LOGIN_LOCAL, LOGIN_OTP, ACCESS_DENIED, IMPERSONATE_START, and friends.

resourceType is a plain varchar — AuditResourceType gives a convention vocabulary (staff, student, api_key, auth:token, …) but you can add a value without a migration, because nothing in the schema constrains it to a fixed set.

Where to go next

On this page