Admin Audit Log
The audit-log viewer (GET /api/admin/audit-log) is a unified, reverse-chronological read of
three append-only sources:
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.student_data_access_log(kindstudent_data_access) records every view or export of named children: teacher assessment exports, principal / manager / admin student pages, lists and exports. Rows are tenant-scoped viaorganizationId. See Student data access rows.
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, super_admin_bypass or student_data_access (omit for all three) |
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 | 2000 (above it: 422) |
The total field in the response counts all matching rows across the three sources before the
limit/offset window, so callers can compute the number of pages. Each source is read up to
offset + limit rows before the merge, which is why offset is capped.
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, access:<id> for student data access rows. Results are sorted
reverse-chronologically by occurredAt; rows that share a millisecond timestamp keep a fixed order
(security, then bypass, then access; descending id within a source) for deterministic pagination
(V-6). GET /api/admin/audit-log/{id} reads one event by that id (the deep link), with the same
tenant scoping; an unknown, out-of-scope or malformed id is a 404.
Student data access rows
Every response that shows or exports named children writes one student_data_access_log row
before it is served (a teacher’s assessment export, a principal’s student page or class export, and
so on). The audit log lists them as the kind student_data_access (decision D-R12), so an admin can
see who looked at or exported which class’s children, and when. Each row maps onto the shared DTO:
| DTO field | From the access row |
|---|---|
eventType | accessKind (for example export_csv, student_page_view, student_list_view) |
actorUserId | the teacher, principal, manager or admin |
targetOrgId | organizationId |
targetStudentId | the one subject child for a single-child view; null for a list or an export |
action | surface, the route template (never a raw URL) |
actorPersona | the persona at access time (TEACHER, PRINCIPAL, MANAGER, ADMIN) |
subjectStudentCount | how many named children the response exposed |
targetClassId | the class the view or export was scoped to (null without a class scope) |
actorPersona, subjectStudentCount and targetClassId are null on the two older kinds. The
access row’s filters, filtersHash, queryLength, requestId, schoolId, macroDomainKey,
assessmentWindow and rowCount are never returned.
The same rows appear on the activity screen (/admin/activity, where /admin/audit-log
redirects) under the «الأمان» tab, as the source access. Until the screen’s follow-up names the
kind, they show there with a generic label.
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). - Student data access rows where
organizationIdmatches their own org.
A super-admin (platform operator with skipTenantFilter = true) sees all security rows,
all bypass rows and all student data access 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.
Update (WP-TDV3 S8, D-R12):
student_data_accessrows now read «الاطلاع على بيانات الطلاب أو تصديرها» with the source «سجلّ الاطلاع على بيانات الطلاب» and their own kind-filter option; the detail shows the access kind (e.g. «تصدير ملف CSV») and the number of children.
Related guides
- System QA Checks: the QA registry that shares the
VIEW_QA_REGISTRYpermission - Language Safety: the layer that produces
FORBIDDEN_FREE_TEXTsecurity events