Skip to main content

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:

ColumnMeaning
actor_idThe subject who performed the action (user, service account, or agent)
actor_typeKind of subject
actor_emailThe subject's email at the time of the action
actionA short, stable event name, e.g. role_created, kube_token_issued
resource_type / resource_idWhat the action was performed on
detailsStructured, non-secret context (jsonb)
ip_addressCaller's IP
user_agentCaller's user agent
created_atWhen 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_log rows 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:

TierGuaranteeHowExample
TransactionalThe action and its audit row commit together, or neither doesThe store's ...Audited methods (e.g. RoleStore.CreateAudited) write the row and the audit entry in one database transactionrole_created, service_account_created, credential.created, install_token.created, user_client_assigned, team_member_added
Audit-before-actThe credential is only handed to the caller if both an intent row and an outcome row are durableapiutil.Issuance records a *_requested row before minting, mints, then records *_issued (or *_failed) after — the caller must not return the credential unless Run returns nilssh_user_cert_requested → ssh_user_cert_issued / ssh_user_cert_issue_failed; kube_token_requested → kube_token_issued / kube_token_issue_failed
Best-effortThe audit row is written after the action has already committed; a failed write loses the record, not the actionA plain call to record an entry post-hocaudit_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 to false.

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.Issuance pattern.
  • 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_ENABLED is explicitly set to true.