Skip to Content

Admin Audit Log

The audit-log viewer (GET /api/admin/audit-log) is a unified, reverse-chronological read of three append-only sources:

  • SecurityAuditLog records security boundary violations such as forbidden-free-text attempts and sensitive-note access attempts. Rows are tenant-scoped via organizationId.
  • SuperAdminBypassLog records every time a platform operator (SUPER admin) reads across organizations. Rows carry targetOrgId (the org reached into) instead of an organizationId column.
  • student_data_access_log (kind student_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 via organizationId. See Student data access rows.

Route and permission

GET /api/admin/audit-log

Requires 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 fieldReason
ipAddressForensic PII; not needed for oversight
userAgentForensic PII; not needed for oversight
requestIdCorrelation ID; not needed by the oversight table
Attempted valueSensitive; 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.

ParameterTypeDescription
eventTypestringsecurity_attempt, super_admin_bypass or student_data_access (omit for all three)
actorUserIdintegerfilter to one actor (the User.id of the admin or super-admin)
fromstringISO date; inclusive lower bound on the event timestamp
tostringISO 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

ParameterDefaultMaximum
limit50200
offset02000 (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 fieldFrom the access row
eventTypeaccessKind (for example export_csv, student_page_view, student_list_view)
actorUserIdthe teacher, principal, manager or admin
targetOrgIdorganizationId
targetStudentIdthe one subject child for a single-child view; null for a list or an export
actionsurface, the route template (never a raw URL)
actorPersonathe persona at access time (TEACHER, PRINCIPAL, MANAGER, ADMIN)
subjectStudentCounthow many named children the response exposed
targetClassIdthe 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 organizationId matches their own org.
  • Bypass rows where targetOrgId matches their own org (bypass events that reached into their org).
  • Student data access rows where organizationId matches 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-tables build guard enforces this.
  • Append-only source tables. Neither SecurityAuditLog nor SuperAdminBypassLog supports 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_access rows 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.

  • System QA Checks: the QA registry that shares the VIEW_QA_REGISTRY permission
  • Language Safety: the layer that produces FORBIDDEN_FREE_TEXT security events