---
title: "Admin Audit Log"
url: https://docs.growamal.com/en/guides/audit-log/
language: en
---

# 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](#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 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.

```bash
curl "https://localhost:3000/api/admin/audit-log?limit=50&offset=0" \
  -H "Authorization: Bearer <accessToken>"
```

```json
{
  "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 `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.

> **Warning:** 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.

## Related guides

- [System QA Checks](https://docs.growamal.com/en/guides/qa-checks/): the QA registry that shares the `VIEW_QA_REGISTRY` permission
- [Language Safety](https://docs.growamal.com/en/guides/language-safety/): the layer that produces `FORBIDDEN_FREE_TEXT` security events
