Tamper-evident audit logging for every policy decision, write, and forget.

Audit Trail

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:

FieldDescription
idUUID of the audit row
tsWhen the request was decided
actorCaller's ActorId (e.g. user:alice, service:slack-connector)
token_jtiThe PASETO token's jti claim (for revocation correlation)
tenant_idTenant binding from the token's aud claim
scopeHierarchical scope path the request targeted
endpointHTTP path (e.g. /v1/experience, /v1/forget)
capabilityThe capability the request required
decisionallow or deny
decided_by_tierWhich policy tier decided: deployment, tenant, scope, or actor
requestBody SHA-256 + safe metadata (no raw secrets)
response_statusHTTP status code returned
event_idEvent written, if any
client_ipRemote address
request_idThe same X-Cortex-Request-ID returned in the response
elapsed_msWall-clock processing time
gdprtrue if this row is part of a GDPR workflow (/v1/erasures, /v1/admin/dsar)

Denials are first-class — every deny row cites both the tier and the missing capability, so there are no opaque 403s.

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&capability=forget.gdpr&decision=deny&limit=100" \
  -H "Authorization: Bearer $CORTEX_TOKEN" \
  -H "X-Cortex-Actor: $CORTEX_ACTOR"

Fetch a single row:

row = client.audit_row("audit_01HX...")

Tamper checks

POST /v1/audit/verify lets external auditors confirm an audit 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 sha256

match: 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.

SIEM drain (enterprise / experimental)

POST /v1/admin/siem/drain initiates a one-shot flush of pending audit rows to a configured SIEM endpoint (syslog or JSON). Useful for batch shipping during maintenance windows. Stability: experimental.

Configuration

VariableDefaultNotes
CORTEX_AUDIT_RETENTION_DAYS365Rows older than this are pruned by a background job
CORTEX_AUDIT_INCLUDE_BODY_HASHtrueRecords SHA-256 of the request body for tamper checks
CORTEX_AUDIT_SIEM_ENDPOINT(none)If set, audit rows are mirrored to this endpoint in real time

All audit rows are kept on the same RocksDB column family used for events, so backups + PITR cover the audit log too.