Skip to content

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 | timestamp

followed, only when at least one is present, by:

| actingScopeKind | actingScopeId | authorityKind

The 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:

  1. Content recomputation. The hash is recomputed from the entry’s own stored fields and its stored previousHash, and compared against the stored integrityHash. This is what catches an in-place edit.
  2. Link check. Each entry’s previousHash is compared against the preceding entry’s integrityHash. 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.

StateMeaning
VerifiedEvery link and every entry’s content recomputed correctly
TamperingAn entry does not match. Escalate.
No linksFewer than two entries in the window — a pass over an empty window proves nothing, and is not reported as one
Not checkedVerification has not run yet
RunningIn progress
UnknownThe 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

MethodPathPermission
GET/api/admin/audit-logsaudit-logs:read
GET/api/admin/audit-logs/verifyaudit-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.

  • Request Log — gateway traffic, which is not here
  • Licensing — the audit-log entitlement
  • Data Privacy — GDPR export and erasure, which leave the chain intact