Skip to content

Audit

Partial

The audit log answers “who changed this, and to what?”: every mutation is recorded once, at the source.

audit_log is ground truth (not derived): one row per mutation, carrying actor, verb, resource, resource_id, and the old -> new diff.

  • Write-time mandatory. Every API write emits one audit_log in the same transaction as the data write, a storage-layer responsibility, so it cannot be forgotten or bypassed.
  • resource_id is the primary key, never the name. A named entity is addressable two ways (ADR-0062), by its uuid or by its renameable handle, so recording whichever reference the caller happened to use would give one entity two audit keys and orphan every row keyed on a name the moment somebody renamed it. The name at the time of the action is not lost: it is in the old and new images, which is a point-in-time snapshot rather than a lookup key, exactly as actor_username is snapshotted beside actor_principal_id. A guard reads every writeAuditRes call site and fails on an argument that is not primary-key shaped, which catches the two ways this went wrong (a name, and a dual-accept route’s reference) without being able to prove that a given uuid is the right table’s. The remaining gap is narrow and known: a credential row is keyed on its principal’s uuid rather than its own.
  • The actor is resolved by IAM (identity and access): the human, service, or node. The read resolves it to the actor’s identifier, a human’s username, a service account’s name, or a node’s name, through the gateway’s one resolution (ADR-0110), falling back to the snapshot on the row once that principal is purged. All three profiled kinds resolve, so only a principal with no profile row at all reads empty; a node actor read blank until #738 made node.name the third source. Nothing here surfaces a raw uuid where a name was expected.
  • An AI-accepted suggestion is one row. An AI tool acts via OAuth as a human or service principal, so the actor is that principal; the AI-sourced marking rides alongside the row (AI).
  • Secret decrypts are always audited and never filterable. Every read of secret material emits an audit_log row (a credential decrypt), and that subset cannot be filtered away.
  • Other reads are not audited at the storage layer.

GET /audit-log is gated by the admin-sensitive audit:read:admin, out of a two-token wildcard’s reach, so only admin and owner see the security trail. Rows return newest first, filterable by resource and verb, paged backward with before plus limit (default 100, capped at 500), each carrying the old and new images the write recorded: a create only new, a delete only old, an update both, auth events neither. Redaction is the write side’s (sealed secret material and credential hashes never enter an image); the read passes images through verbatim.

Auth events ride a second write lane: login and logout run on read / no-transaction paths, so they emit through a standalone non-transactional seam (WriteAuthEvent) under resource = 'auth', with the verbs login, logout, login_failed (a wrong password on a real account), login_denied (a correct password on a disabled account), login_locked (an attempt inside the lockout window), and revoke_session (an admin ending another principal’s session). An impersonated action records both actors (actor_principal_id the impersonated principal, real_actor_principal_id the admin behind it); both identifiers are denormalized onto the row (actor_username, real_actor_username) with the foreign keys going ON DELETE SET NULL, so the trail stays attributable after its actor is purged (ADR-0016). What gets written there is the actor’s identifier, a human’s username, a service account’s name, or a node’s name, and the platform’s answer to which one belongs to the gateway rather than to the schema: the stored function that used to resolve it retired, so the order is stated once in Go and rendered into the statements the gateway binds (ADR-0110).

Audit carries the longest retention of any ground-truth log (compliance) and is append-only by construction.