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.
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 |
decided_by_tier | Which policy tier decided: deployment, tenant, scope, or actor |
request | { method, path, body_bytes, body_hash, headers_hash, tenant_pepper_version } |
response_status | HTTP status code returned |
request_id | The same X-Cortex-Request-ID returned in the response |
elapsed_ms | Wall-clock processing time |
gdpr | true if this row is part of a GDPR workflow (/v1/erasures) |
row_hash / prev_hash | The SHA-256 hash chain — each row's prev_hash = the prior row's row_hash |
Row shape corrections (v0.9.9)
The audit row does not carry a scope, event_id, or client_ip field, and 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 "").
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"The endpoint filter is a no-op
Filter by actor and decision (both work). The endpoint filter is ignored on v0.9.9 — rows for
other endpoints come back regardless.
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 (verified live on v0.9.9, dev_local preset):
| 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 CORTEX_AUDIT_* env vars in the v1 docs do not exist
v1 documents CORTEX_AUDIT_RETENTION_DAYS (365), CORTEX_AUDIT_INCLUDE_BODY_HASH and
CORTEX_AUDIT_SIEM_ENDPOINT. None of those names is read by the server — verified against the
shipped v0.9.8 and v0.9.9 binaries, which contain no CORTEX_AUDIT_* variable at all. Setting them
does nothing, and the "365 days" default was never in force (dev_local resolves to P7D). There is
likewise no SIEM endpoint setting — forward audit rows off the lifecycle stream instead.
SIEM one-shot drain and /v1/admin/dsar are not shipped
POST /v1/admin/siem/drain and /v1/admin/dsar both return 404 on v0.9.9 — the documented one-shot
SIEM flush and the dsar endpoint do not exist. For real-time SIEM forwarding use the
[compliance.siem] config (see Security & Compliance); the
GDPR erasure path is /v1/erasures only.
All audit rows live on the same RocksDB column family as events, so a verified 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.