Admin Audit Log
The audit-log viewer (GET /api/admin/audit-log) is a unified, reverse-chronological read of the
two append-only admin-security tables:
SecurityAuditLogrecords security boundary violations such as forbidden-free-text attempts and sensitive-note access attempts. Rows are tenant-scoped viaorganizationId.SuperAdminBypassLogrecords every time a platform operator (SUPER admin) reads across organizations. Rows carrytargetOrgId(the org reached into) instead of anorganizationIdcolumn.
Route and permission
GET /api/admin/audit-logRequires three middleware layers: requireAuth, requireTenantContext, and
requirePermission("VIEW_QA_REGISTRY"). A token without VIEW_QA_REGISTRY returns 403.
Secret-free DTO
The response is secret-free by contract. The following fields are deliberately omitted from every row regardless of the caller’s permission level:
| Omitted field | Reason |
|---|---|
ipAddress | Forensic PII; not needed for oversight |
userAgent | Forensic PII; not needed for oversight |
requestId | Correlation ID; not needed by the oversight table |
| Attempted value | Sensitive; only the byte-length is surfaced |
attemptedValueLength (an integer or null) tells you that an attempt was made and how long the
blocked value was, without storing the value itself.
Filters
All four filter parameters are optional and independent. Combine them freely.
| Parameter | Type | Description |
|---|---|---|
eventType | string | security_attempt or super_admin_bypass (omit for both) |
actorUserId | integer | filter to one actor (the User.id of the admin or super-admin) |
from | string | ISO date; inclusive lower bound on the event timestamp |
to | string | ISO date; inclusive upper bound on the event timestamp |
An unparseable from or to value is silently dropped rather than returning a 400 error. The
filter is best-effort UX, not a correctness gate.
Pagination
| Parameter | Default | Maximum |
|---|---|---|
limit | 50 | 200 |
offset | 0 | (unbounded) |
The total field in the response counts all matching rows across both tables before the
limit/offset window, so callers can compute the number of pages.
curl "https://localhost:3000/api/admin/audit-log?limit=50&offset=0" \
-H "Authorization: Bearer <accessToken>"{
"total": 142,
"rows": [
{
"id": "sec:clx4z2...",
"kind": "security_attempt",
"eventType": "FORBIDDEN_FREE_TEXT",
"actorUserId": 7,
"targetOrgId": "clx4z2...",
"targetStudentId": 42,
"action": null,
"attemptedPath": "/api/students/42/context-flag",
"attemptedFieldName": "freeText",
"attemptedValueLength": 83,
"occurredAt": "2026-06-25T08:14:22.000Z"
},
{
"id": "bypass:clx4z3...",
"kind": "super_admin_bypass",
"eventType": "BYPASS",
"actorUserId": 1,
"targetOrgId": "clx4z2...",
"targetStudentId": null,
"action": "GET /api/admin/audit-log",
"attemptedPath": null,
"attemptedFieldName": null,
"attemptedValueLength": null,
"occurredAt": "2026-06-25T07:00:01.000Z"
}
]
}The id field is a source-prefixed stable identifier: sec:<id> for security rows,
bypass:<id> for bypass rows. Results are sorted reverse-chronologically by occurredAt; rows
that share a millisecond timestamp are broken by descending id for deterministic pagination (V-6).
Tenant scoping
A regular org admin (with VIEW_QA_REGISTRY) sees:
- Security rows where
organizationIdmatches their own org. - Bypass rows where
targetOrgIdmatches their own org (bypass events that reached into their org).
A super-admin (platform operator with skipTenantFilter = true) sees all security rows and
all bypass rows across every organization, including bypass rows where targetOrgId is null.
Every time a super-admin reads this endpoint, the server appends one SuperAdminBypassLog row
recording the access. That row is itself visible to other super-admins on their next read of this
page. Super-admin reads of the audit log are therefore self-auditing.
Invariants
- Read-only. The handler imports no writer; it never updates, deletes, or upserts any audit
row. The
lint:no-update-on-audit-tablesbuild guard enforces this. - Append-only source tables. Neither
SecurityAuditLognorSuperAdminBypassLogsupports edits or deletions. Every row you see is the original record. - Secret-free by contract (
SPEC-AL-4). Raw IP, user agent, request ID, and attempted values are stripped at the select layer via an explicit allow-list, so a future column addition cannot silently leak into the response. - Tenant-isolated. A regular admin cannot see another organization’s records through any filter or pagination combination.
Related guides
- System QA Checks: the QA registry that shares the
VIEW_QA_REGISTRYpermission - Language Safety: the layer that produces
FORBIDDEN_FREE_TEXTsecurity events - Demo Tenant: operator tooling for demo environments