Audit Logging
Proxima Console keeps a single, platform-wide audit_log table for
non-repudiation of privileged action (ISO 27001:2022 A.8.15) — the record that
answers who did this, when, and from where when an admin, a service account,
or an agent changes something that matters.
This page is the reader-facing description of that trail: what it records, what its integrity machinery actually proves, the three levels of durability different actions get, retention, export, and the queries you can run yourself to check any of this rather than take it on trust. It also names what is not covered yet, because a security-control page that overstates a guarantee is worse than no page at all.
What is recorded
Every row in audit_log carries:
| Column | Meaning |
|---|---|
actor_id | The subject who performed the action (user, service account, or agent) |
actor_type | Kind of subject |
actor_email | The subject's email at the time of the action |
action | A short, stable event name, e.g. role_created, kube_token_issued |
resource_type / resource_id | What the action was performed on |
details | Structured, non-secret context (jsonb) |
ip_address | Caller's IP |
user_agent | Caller's user agent |
created_at | When the row was written |
Two rules govern details:
- Credential material never appears in a row. Credential-issuance entries
record things like the resolved principals, a TTL, or a certificate's SHA-256
fingerprint — a one-way digest that can trace a certificate found on a host
back to the request that produced it, but that cannot itself authenticate
anything. The signed certificate, token, or key is never written to
details. - Only non-secret, structured context goes in. Names, types, IDs, counts — enough to reconstruct what happened without turning the audit trail into a second place secrets can leak from.
Integrity
Three things protect the trail against being quietly rewritten:
Append-only, at the database level. The audit_log_append_only and
audit_log_no_truncate triggers (backed by audit_log_reject_mutation) reject
any UPDATE, DELETE, or TRUNCATE against audit_log — including from the
application's own database role, which owns the table and would otherwise keep
its implicit privileges no matter what is REVOKEd. A BEFORE trigger fires
for the owner too, which is the point: the application role is itself
privileged, and A.8.15 asks that logs be protected from the privileged users
they log.
A hash chain. Every row carries seq (its position in the chain),
prev_hash (the entry_hash of the row before it), and entry_hash (a SHA-256
digest over the row's own content plus prev_hash), computed by the
audit_log_chain trigger via audit_log_compute_chain — never by the
application. Altering or removing a row breaks every hash after it.
A verification function. audit_log_verify_chain() recomputes the chain and
returns one row per problem found — nothing when the chain is intact:
SELECT * FROM audit_log_verify_chain();
An empty result is the good outcome. A content hash mismatch means a row's
fields changed after it was written; a broken link means a row was removed or
inserted out of order.
What this does and does not prove
Be precise about the guarantee:
- It detects modification, deletion, or reordering of
audit_logrows by anyone who does not hold database superuser rights. - It does not stop a database superuser, who can disable the triggers or rewrite history directly in the database.
- It cannot prove that an action which was never recorded did not happen. Absence of a row is not evidence of absence of the act — a code path that never writes an audit entry leaves no trace for the chain to protect. This is exactly why the durability tiers below matter as much as the chain itself: the chain only guards rows that were written.
Durability tiers
Not every audited action gets the same guarantee that the audit row exists at all. There are three tiers:
| Tier | Guarantee | How | Example |
|---|---|---|---|
| Transactional | The action and its audit row commit together, or neither does | The store's ...Audited methods (e.g. RoleStore.CreateAudited) write the row and the audit entry in one database transaction | role_created, service_account_created, credential.created, install_token.created, user_client_assigned, team_member_added |
| Audit-before-act | The credential is only handed to the caller if both an intent row and an outcome row are durable | apiutil.Issuance records a *_requested row before minting, mints, then records *_issued (or *_failed) after — the caller must not return the credential unless Run returns nil | ssh_user_cert_requested → ssh_user_cert_issued / ssh_user_cert_issue_failed; kube_token_requested → kube_token_issued / kube_token_issue_failed |
| Best-effort | The audit row is written after the action has already committed; a failed write loses the record, not the action | A plain call to record an entry post-hoc | audit_log.read, audit_log.exported, version_catalog_sync, chat.tool_approved / chat.tool_rejected, kube_request, kube_probe |
Best-effort is about durability, not about ordering. Two of those entries are
written before the act they record, not after: kube_request and kube_probe
both reach into a customer's Kubernetes cluster, so each row is written at
initiation and carries the impersonated identity the cluster's own audit log
will show. They record that access was authorized and a request was started —
there is no outcome, status or response code on either entry. For kube_probe
(the cluster-onboarding
round-trip) that ordering is load-bearing: the probe runs on the request's
context, so a caller who abandons mid-probe cancels it, and an abandoned probe
still leaves a record.
Why credential issuance needed its own tier. Minting a certificate or token is not a database write, so it cannot share a transaction with a row the way a role or credential record can — a rolled-back transaction cannot un-issue a certificate that already exists. Recording intent first, and withholding the credential unless the outcome is also durable, is the equivalent guarantee for an action a database transaction can't cover.
Most best-effort sites are best-effort because the event is low-value enough
that a lost row isn't worth a transaction. chat.tool_approved /
chat.tool_rejected — a human approving or rejecting an AI tool call — is
different: it's best-effort by necessity, not by choice. The decision
itself lives in Valkey (with a TTL) and the audit entry in Postgres, so no
single transaction can cover both.
Roughly half of all audit call sites are still best-effort — see Known gaps below.
Retention
audit_log is partitioned by month (RANGE on created_at), so an expired
month is removed with one DROP TABLE on its partition rather than a mass
DELETE — which the append-only trigger would refuse in any case.
Retention is controlled by two settings:
PROXIMA_AUDIT_RETENTION_MONTHS— the retention period, default 24 months.PROXIMA_AUDIT_RETENTION_ENABLED— defaults tofalse.
Pruning is off unless an operator explicitly turns it on. With the default configuration, the retention period is set to 24 months but nothing enforces it — rows are kept indefinitely, and the retention worker logs a warning that months past the configured period are being retained rather than removed.
When pruning is enabled, every removed partition is recorded before it's
dropped: audit_log_prune holds one row per pruned partition (row count, time
range, and the archive's location and checksum), and
audit_log_prune_boundary records the specific rows a surviving row still
points at as prev_hash. This is what lets audit_log_verify_chain()
distinguish a sanctioned prune from tampering — a broken link at the start of
the trail is only accepted when a recorded prune accounts for exactly that gap.
Both tables are themselves append-only.
Export
GET /api/v1/audit-log/export produces evidence for handoff — CSV (default) or
JSON via ?format=csv|json — restricted to super admins, since the audit log
has no client linkage and an export of it is not tenant-scopable.
The response carries an X-Audit-Chain-Verified header, and the export runs
audit_log_verify_chain() at export time rather than relying on a cached
result: an export taken while the chain is broken says so in the header, in a
chain_verified=false banner row (CSV) or the integrity object (JSON),
instead of looking identical to a clean one. A verification failure does not
block the export — withholding evidence because it looks tampered with is
backwards; the file reports the problem and remains the artifact an
investigator needs.
Verifying it yourself
These are read-only and safe to run at any time.
Is the chain intact?
SELECT * FROM audit_log_verify_chain();
Empty result = intact.
Did a transactional write actually happen atomically? For any tier-1
(transactional) action, the action row and its audit row share an identical
created_at, because NOW() is fixed for the whole transaction. This query
compares role creation against its audit entry:
SELECT r.name, r.created_at = a.created_at AS same_transaction
FROM roles r
JOIN audit_log a ON a.resource_id = r.id::text AND a.action = 'role_created'
ORDER BY r.created_at DESC LIMIT 10;
Rows created before the transactional pattern was adopted show false (two
separate transactions, milliseconds apart, from a post-hoc write); rows created
after show true.
Known gaps
Stated plainly, so nobody relies on a guarantee this trail doesn't yet make:
- Roughly half of all audit call sites are still best-effort — the audit row is written after the action commits, so a write failure loses the record without affecting the action. It is not yet a database transaction or an audit-before-act pair.
- Fleet agent credential rotate, revoke, and release-identity are best-effort,
not audit-before-act. These change live agent identity but, unlike
certificate/token issuance, do not yet use the
apiutil.Issuancepattern. - Denied requests are not recorded. An HTTP
403(permission denied) does not write an audit row today, so repeated unauthorized attempts leave no trail here. - Pruning is disabled by default. The 24-month retention period is
configured, not enforced, unless
PROXIMA_AUDIT_RETENTION_ENABLEDis explicitly set totrue.