Skip to content

The audit log

For an access control system, "who granted which permission to whom, and when" is not a nice-to-have. It is the question the system exists to be able to answer, and it is the one thing that cannot be added later — history not written is simply gone.

Every state-changing request writes one row. There is no way to turn it off, and no endpoint that writes to it: the request being recorded is its only author.

What counts as state-changing

Any non-GET request to /admin/*, /entity/*, /api/v1/auth/*, or /oauth2/revoke — plus GET /oauth2/logout, which is a GET by specification and still destroys a session.

Reads are not recorded. An audit log nobody can read through is one nobody reads, and GET volume would bury the writes.

POST /oauth2/token is deliberately excluded: a refresh happens every few minutes for every active session, and the sign-in that authorised it is already in the log.

What a row holds

Field Notes
action Method plus route templatePOST /admin/users/{user_id}/roles. The template groups; the resolved path would not.
target The object acted on, taken from the path.
statusCode Refused attempts are recorded too. A wall of 403s from one actor is the signal you want.
actorUserId, actorLabel, actorClient Who, by id, by email at the time, and through which application.
ip The socket address. X-Forwarded-For is recorded in the detail but not trusted — a header the caller sets is a claim, not an observation.
detail The request body with secrets removed, plus path parameters.

Two design points

Deleting a user does not erase what they did. The actor reference is nulled rather than cascaded, and their email is copied onto each row at the time it is written. After the account is gone, that label is all that is left of who this was — and it is enough.

Secrets never reach a row. Keys named password, clientSecret, token, code, codeVerifier and their relatives are replaced before the row is built, compared with punctuation stripped so client_secret and clientSecret are the same key.

Reading it

GET /admin/audit under admin:audit:read, newest first, filtered by actor, action substring, and a time range. There is no write scope, because there is nothing to write.

One known gap

The row is written just after the change, from a separate transaction — so if the database becomes unreachable in between, the change stands and the record is lost. The failure is logged loudly rather than swallowed. Tracked as KI-17 on the roadmap.