Audit Log — Cross-Cutting Who-Did-What Trail¶
AuditService is a shared, cross-cutting service, not a domain feature. A domain module injects it and appends one immutable event per mutation inside its own Prisma transaction, so the audit row commits atomically with the change it records. Attendance is the first consumer (chapter 19); grades, rosters, and future write-heavy modules adopt the same pattern. The module also owns one descriptor entity (audit_log) and an admin-only read API.
Spec: 2026-06-25-audit-log-design.md. RBAC primitives: chapter 04.
1. Mental model¶
An append-only trail. One AuditLog row per event; rows are never updated or deleted. There is no domain logic, no lifecycle, no cascade — writing an event is a side-effect a caller opts into, and reading is admin-only.
Two facts make the design simple:
- Atomic with the mutation.
record()takes the caller's transaction handle (Prisma.TransactionClient) and writes through it. If the domain mutation rolls back, so does its audit row — they share one transaction. There is no fan-out, no post-commit queue, no fail-soft branch. - Self-describing & decoupled.
entityTypeandactionare free strings (the audit module knows nothing about attendance). The actor and the changed values are denormalized snapshots captured at write time, so a row stays readable even after the user or the referenced entity is gone.
2. Data model¶
model AuditLog (prisma/schema.prisma), table audit_logs, migration 20260625130406_add_audit_log (applied):
| Field | Type | Notes |
|---|---|---|
id |
uuid PK |
|
tenantId |
uuid FK → tenants, onDelete: Restrict |
tenant-scoped; a tenant with audit rows can't be hard-deleted |
actorUserId |
uuid? FK → users, onDelete: SetNull |
nullable; the user who acted. Goes NULL if the user is later deleted — the trail survives |
actorName |
VarChar(200) |
denormalized name snapshot at write time |
actorRole |
VarChar(200) |
denormalized role snapshot (see §3) |
entityType |
VarChar(100) |
free string, e.g. attendance_record |
entityId |
uuid NOT NULL, no FK |
soft reference — required and UUID-typed, but has no foreign key, so the referenced entity can be hard-deleted without breaking the trail |
action |
VarChar(150) |
free string, e.g. attendance_record.updated |
data |
Json (NOT NULL) |
full value snapshot for this event, shape per entityType |
requestContext |
Json? |
{ requestId } lifted from CLS, or null |
createdAt |
DateTime @default(now()) |
event time; the only ordering key |
Two composite indexes, both tenant-first:
(tenantId, entityType, entityId, createdAt)— backshistoryFor()and the entity-filtered admin list.(tenantId, actorUserId, createdAt)— backs actor-filtered queries.
entityId is the one detail people get wrong. It is not nullable and not a free string — it is a required UUID. What makes it "soft" is the absence of an FK: nothing constrains it to a live row, so deleting the referenced entity leaves the audit rows intact (and still findable by id). Contrast with actorUserId (nullable, SetNull) and tenantId (FK, Restrict).
3. Writing events¶
Two calls, both on AuditService (src/audit-log/audit.service.ts):
// once per request, at the top of the transaction
const actor = await this.audit.resolveActor(tx, ctx);
// once per mutation
await this.audit.record(tx, actor, { entityType, entityId, action, data });
resolveActor(tx, ctx: RecordAccessContext) → AuditActor builds the actor snapshot once and is reused across every record() in the same request. It looks up the acting user's name (firstName + lastName, falling back to the raw userId if the lookup is empty) and derives the role string: 'platform_admin' when ctx.isPlatformAdmin, otherwise ctx.roles.join(','). The returned AuditActor = { userId, tenantId, name, role } carries everything record() denormalizes.
record(tx, actor, params: AuditRecordParams) → void appends one row via tx.auditLog.create. It pulls requestId from CLS (ClsService) and stores requestContext: { requestId } when present (else undefined → column null). Because it writes through the caller's tx, the event is part of the caller's atomic unit — never a separate write.
AuditRecordParams (interfaces/audit.interface.ts): { entityType, entityId, action, data: unknown }. data is the full value snapshot; the consumer decides its shape.
Naming convention (by consumers, not enforced)¶
entityType is a stable string the consumer owns. action is namespaced <entityType>.<verb> — attendance writes attendance_record.created / attendance_record.updated (src/attendance/attendance.service.ts). Nothing in the audit module validates these strings; the convention lives in the consumers.
What goes in data¶
A full snapshot of the meaningful values for this event, not a diff. Attendance, for instance, stores the full cell snapshot (status, time, note, justification, plus denormalized student/subject/room labels) so a history entry is self-contained and readable without joining back to live rows that may have changed or been deleted.
4. Reading history (consumer-facing)¶
historyFor(tenantId, entityType, entityId) → AuditLogEntryResponseDto[] returns every event for one entity instance, newest-first. Domain modules call it to surface a typed value-change history — e.g. attendance's per-cell history endpoint returns { current, history }, where history is historyFor(tenantId, 'attendance_record', id) (src/attendance/attendance.service.ts). This read goes through the non-transactional PrismaService (it's a query, not part of a write).
Mapping to the response DTO is centralized in toAuditLogEntryResponse (audit-log.queries.ts); requestContext is normalized to null when absent.
5. Admin read API¶
GET /audit-log (src/audit-log/audit-log.controller.ts) is the only HTTP surface — read-only, no write endpoints.
Paginated (PaginationQueryDto), newest-first, tenant-scoped. ListAuditLogQueryDto adds optional filters entityType / entityId / actorUserId / from / to; buildAuditLogWhere (audit-log.queries.ts) assembles the where (the from/to pair becomes a createdAt range, inclusive). service.list runs findMany + count in parallel and returns PaginatedResponseDto<AuditLogEntryResponseDto>.
6. RBAC wiring¶
audit_log is a descriptor-only, admin-only entity — see chapter 04.
- Entity key
EntityKey.AUDIT_LOG = 'audit_log'(src/common/constants/entity-keys.ts). - Catalogue entry at
sortOrder 18with a single scopeconfiguration(descriptor-only) and no action key (prisma/seed/rbac-catalogue.ts). - The model is entity + mode, not entity-scope-action: the controller composes
@RequireScopes(AUDIT_LOG, 'read')(the read mode on the scope) with@RequireRoles('admin')and@AggregateResponse(). There is notake/write/etc. action — those (register/take) belong to the separateattendanceentity, not here.
No write action exists because there is no write endpoint — events are only ever appended in-process via AuditService, never over HTTP.
7. Module wiring¶
AuditLogModule (src/audit-log/audit-log.module.ts) imports PrismaModule, declares AuditLogController, and provides + exports AuditService so other modules can inject it. It is registered in src/app.module.ts.
Consumers import from the barrel: import { AuditService } from '../audit-log' (src/audit-log/index.ts re-exports AuditService, AuditLogModule, and the AuditActor / AuditRecordParams types). A consuming module imports AuditLogModule and injects AuditService in its service constructor (attendance does exactly this).
8. Deferred: base-service auto-hook¶
Today, audit emission is explicit — each consumer calls resolveActor once and record per mutation by hand. A design was floated to hang an automatic write-side hook off the shared base service (so any CRUD write would emit an event without per-module wiring). That auto-hook is not shipped. Until it lands, follow the recipe below to wire a module by hand; do not assume writes are audited automatically.
9. Recipe — emit audit events from a new module¶
- Import
AuditLogModulein your module and injectAuditService(from '../audit-log'). - Do your mutation inside a
this.prisma.$transaction(async (tx) => { ... }). - At the top of the tx, call
const actor = await this.audit.resolveActor(tx, ctx)once (you need aRecordAccessContext— thectxyour controller already passes for tenant/user/roles). - After each write, call
await this.audit.record(tx, actor, { entityType, entityId, action, data }): entityType— a stable string for your domain (e.g.grade_record).action—'<entityType>.<verb>'(e.g.grade_record.created).entityId— the UUID of the row you mutated (the soft reference).data— a full snapshot of the meaningful values for this event, including any denormalized labels you'd want readable after related rows change or are deleted.- (Optional) To expose a value-change history for one instance, call
this.audit.historyFor(tenantId, entityType, id)from a read method and return it alongside the current state (see attendance's{ current, history }cell endpoint).
That's the whole integration — no new scope, no new action, no migration. The admin GET /audit-log surface picks up your events automatically (filterable by your entityType).