Audit Trail
Every administrative change is recorded to an append-only, hash-chained trail. Entries are immutable at the schema level, each carries the hash of the one before it, and a verification pass recomputes both the links and the content — so tampering is detectable rather than merely suspected.
Two things to fix in your mental model before the detail.
Recording is never licensed. The audit-log entitlement gates reading the
trail, not writing to it. On a deployment without it, entries are still being
written and nothing is being lost; only this page and its endpoints are refused.
The console says exactly that rather than going blank.
Gateway traffic is not in here. /v1/* requests are deliberately excluded —
they belong to the Request Log. The audit trail is
about who changed the configuration.
What is recorded
Every mutating admin request: creations, updates and deletions across users, API keys, providers, provider overrides, models, model overrides, routing, guards, budgets, organizations, departments, roles, settings, the licence, cache flushes and backups. Plus sign-in, sign-out, failed sign-in and MFA enablement.
Read-only GET requests are not recorded, with one deliberate exception:
A refusal for scope is audited even on a read. When a request is turned away
because its target is outside the scope the session is acting in, the entry
records access.denied_scope with the reason and the acting scope. Reads are
otherwise invisible, so a refused read would leave no trace at all — and a
refused read is precisely the event worth keeping.
Redaction
Before an entry is stored, the captured request body is walked to a depth of
five and any value whose key matches secret, key, token, password,
credential, cert or authorization (case-insensitive) is replaced with the
literal string [REDACTED].
That is key-name matching, not content inspection. A secret pasted into a field
named something else is stored as written. Bodies are captured only for POST,
PUT, PATCH and DELETE.
The chain
Each entry carries a sequenceNumber, a previousHash and an integrityHash.
The integrity hash is sha256(content + previousHash), where content is a
deterministic pipe-joined string:
tenantId | userId | action | resource | resourceId | timestampfollowed, only when at least one is present, by:
| actingScopeKind | actingScopeId | authorityKindThe conditional append matters: entries written before the scope fields existed hash to exactly the bytes they were written with, so adding a field does not retroactively invalidate history.
The first entry’s previousHash is the literal string GENESIS.
What the hash does not cover
details (including the redacted body), the request block (method, path, IP,
user agent, request ID), apiKeyId, sequenceNumber and expiresAt are
outside the hash. An in-place edit of a stored IP address or a captured body
would not be detected.
The hash covers the load-bearing claim — who did what, to what, when, acting in which scope — and states that boundary rather than implying more.
Verification
GET /api/admin/audit-logs/verify?startDate=…&endDate=…Both parameters are optional and default to the last 24 hours.
Two checks run on every entry in the window, in this order:
- Content recomputation. The hash is recomputed from the entry’s own stored
fields and its stored
previousHash, and compared against the storedintegrityHash. This is what catches an in-place edit. - Link check. Each entry’s
previousHashis compared against the preceding entry’sintegrityHash. This catches a deletion or a reordering.
The verifier uses the same hash function the writer uses, from one shared definition. That is the fix for a real earlier defect: the formula used to live only in the writer and the verifier only walked links, so changing the actor, action or resource of a stored entry left every link matching and went undetected.
The response is the same shape whether it passes or fails:
{ "data": { "valid": true, "totalLogs": 412, "checkedLinks": 412, "brokenAt": null, "dateRange": { "startDate": "…", "endDate": "…" } }}On a failure, valid is false, brokenAt is the ID of the first entry that
did not match, and checkedLinks is how many passed before it — the scan stops
at the first mismatch. A broken chain is still a 200; read valid.
Verification requires deployment scope
Reading the trail is scoped; proving it is not. A scoped session sees a filtered slice of a chain that is deployment-wide and sequential, and in a filtered slice a legitimate gap and a deleted entry are indistinguishable. So an organization administrator can read the trail and cannot verify it.
The refusal is itself audited, as access.denied_scope on the audit-log
resource.
Reading it in the console
The Chain integrity card sits at the top and runs once automatically on load.
| State | Meaning |
|---|---|
| Verified | Every link and every entry’s content recomputed correctly |
| Tampering | An entry does not match. Escalate. |
| No links | Fewer than two entries in the window — a pass over an empty window proves nothing, and is not reported as one |
| Not checked | Verification has not run yet |
| Running | In progress |
| Unknown | The check itself could not complete |
When the filters move after a check has run, the card says so rather than letting a stale verdict stand for a range it did not cover.
Filters: action, area (resource), who, and a date range. Who is a picker over the first hundred users, never a free-text ID field. To follow someone outside that list, open one of their entries and use Only this person.
Columns: When, What happened, Who, Applied to, Result. Only When and What happened are sortable — the server’s sort allow-list does not include the resource field, and offering a control that silently does nothing would be worse than not offering it.
Result translates the HTTP status into auditor language: Applied, Denied (401/403), Rejected (other 4xx), Failed (5xx), or Not recorded. An entry with no signed-in user but an API key reads “API key / not a signed-in user” rather than “Unknown”.
The detail drawer shows the facts, the redacted body, everything else recorded
under details (which is where a scope refusal’s reason appears), and the
entry’s chain position — its sequence number, its own hash and its
predecessor’s.
How entries are written
The server does not write audit entries inline. It enqueues them, and a worker writes them.
That has consequences worth knowing:
- The worker is not optional. Without it running, nothing reaches the trail, however healthy the server looks.
- The writer runs one job at a time, and takes a lock per deployment as well. A hash chain is a read-modify-write over shared state; run it concurrently and several jobs claim the same predecessor and the chain forks.
- If the lock cannot be taken, the entry is still written — without chain hashing. Losing the record entirely would be worse than an unchained one, but such an entry will not verify.
- If the queue is unavailable entirely, the entry is written to the application log instead. Those events never reach the collection and are invisible to this page and to verification.
Retention
Set with AUDIT_LOG_RETENTION_DAYS, default 365. 0 disables expiry.
The expiry stamp is written onto each entry and is immutable, so a window that was set too short cannot be widened afterwards — the entries already stamped will still expire on their original schedule. Decide retention before you accumulate a trail you need.
A malformed or negative value refuses to start rather than silently falling back to a default, because “the retention you configured was ignored” is not something you want to discover from a missing record.
Endpoints
| Method | Path | Permission |
|---|---|---|
GET | /api/admin/audit-logs | audit-logs:read |
GET | /api/admin/audit-logs/verify | audit-logs:read + deployment scope |
Both are gated on the audit-log licence feature.
GET /audit-logs accepts page, limit (capped at 100), sort (timestamp
or action), order, action, userId, resource, startDate and
endDate. It returns whole entries, hashes included — there is no separate
per-entry endpoint because the row already carries everything.
Related
- Request Log — gateway traffic, which is not here
- Licensing — the
audit-logentitlement - Data Privacy — GDPR export and erasure, which leave the chain intact