Audit Trail
Tamper-evident, hash-chained audit logging for every policy decision, write, and forget.
CortexDB logs every API operation and every policy decision into an append-only, SHA-256-chained
audit log. The log is always-on (no flag to disable) and exposed via three endpoints under /v1/audit.
For the endpoint shapes, see the Audit API reference.
Set CORTEX_V1_AUDIT_PATH or the audit log is lost on every restart
By default the audit log is kept in memory only: a graceful restart (docker stop -t 120, then
start) empties GET /v1/audit, and the default boot log says nothing about it. Set
CORTEX_V1_AUDIT_PATH to a file on the data volume (for example /data/audit.jsonl) and the server
writes the rows there as JSONL and keeps them across restarts; the boot log then says
v1 audit log: persistent (JSONL) path=…. A path the server can't open falls back to in-memory with an
ERROR log line. This applies with or without an API key. Treat the setting as required for any
deployment that relies on the audit trail.
What's logged
Every request produces an AuditRow:
| Field | Description |
|---|---|
id | Id of the audit row |
ts | When the request was decided |
actor | Caller's ActorId (e.g. user:alice, service:slack-connector) |
token_jti | The PASETO token's jti claim (for revocation correlation) |
tenant_id | Tenant binding from the token's aud claim |
endpoint | HTTP method + path (e.g. POST /v1/experience) |
capability | The capability the request required |
decision | allow or deny; a derived-data re-derivation or unmerge row records its outcome instead (for example DERIVED_REDERIVE:unmerge_dry_run) |
decided_by_tier | Which policy tier decided: deployment, tenant, scope, or actor. A row a route writes for its own decision (a forget's, a re-derivation's or an unmerge's) says handler, which the schema's enum doesn't list |
request | { method, path, body_bytes, body_hash, headers_hash, tenant_pepper_version } |
response_status | HTTP status code returned |
request_id | The request's X-Request-Id (yours when you sent one). The authentication layer's row and a forget's gdpr row carry the id the response returned, which is also on the request's log line; a re-derivation or unmerge row still carries a fresh id of its own |
elapsed_ms | Wall-clock processing time |
gdpr | true on the row POST /v1/forget writes for its own decision; the authentication layer's rows, /v1/erasures included, say false |
row_hash / prev_hash | The SHA-256 hash chain — each row's prev_hash = the prior row's row_hash |
Row shape
The authentication layer's rows carry no scope; the rows a route writes for its own decision do (a forget's, a
re-derivation's, an unmerge's). No row carries event_id. client_ip appears on the authentication layer's row
only when the request carries X-Forwarded-For (its first address), and only callers that may read other actors'
rows see it. The request digest is
request.body_hash (value "sha256:…"), not body_sha256. The row_hash / prev_hash chain
fields are the tamper-evidence the page is about — they're present on every row. Denials are
first-class: every deny row cites the tier and the missing capability, so there are no opaque 403s
(on a dev_local instance, deployment-tier allows short-circuit before a capability is attributed, so
capability can be "").
A POST /v1/forget writes two rows with the same request_id: the authentication layer's row
(capability: "", gdpr: false) and the forget's own decision (capability: "memory.forget",
decided_by_tier: "handler", gdpr: true, with the forget's scope). Send your own X-Request-Id on a
forget to find both, and the request's log line, by one id.
Querying the audit log
audit = client.audit_list(
actor="user:alice",
capability="forget.gdpr",
decision="deny",
limit=100,
)
for row in audit["items"]:
print(f"{row['ts']} {row['endpoint']} → {row['decision']} "
f"(by {row['decided_by_tier']}: {row['capability']})")curl "https://api-v1.cortexdb.ai/v1/audit?actor=user:alice&decision=deny&limit=100" \
-H "Authorization: Bearer $CORTEX_TOKEN" \
-H "X-Cortex-Actor: $CORTEX_ACTOR"
# Fetch a single row
curl "https://api-v1.cortexdb.ai/v1/audit/audit_01HX..." \
-H "Authorization: Bearer $CORTEX_TOKEN" -H "X-Cortex-Actor: $CORTEX_ACTOR"Filtering rows
The list filters are actor, decision and capability. To narrow by endpoint or time, filter on the
rows' endpoint and ts fields on your side.
Tamper checks
POST /v1/audit/verify lets external auditors confirm a row hasn't been altered — pass the row id plus
the exact body you have on file; the server recomputes the SHA-256 chain and reports a match.
result = client.audit_verify(
audit_id="audit_01HX...",
body="<canonicalized JSON of the row you have>",
)
print(result["match"], result["algorithm"]) # True sha256match: false doesn't necessarily mean tampering — it can also indicate the canonical JSON differs
(use the body you fetched from GET /v1/audit/{id} verbatim).
Streaming changes via lifecycle
Audit rows are also surfaced as policy_changed / policy_revoked events on the
lifecycle stream, so you can plug your SIEM into the SSE stream rather
than polling.
Configuration
Audit settings are deployment-preset defaults, not environment variables. Read the values actually
in force with GET /v1/policy/deployment → defaults (flat dotted names such as "audit.retention": "P7D"):
| Preset default | dev_local value | Notes |
|---|---|---|
audit.retention | P7D | ISO-8601 duration; rows older than this are pruned. |
audit.store_body_bytes | true | Whether the request-body digest is recorded for tamper checks. |
audit.pepper_rotation_period | P3M | How often the hashing pepper rotates. |
audit.pepper_retention_buffer | P3M | Grace window kept across a pepper rotation. |
Change them by selecting a different preset with CORTEX_DEPLOYMENT_PRESET — see
Deployment Presets.
The one audit environment variable
The only audit environment variable is CORTEX_V1_AUDIT_PATH (above). Retention and body hashing
come from the deployment preset, so names such as CORTEX_AUDIT_RETENTION_DAYS,
CORTEX_AUDIT_INCLUDE_BODY_HASH or CORTEX_AUDIT_SIEM_ENDPOINT have no effect.
SIEM forwarding and data-subject requests
For SIEM forwarding use the [compliance.siem] config (see
Security & Compliance); there is no one-shot drain route. For a
data-subject deletion request, use /v1/erasures.
With CORTEX_V1_AUDIT_PATH pointing inside the data directory (for example /data/audit.jsonl), a cold
backup of the data directory covers the audit log too.
Multi-Tenancy
How CortexDB isolates tenants — the tenant = token audience model, the cloud_shared_saas preset, per-tenant scopes, and how to provision multiple tenants.
Deployment Presets
How dev_local, on_prem_enterprise, cloud_shared_saas, and cloud_private presets shape a CortexDB deployment's posture.