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.
@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.
userIdis the record being acted upon;actorId/actorEmail/actorName/actorTypeis the real actor. Usually they're the same person. During impersonation they differ — an admin impersonating a user recordsactorId = the admin,userId = the impersonated user. - Three
types of record.ACTION(a CRUD-style mutation),ACCESS(an auth event — login, OTP, denial), andEVENT(a background-job lifecycle). Thetypetells the families apart; theactionis 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
The @Audit Decorator
How a one-line decorator plus a global interceptor capture the actor, action, and before/after snapshots.
The Audit App
The BullMQ queue and the separate audit microservice that does the only write.
Reading the Log
The read API, read-time diffing, redaction, manual logging, and the gotchas.
Queues & Messaging
The BullMQ layer the audit queue rides on, and how producers and consumers agree on a payload.
Scoped Entities
The eleven entities pinned to a campus academic year, the structural data that deliberately is not, and children that scope through their parent.
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.