Audit log — generic "who did what when" trail
1. Problem distillation
- K-12 domain needs a durable, tamper-evident answer to "who did what, to which record, when" — first for attendance (a legal register), soon for grades, notes, roster changes.
- This is a cross-cutting forensic concern, not a per-domain feature. Each domain re-inventing a
*_Event table gives no single cross-system view and duplicates the pattern.
- It is distinct from a domain's compliance snapshot: the snapshot describes what a record is (lives on the domain row); the audit trail describes what happened to it over time (this module). Conflating them is the trap.
- The trail must be immutable (append-only), atomic with the mutation it records (no orphaned/missing entries), and legally reconstructable (full value at each point, not fragile diffs).
Success criteria (observable behavior that proves this works):
- A service mutation can call AuditService.record(tx, actor, { entityType, entityId, action, data }) inside its own transaction; the audit row commits iff the mutation commits.
- The row captures actor identity (snapshotted name + role), the entity touched (soft reference, survives the entity's deletion), the action, a full typed value snapshot, and a timestamp.
- An admin can query GET /audit-log?entityType=&entityId=&actorUserId=&from=&to= and reconstruct a record's change history in order.
- A non-admin receives 403 from GET /audit-log.
- There is no route to update or delete an audit row.
- The log row survives hard-deletion of the entity it references (no FK cascade reaches it).
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No automatic capture via a global interceptor or BaseTenantedCrudService hook — emission is explicit at mutation sites in v1 (the base-service convenience hook is designed below but not built until its first fitting consumer).
- No retrofit of existing modules (students/teachers/staff/etc.) — attendance is the only emitter in v1.
- No audit-viewer SPA/UI — JSON API only.
- No diff/patch storage — full value snapshot per event.
- No retention/purge policy — kept indefinitely (legal retention).
- No per-row RBAC narrowing beyond admin-only read.
2. Patterns survey
| Analogous module/spec |
What we'd borrow |
What doesn't fit |
src/logger/ (nestjs-pino + CLS request context) |
The CLS request context is the source of requestId / IP / user-agent for the optional requestContext column; actor identity comes from the caller's AccessContext |
The logger is an observability stream (ephemeral, unindexed), not a queryable durable store with RBAC — the audit log is a first-class table |
src/timetables/ (entity TIMETABLES, descriptor-only scope, admin-only, no field-filtering) |
The "single descriptor scope + admin-only role allowlist + bespoke (non-BaseTenantedCrudService) service" shape |
Timetables is AY-scoped read/write CRUD; the audit log is append-only and not AY-scoped |
src/invitations/ (Invitation polymorphic ownerType/ownerId, no FK to the owner) |
The polymorphic soft-reference pattern: entityType + entityId with no FK, so the referenced entity can be deleted without breaking the row |
Invitation rows are mutable lifecycle rows; audit rows are immutable |
src/common/services/base-tenanted-crud.service.ts update() in-transaction side-effect hooks (ctx._previousInvitationEmail snapshot → post-update side-effect within the same $transaction) |
The "side-effect participates in the caller's transaction" discipline — record(tx, …) takes the tx client so atomicity holds |
The base service owns its own tx and is per-entity CRUD; AuditService is a stateless cross-cutting writer invoked from any tx |
3. Architecture mapping
| Primitive |
Apply? |
How |
Justify |
| Tenant scope |
yes |
Every AuditLog row carries tenantId (FK Restrict); the read endpoint filters tenantId from @TenantId(); record() takes tenantId from the caller's AccessContext |
Audit is tenant-local |
| Academic-year scope |
no |
n/a — the log is system-wide and ordered by createdAt; AY-scoped consumers (attendance) put academicYearId in their data payload if they want to filter by it |
The trail spans all entities and is not a time-scoped domain relation |
| RBAC entity key |
new |
Add AUDIT_LOG: 'audit_log' to src/common/constants/entity-keys.ts |
New read surface needs an entity to gate on |
| Scopes |
new |
One descriptor-only scope configuration on audit_log (mirrors timetables/evaluation_scales: empty field array, registered in FLAT_DTO_ENTITIES, no FIELD_MAPPINGS entry); route reads gate with @RequireScopes(AUDIT_LOG, 'read') |
Admin-only; no sensitive/non-sensitive split, so per-field filtering is meaningless |
| Actions |
none |
Append-only; there is no user-facing write route. record() is internal infrastructure, not a @RequireAction route |
Users never "create/update/delete" audit rows |
| Service base |
custom AuditService |
Stateless record(tx, actor, params); not BaseTenantedCrudService (it is not entity CRUD). A queries.ts holds the read filter builder |
Append-only writer + one read query |
queries.ts shape |
include/select const + buildAuditLogListArgs(filter) |
List query with entityType/entityId/actorUserId/from/to + pagination |
Single read path |
| Error codes |
none |
Read is a plain list; record() is internal and throws nothing domain-specific |
No new ErrorCode |
| DTO conventions |
dto/audit-log-entry-response.dto.ts (flat, not scope-grouped — descriptor-only), dto/list-audit-log-query.dto.ts (extends PaginatedListQueryDto) |
Standard list-query + flat response DTO |
Matches descriptor-only entities (timetables) |
| File-backed sub-resources |
n/a — no files |
|
|
| Custom fields |
no |
Not in CUSTOM_FIELD_ENTITY_KEYS |
Infrastructure table |
| Profile completeness |
no |
n/a |
|
4. Data model plan
Schema deltas
- New table
audit_logs:
id uuid PK
tenantId uuid — FK → Tenant, onDelete: Restrict
actorUserId uuid? — FK → User, onDelete: SetNull (user persists, but be safe)
actorName string — snapshot (survives user rename/deactivation)
actorRole string — the active profile/role at action time (e.g. teacher, admin)
entityType string — e.g. attendance_record (no enum — see Divergence)
entityId uuid — soft reference, no FK (entity may be hard-deleted)
action string — namespaced verb, e.g. attendance_record.created / .updated / .justified / .admin_override
data json — full value snapshot at this point, typed per consumer
requestContext json? — { requestId, ip, userAgent } from CLS (best-effort)
createdAt DateTime default now()
Migration shape
- Additive — one new table, no enums, no existing-column changes, no backfill.
- Hazards from chapter 12 checklist: none (pure additive table; no destructive ops, no enum diff).
Indexes and uniqueness
@@index([tenantId, entityType, entityId, createdAt]) — the record-history query (cell modal / per-record trail).
@@index([tenantId, actorUserId, createdAt]) — the "what did user X do" query.
- No unique constraints (append-only; duplicates by design are impossible per
id).
5. API surface
| Verb |
Path |
Decorators |
Request DTO |
Response DTO |
| GET |
/audit-log |
@RequireScopes(AUDIT_LOG, 'read'), @RequireRoles('admin') |
ListAuditLogQueryDto (entityType?, entityId?, actorUserId?, from?, to?, pagination) |
PaginatedResponseDto<AuditLogEntryResponseDto> |
Internal (not a route): AuditService.record(tx, actor, { entityType, entityId, action, data }) — actor derived from the caller's AccessContext (actorUserId, actorName, actorRole, tenantId); requestContext read from CLS inside record().
Swagger considerations
data and requestContext are free-form JSON — document as type: 'object' with a note that data's shape is per-entityType (the consumer's payload contract). This is one of the rare legitimate type: 'object' response fields (genuinely polymorphic forensic payload), called out in the Divergence ledger.
6. RBAC seed plan
| Seed file |
Delta |
SCOPES / PermissionScope (rbac-catalogue.ts) |
new: audit_log.configuration (descriptor-only) |
ACTIONS / PermissionAction (rbac-catalogue.ts) |
none |
FIELD_MAPPINGS / ScopeFieldMapping (rbac-catalogue.ts) |
none (descriptor-only — no entry, like timetables.configuration); register audit_log in FLAT_DTO_ENTITIES |
| Role grants (roles.ts) |
admin → (audit_log, configuration) read. No other role. |
*_SCOPES runtime constant |
add AUDIT_LOG_SCOPES = { configuration: [] as const } to src/common/constants/scope-fields.ts |
entity-keys.ts |
add AUDIT_LOG: 'audit_log' |
7. Divergence ledger
| Pattern |
We diverge by |
Reason |
Tradeoff accepted |
BaseTenantedCrudService for entities |
Custom stateless AuditService (append-only record() + one read) |
Not entity CRUD; immutable log |
Lose generic CRUD scaffolding (not needed) |
FK discipline (interfaces/ Prisma payloads, real FKs) |
entityId is a soft reference with no FK |
The referenced entity can be hard-deleted; audit must survive it |
Cannot JOIN to the live entity; consumers resolve via entityType+entityId |
Typed EntityKey constants for entity refs |
entityType and action are free strings, not enums |
A future domain adopts the log with zero migration |
Weaker compile-time guarantee; mitigated by a TS const map of known (entityType, action) strings per consumer |
| Services own their transaction |
record(tx, …) accepts the caller's tx client |
Atomicity: audit row commits iff the mutation commits |
Caller must pass tx; calling outside a tx is a misuse (lint via the typed signature) |
Response DTO fields reference real DTO classes (no type: 'object') |
data/requestContext are genuine JSON blobs |
Forensic payload is polymorphic per consumer |
Documented exception; FE keys off entityType |
8. Pushback log
| US says |
Conflicts with |
Proposed instead |
Status |
(initial design) attendance's value history lives in a bespoke AttendanceRecordEvent table |
Duplicates a cross-cutting concern; no system-wide view |
Single generic AuditLog; attendance has no own history table |
Resolved (chat 2026-06-25) |
| (initial design) surface attendance cell history with the generic audit model |
User wants the cell modal to be a domain-shaped read, not raw audit rows |
Separate write source of truth (audit log) from read presentation (attendance renders a typed projection over the same rows) |
Resolved (chat 2026-06-25) |
(initial design) auto-wire emission into BaseTenantedCrudService now |
Attendance is bespoke and won't extend the base service → nothing in v1 would exercise the hook (speculative abstraction) |
Ship the record() primitive (proven by attendance); design but defer the base-service hook to its first fitting consumer |
Resolved (chat 2026-06-25) |
9. Deferrals
- Base-service auto-emit hook (opt-in
auditConfig on BaseTenantedCrudService emitting created/updated/deleted via existing lifecycle hooks, calling record()) — designed in shape, not built — follow-up: first standard CRUD entity that adopts audit (grades / students retrofit).
- Global interceptor auto-capture — deferred indefinitely; explicit emission preferred for intentionality.
- Audit-viewer UI — deferred; JSON API only in v1.
- Retrofit existing modules — deferred; adopt incrementally.
- Diff-based storage / value-replay — rejected in favor of full snapshot (legally clearest).
- Richer
requestContext — best-effort { requestId, ip, userAgent } only.
10. Open questions
- None — all resolved in chat 2026-06-25.
11. Verification plan
- Unit specs:
src/audit-log/audit.service.spec.ts — record() writes exactly one row inside the passed tx; snapshots actorName/actorRole from the AccessContext; pulls requestContext from CLS; never updates/deletes. src/audit-log/audit-log.queries.spec.ts — filter builder honors entityType/entityId/actorUserId/from/to + pagination + createdAt ordering.
- E2E specs:
test/audit-log.e2e-spec.ts — admin reads and filters; non-admin → 403; no update/delete routes exist (404/405); row survives a referenced entity's deletion. Cross-module proof (a real mutation emits a row in-tx) is covered by the attendance e2e.
- Manual verification: take an attendance action →
GET /audit-log?entityType=attendance_record&entityId=… shows the create/update events in order.
Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md.
12. Sign-off
- Approved by: Fabio Barbieri
- Date: 2026-06-25
- Chat reference: design walkthrough with Fabio, chat 2026-06-25 (audit-log separated from attendance as a foundational dependency; approved alongside the attendance spec).
Until this section is filled, no implementation code is written.