Skip to content

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.tsrecord() 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.