Audit log admin read surface — iteration 2 (scope-governed visibility + consumability)
1. Problem distillation
- The "very simple GET" already exists.
GET /audit-log (src/audit-log/audit-log.controller.ts) shipped with the 2026-06-25 base spec: admin-only (@RequireScopes(audit_log, read) + @RequireRoles('admin')), paginated, newest-first, tenant-scoped, with filters entityType / entityId / actorUserId / from / to (ch20 §5). It has never been surfaced to the FE — no FE guide exists.
- The RBAC model is currently lying to the FE. The FE scopes views by
GET /permissions (compiled Record<entity, { scopes, actions }>; nav keys on an entity's scope presence). But audit_log.configuration is granted far wider than the surface: every exclusion-shaped preset matrix — teacher, principal, hr, secretary (via the HR spread), dept_head, curriculum_coordinator — fails to exclude it, so all six roles hold READ on it today and /permissions advertises the audit-log entity to them, while @RequireRoles('admin') 403s everyone but admin at the route. Iteration 2 makes audit_log a normally-governed entity: the scope grant becomes the single source of truth (seeded admin-only, per-role editable via PATCH /roles/:key), and the hard role gate is deleted. Who sees the audit log at the FE is then exactly who holds the grant.
- What the existing endpoint cannot support is an actual admin screen. Three gaps:
- No vocabulary discovery.
entityType / action are free strings owned by consumers (value audit) or structural-audit.constants.ts (structural audit). The FE has no endpoint to populate its filter dropdowns — it would have to hardcode a backend-internal vocabulary that grows with every new audit consumer.
- Actors are filterable only by UUID. An admin knows "Rossi", not
actorUserId. There is no name search.
- Rows are UUID-dense.
entityId is a soft reference; value snapshots (data) carry field values, not names; structural envelopes carry row snapshots full of ids. An admin reading the table cannot tell which student a homeroom_membership.moved row concerns without a second lookup the FE can't cheaply do.
- This iteration therefore does two things: (a) normalizes
audit_log into a scope-governed entity (guard + seed + stale-grant cleanup), (b) adds the consumability layer on the existing surface — an action filter + actor name search on the list, a GET /audit-log/filters vocabulary-discovery endpoint, and a nullable per-row entityLabel resolved server-side — then finally surfaces the whole thing with an FE guide.
- No schema change. One data-only migration (stale-grant cleanup, §4/§6) — required because editable presets never prune on reseed ("RBAC seed never deletes"), and deleting the role gate without the cleanup would silently hand six roles working access to the full trail.
Success criteria (observable behavior that proves this works):
- /permissions advertises audit_log only to roles actually holding the grant — after cleanup + reseed, that is admin alone. Teacher/principal/hr/secretary/dept_head/curriculum_coordinator neither see the entity in /permissions nor reach the routes (403).
- Granting audit_log.configuration READ to another role via PATCH /roles/:key makes the FE view appear and both routes return 200 for that role, with zero code change; revoking hides and 403s it again. The grant IS the FE visibility filter.
- Admin FE builds the audit table's entityType/action dropdowns from one GET /audit-log/filters call, with zero hardcoded vocabulary; the dropdowns automatically pick up any future audit consumer.
- GET /audit-log?search=ros returns only rows whose actorName contains "ros" (case-insensitive); ?entityType=grade_entry&action=grade_entry.deleted narrows to grade deletions.
- Every list row carries entityLabel — the display name of the affected student / subject group / combined class / timetable — or null when the target is unknown or hard-deleted; the FE renders "Mario Rossi" instead of a UUID, falling back to the UUID on null.
- Non-admin callers get 403 on both routes (unchanged for the list, asserted for /filters).
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No write/amend/delete endpoints — the trail stays append-only, in-process-write-only (ch20 invariant).
- No export (CSV/PDF), no retention/archival policy, no notification hooks.
- No second scope on the entity — one configuration scope stays; per-role trail slicing (e.g. "see only attendance events") is not modeled (see §9).
- No field-level masking / per-role redaction of data blobs — the grant is all-or-nothing per role; granting a non-admin role the scope shows it the full trail (privacy call-out in §9 + FE guide).
- historyFor consumer reads are untouched — they live behind their own domain gates.
- No i18n of entityType/action display labels — BE strings are stable keys; the FE owns the display map (see §9).
- No new indexes and no cursor pagination — school-scale volumes on the existing tenant-first indexes (see §4).
2. Patterns survey
| Analogous module/spec |
What we'd borrow |
What doesn't fit |
docs/superpowers/specs/2026-06-25-audit-log-design.md + src/audit-log/ |
The entire base surface: controller, ListAuditLogQueryDto, buildAuditLogWhere, toAuditLogEntryResponse, e2e spec. This iteration extends those files in place. |
Base spec deliberately kept the read side vocabulary-blind and label-blind; that is exactly what this iteration revisits. |
docs/superpowers/specs/2026-07-26-attendance-temporal-d-structural-audit-contract-design.md (spec D) |
The anchor table (§ "entityId is the stable anchor"): for structural events entityId is the history key (studentId / subjectGroupId / combinedClassId / timetable id) — which is precisely what makes server-side label resolution a bounded grouped lookup. |
Spec D governs the write side; it says nothing about reads. This spec adds the read-side consumer of its anchor semantics. |
src/filters/ (table-lists-and-filters v1) |
Two conventions: dropdown-population GETs returning aggregate { data }, and the people-list search param — case-insensitive contains on name fields (filters.service.ts teachers/students). |
src/filters/ structural routes are auth-only/no-PII by design. Audit vocabulary + actor names are admin-only, so the discovery route lives under /audit-log with the list's guards, not in src/filters/. |
src/attendance/ teacher-day names (project_attendance_future_writes_and_day_names) |
Precedent for denormalizing display names into a read DTO server-side (BE resolves names once, FE never joins), with null as the honest fallback when a name doesn't apply. |
Attendance resolves within one domain; here the resolver spans consumer tables from a shared module — ledgered in §7. |
Timetable reads tenant-wide (2026-07-22, project_timetable_teacher_published_read) |
The precedent for this exact move: read routes scope-gated (not role-gated) so any role holding the READ reaches the surface and /permissions tells the FE the truth; writes stay admin-gated elsewhere. Also the precedent for the release discipline (grant changes ⇒ reseed). |
Timetables widened default access (READ granted broadly); audit-log narrows it (grant pulled back to admin by default) — hence the extra stale-grant cleanup migration that timetables never needed. |
3. Architecture mapping
| Primitive |
Apply? |
How |
Justify |
| Tenant scope |
yes |
Every query is tenantId-first (buildAuditLogWhere already leads with it; the vocab groupBy and label lookups take tenantId explicitly). |
Unchanged from base spec; RLS + app-level filter. |
| Academic-year scope |
no |
The trail is tenant-lifetime; time filtering is the existing from/to createdAt range. |
Base-spec decision, unchanged. |
| RBAC entity key |
existing EntityKey.AUDIT_LOG |
No delta to entity-keys.ts. Catalogue copy edit: drop "admin-only" from the entity + scope descriptions (rbac-catalogue.ts) — access is grant-governed now. |
Descriptor-only entity, already seeded (sortOrder 18, single configuration scope, no action key). |
| Scopes |
configuration (descriptor-only, existing) |
Both routes gate on @RequireScopes(EntityKey.AUDIT_LOG, 'read') + @AggregateResponse() only — @RequireRoles('admin') is deleted. The grant is the single visibility filter, because the FE scopes views by /permissions and a role gate stacked on a granted scope makes /permissions lie (the live pre-iteration state). |
Same gating model as every other read surface (timetable-reads precedent); seeded grants make it admin-only by default (§6). |
| Actions |
none |
No new action keys. |
Read-only surface; read is implicit. |
| Service base |
custom (AuditService) |
Extend list(); add listFilters(). No BaseTenantedCrudService. |
Audit is not a CRUD domain; base spec already custom. |
queries.ts shape |
extend audit-log.queries.ts |
buildAuditLogWhere gains action + search; new buildAuditLogVocabulary (maps a groupBy result to the response shape) and resolveEntityLabels (grouped per-type lookups → Map<rowId, string>); toAuditLogEntryResponse gains the entityLabel param. |
Named functions in queries.ts per convention, all unit-testable pure mappers around thin Prisma calls. |
| Error codes |
none new |
Validation errors via class-validator as today. |
No new failure modes; unresolvable labels are null, never errors. |
| DTO conventions |
extend + add under dto/ |
ListAuditLogQueryDto += action?, search?; AuditLogEntryResponseDto += entityLabel: string \| null; new AuditLogFiltersResponseDto (entityTypes: { key, actions[] }[]). |
Additive on the list DTOs (non-breaking); new DTO for the new route. |
| File-backed sub-resources |
n/a |
— |
No files involved. |
| Custom fields |
no |
— |
Not a person/domain entity. |
| Profile completeness |
no |
— |
Not a person entity. |
4. Data model plan
Schema deltas
- None. No new tables, columns, enums, or FKs.
Migration shape
- One data-only migration: idempotent
DELETE of RolePermission rows joining scope audit_log.configuration for preset roles with key <> 'admin' — both the global preset rows (tenantId IS NULL) and every tenant clone. Covers all six stale holders (teacher, principal, hr, secretary, dept_head, curriculum_coordinator).
- Why a migration and not the seed: editable presets are seeded without
prune — reseed adds/updates but never deletes ("RBAC seed never deletes", periodLabel lesson), so the seed-matrix exclusions in §6 only shape future clones. Existing environments need the explicit delete, and it must land with the role-gate removal: gate deleted + stale grants kept = six roles silently gain working access to the full trail.
- Safety: no working access is revoked — the surface always 403'd non-admins, so these grants never did anything except mislead
/permissions. Teacher (frozen, prune: true) would also self-heal on reseed; the migration covers it anyway for determinism.
- Deliberately untouched: tenant-authored custom roles (non-preset). None can have working access today either, but backoffice grant edits are deliberate acts we don't clobber (see §9).
- Ch12 hazard check: additive-schema-free, data-delete-only, idempotent, tenant-loop-free (single set-based statement); RLS not in play (runs as migration role).
Indexes and uniqueness
- No new indexes. Rationale, per filter:
action — every action is namespaced <entityType>.<verb> (ch20 §3 / spec D), so the FE always derives and sends entityType alongside action; the query then enters through the existing (tenantId, entityType, entityId, createdAt) index and filters action on the narrowed set. The FE guide states this pairing explicitly.
search on actorName — tenant-bounded case-insensitive contains; actor cardinality and row volume at school scale don't justify a trigram index. Accepted tradeoff; revisit only if the list measurably degrades.
- Vocabulary
groupBy (entityType, action) — distinct-pair cardinality is tiny (≈30 today); a tenant-scoped scan is acceptable for an admin-only dropdown-population call.
5. API surface
| Verb |
Path |
Decorators |
Request DTO |
Response DTO |
| GET |
/audit-log (regated + extended) |
@RequireScopes(AUDIT_LOG, 'read'), @AggregateResponse() — @RequireRoles('admin') deleted |
ListAuditLogQueryDto + action?: string (exact match), search?: string (case-insensitive contains on actorName) |
PaginatedResponseDto<AuditLogEntryResponseDto>; entry gains entityLabel: string \| null |
| GET |
/audit-log/filters (new) |
same guard pair (scope-gated, no role gate) |
none (no query params) |
AuditLogFiltersResponseDto — { entityTypes: [{ key: string, actions: string[] }] } |
Gating semantics
- Purely scope-gated: any role holding
audit_log.configuration READ reaches both routes; nobody else does. Default seeded holder: admin. Widening/narrowing is a role-grant edit (PATCH /roles/:key or backoffice preset editing), never a deploy.
/permissions is therefore truthful by construction: the FE shows the audit-log view iff the compiled map contains audit_log — the same rule it applies to every other entity.
/audit-log/filters semantics
- Derived from the tenant's actual rows (
groupBy on entityType, action), not from a hardcoded vocabulary union. Consequences, all intended:
- A type/action appears in the dropdown iff at least one row exists — filtering on an empty type is pointless, so nothing of value is hidden.
- New audit consumers (value or structural) surface automatically with zero code here.
- Legacy and envelope rows are indistinguishable at this level (both are just
entityType+action pairs) — correct, since the list filter treats them uniformly.
entityTypes ordered alphabetically, actions alphabetical within each type — stable for the FE.
entityLabel resolution (per returned page, grouped IN lookups)
entityType |
Resolution path |
Label |
student_placement, student_enrolment, homeroom_membership, subject_group_membership, curriculum_selection |
entityId is studentId (spec D anchor) → one Student lookup covers all five |
firstName lastName |
attendance_record |
AttendanceRecord.id → studentId → Student |
firstName lastName |
attendance_day_event |
AttendanceDayEvent.id → studentId → Student |
firstName lastName |
grade_entry |
GradeEntry.id → studentId → Student |
firstName lastName |
subject_group_teacher |
entityId is subjectGroupId → SubjectGroup |
group name |
combined_class_membership |
entityId is combinedClassId → CombinedClass |
class name |
timetable |
entityId is the stable published Timetable id |
timetable name |
| anything else (future consumers) |
— |
null |
- Resolution is fail-soft by construction:
entityId is a soft reference, so a hard-deleted target (or an unknown future type) yields null, and the FE falls back to rendering the UUID. null is a first-class value, never an error.
- Cost per page: group ≤
limit (≤100) rows by resolution path → at most 5 IN queries (students, attendance records, day events + grade entries, groups/classes, timetables), each tenant-scoped. The three row→student paths share the final student lookup.
- The resolver map is one table in
audit-log.queries.ts; adding a future consumer's label is a one-row edit there (documented in the ch20 §9 recipe).
Swagger considerations
action param documents the namespacing convention and instructs pairing with entityType (public copy, FE-facing — no internals).
entityLabel documents nullability and the fall-back-to-UUID contract.
/filters documents that the vocabulary reflects the tenant's recorded history, so an empty trail yields an empty list.
- JSDoc on the new controller method: contract only, per the Swagger-is-public rule.
6. RBAC seed plan
| Seed file |
Delta |
PermissionScope (rbac-catalogue.ts) |
key set unchanged (audit_log.configuration exists); description copy drops "admin-only" on entity + scope rows |
PermissionAction (rbac-catalogue.ts) |
none — read is implicit, no write surface |
ScopeFieldMapping (rbac-catalogue.ts) |
none — descriptor-only entity, no field filtering |
| Role grants (roles.ts) |
Add 'audit_log.configuration' to five exclusion sets: TEACHER_EXCLUDED_SCOPE_KEYS, PRINCIPAL_EXCLUDED_SCOPE_KEYS, HR_EXCLUDED_SCOPE_KEYS (secretary inherits via its ...HR_EXCLUDED_SCOPE_KEYS spread), DEPT_HEAD_EXCLUDED_SCOPE_KEYS, CURRICULUM_COORDINATOR_EXCLUDED_SCOPE_KEYS. Referent + student are allowlist-shaped and already lack it. Admin keeps it via ALL_WRITE. |
expected-preset-grants.ts drift mirror |
updated in lockstep with the matrices above (the drift guard compares DB-vs-code) |
*_SCOPES runtime constant |
none — AUDIT_LOG_SCOPES unchanged in scope-fields.ts |
Release sequencing (one release, three legs): seed exclusions (shapes future clones) + cleanup migration (§4, heals existing environments) + role-gate removal (the behavior change). Shipping the gate removal without the cleanup widens access; shipping only the cleanup breaks nothing (admin keeps working). NEEDS PROD RESEED on top of the migration for the catalogue copy + frozen-role prune.
7. Divergence ledger
| Pattern |
We diverge by |
Reason |
Tradeoff accepted |
Base audit-log spec §6 / ch20 §6: "entity + mode" model (@RequireScopes composed with @RequireRoles('admin')) |
Deleting the role gate; the scope grant alone governs. Supersedes the base spec's gating decision and rewrites ch20 §6. |
The FE scopes views by /permissions; a role gate stacked on a granted scope makes the compiled map unfalsifiable by the FE — six roles are being lied to today. The role-gate belt also made per-role FE visibility undeployable without a code change. |
A mis-edited grant (backoffice) now grants real trail access instead of dead UI. Accepted: that is exactly the intended control surface, and the same trust already applies to every other entity's grants. |
| "The audit module knows nothing about consumers" (ch20 §1, base spec) |
The read side gains a bounded per-entityType label-resolver table referencing consumer tables (Student, AttendanceRecord, GradeEntry, SubjectGroup, CombinedClass, Timetable). |
Without labels the admin surface is unusable (UUID-dense rows); resolving in each consumer would need N endpoints or FE-side join fan-out. |
The write side stays fully ignorant — decoupling where it matters (atomicity, append-only discipline) is intact. Read-side coupling is read-only, fail-soft (null), and localized to one table in queries.ts. Rejected alternative: per-consumer resolver registration via DI token — over-engineered for ~10 types with one consumer each. |
Dropdown-population GETs live in src/filters/ |
/audit-log/filters lives in the audit-log module. |
src/filters/ structural routes are deliberately auth-only/no-PII; audit vocabulary is admin-only and module-owned. Guard parity with the list matters more than module co-location. |
Two homes for filter-shaped GETs; mitigated by reusing the same response conventions. |
Vocabulary as constants (structural side has structural-audit.constants.ts) |
Discovery is data-derived (groupBy over rows), not constants-derived. |
Value-audit vocabulary is consumer-owned free strings with no central registry — the rows are the only complete source; a constants union would silently drift the moment a consumer adds a verb. |
Types with zero rows don't appear in dropdowns (harmless — nothing to filter). |
8. Pushback log
| US says |
Conflicts with |
Proposed instead |
Status |
| "a very simple get to surface entries in the audit log" (chat) |
The exact endpoint already exists in tree (GET /audit-log, base spec 2026-06-25, ch20 §5) — building it again would be a no-op. |
Reframe the ask as the consumability iteration: keep the existing GET as-is, add the three things an admin screen actually lacks (vocabulary discovery, name search + action filter, entity labels), and surface the whole thing with the missing FE guide. |
Resolved — this spec |
| "surface the audit log to the admin only" (chat) |
"the audit log should become an entity with scopes like any others, so that we can filter who sees it at FE" (chat, same thread) — a hard admin gate and grant-governed visibility are different mechanisms. |
Admin-only by default grants, not by role gate: seed the scope to admin alone, delete @RequireRoles('admin'), let PATCH /roles/:key widen per tenant. "Admin only" is the seeded starting state, not a route invariant. |
Resolved — Fabio's second message is the ruling |
9. Deferrals
- Per-role trail redaction / slicing — a role granted
audit_log.configuration sees the full trail: every entityType, every actor, and the data blobs (spec-D projections already keep health out of student_enrolment snapshots, but attendance/grade values are in there). No field masking applies to opaque JSON. Accepted for now: the grant is an all-or-nothing, deliberate act; the role-editor copy + FE guide must say so plainly — follow-up: revisit if a tenant asks for a scoped trail (would mean per-entityType scopes, a real remodel).
- Custom-role stale grants — the cleanup migration skips non-preset roles; a tenant-authored role granted the scope via backoffice keeps it and (post-iteration) gains working access. Acceptable: such a grant was a deliberate admin act — follow-up: none.
- CSV/PDF export — no product ask yet — follow-up: revisit on demand.
- Retention / archival of audit rows — belongs with the parked AY-archival program — follow-up:
project_ay_archival_parked.
- Actor dropdown discovery (distinct
{actorUserId, actorName} pairs on /filters) — search covers the actor-lookup need with less payload; add only if the FE explicitly wants a picker — follow-up: revisit at next iteration.
- BE-served i18n display labels for entityType/action (it/en chip labels) — vocabulary strings are stable machine keys, not messages; FE owns the display map today. If product wants BE-owned labels (consistent with error-i18n direction), that's a small follow-up iteration — follow-up: revisit if FE requests it.
- Deep links from a row to the affected entity's screen — pure FE routing concern;
entityType + entityId (+ label) are sufficient inputs — follow-up: FE guide notes the available keys.
10. Open questions
Blockers requiring user resolution before code starts. Must be empty (all resolved) before sign-off.
11. Verification plan
- Unit specs (
src/audit-log/audit-log.queries.spec.ts, extend):
buildAuditLogWhere — action exact-match branch; search becomes case-insensitive contains on actorName; combinations with existing filters; absent params add no clauses.
buildAuditLogVocabulary — groups (entityType, action) pairs under their type, alphabetical ordering, empty input → empty list.
resolveEntityLabels — anchor types map straight to the student lookup; row-mediated types (attendance record / day event / grade entry) resolve through their row; unknown type → null; missing target row → null; grouping issues one lookup per path, not per row.
toAuditLogEntryResponse — passes entityLabel through, null default preserved.
- E2E specs (
test/audit-log.e2e-spec.ts, extend):
- Gating both ways: teacher/principal-shaped caller without the grant → 403 on both routes AND no
audit_log key in their GET /permissions; after an admin PATCH /roles/:key grants audit_log.configuration READ to a test role → both routes 200 and /permissions gains the entity — no code change, the grant is the switch.
?action= narrows correctly alongside entityType; ?search= matches case-insensitively on actor name and misses non-matching actors.
GET /audit-log/filters returns exactly the seeded vocabulary; 401 unauthenticated.
- List rows carry the expected
entityLabel for a seeded student-anchored event and null for an unknown-type row.
- Drift guards:
expected-preset-grants.ts mirror updated with the five exclusion-set additions (the preset-grants drift spec fails otherwise); rbac-catalogue.drift.spec.ts unaffected (scope key set unchanged).
- Migration audit: ch12 hazard checklist on the cleanup migration's SQL before commit (data-delete-only, idempotent, set-based).
- Manual verification: none beyond e2e — the user runs build/test gates.
- Docs/FE tasks (plan tail): update ch20 §5 + rewrite §6 (entity+mode model is gone — scope-governed like any entity; grants decide FE visibility via
/permissions); §9 recipe note on the label table; write the authoritative FE guide docs/fe-guides/2026-08-04-audit-log-admin-FE-guide.md — none exists yet, so a new file is correct here (first surfacing, not a breaking rewrite). The guide states: key the nav on audit_log presence in /permissions, and the privacy caveat that granting the scope exposes the full trail.
12. Sign-off
- Approved by: Fabio
- Date: 2026-08-04
- Chat reference: "good" ack in chat 2026-08-04 after the RBAC-normalization rework walkthrough; both §10 recommendations (entityLabel IN, cleanup as migration) accepted as recommended.
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.