Access Ledger
This page answers the question a security or compliance reviewer asks: "Can you prove who looked at our data?" It describes the audit surfaces the Control Zero backend actually writes, where they live, how long they are kept, and how a customer gets them out.
Read this first: there is no single "disclosure boundary" table
There is no single disclosure-boundary table where every channel that can release subject-bearing data writes an event before release.
What does exist is a set of five separate sinks, each of which covers a different part of "who did what, and who looked":
| Sink | Table | Covers |
|---|---|---|
| Decision / egress events | audit_logs | Policy decisions on tool calls, gateway requests and browser egress attempts, as reported by connected clients (enrolled machines, API-keyed SDKs, the gateway, the browser extension). A local-only SDK with no API key or enrollment logs locally and does not write here. |
| Control-plane admin actions | admin_audit_log | Configuration changes, auth events, SCIM, billing, retention changes, exports. |
| Args unmask trail | audit_unmask_log | Every time an admin decrypts redacted audit args. |
| Captured-I/O reveal trail | audit_view_log | Every time a user decrypts a captured input/output payload. |
| Captured-I/O purge trail | audit_purge_log | Crypto-shred purges of captured I/O. The scheduled retention worker writes its record after shredding, on a best-effort basis, so a failed write can leave a scheduled shred unrecorded. |
The unmask and view trails implement a record-before-release property: the paired audit row is written and confirmed before the plaintext bytes leave the server, and if the write fails the plaintext is withheld with a 500. Manual purges use the same pattern before the shred: if the record write fails, nothing is shredded. The scheduled retention shred records its row afterwards, on a best-effort basis. That is the closest the product has to a disclosure boundary, but it is spread across three tables rather than one, and there is no single table that enumerates every disclosure channel.
1. Decision events — audit_logs
audit_logs is the main ledger. It lives in a dedicated Postgres audit
database, separate from the transactional database, and that database is the
audit read source. On the self-hosted bundle both databases run on the same
Postgres server by default.
What is recorded
Each row is one decision — allow, deny, warn, or
not_evaluated — or one credential_leak_detected event. The current
ingest seams all write the same table (the legacy POST /v1/sdk/logs route
still writes to it):
- Enrolled CLI SDKs:
POST /api/audit(machine auth). - Hosted SDKs / the gateway LLM proxy:
POST /v1/sdk/audit(API key). - Browser extension:
POST /api/v1/browser/audit. - Legacy hosted SDK seam:
POST /v1/sdk/logs(API key), which mirrors/api/audit.
The SDKs' tool integrations send each call's tool arguments with its audit event by default. What the platform keeps of that content is governed by two opt-in mechanisms, described in "Arguments and captured I/O" below.
What each event contains
The core fields are:
| Field | Meaning |
|---|---|
id | Row UUID. On the CLI-SDK and API-key seams (/api/audit, /v1/sdk/audit) it is client-supplied and is the dedup key, so retried batches are idempotent. Browser-extension rows get a server-generated id, so a retried browser batch is not deduplicated. |
org_id, project_id | Tenancy. Org is always overwritten from the authenticated credential, never trusted from the wire; project is validated to belong to that org. |
agent_id / user_email / hostname / machine_id | Who ran it and on which machine. |
tool_name, method_name, extracted_method | What was invoked (tool, method, and the hook-extractor's resolved method). |
policy_decision | allow / deny / warn / not_evaluated. |
policy_id, policy_source, reason_code | Which policy/rule produced the decision, its provenance (hosted/local/cache-fallback/tamper-quarantine), and the machine-readable reason. |
enforcement_status | Whether enforcement was actually healthy (enforcing, degraded_below_floor, fail_closed, unreported, …). Empty means "unknown", never "healthy". |
dlp_status, dlp_reason, dlp_details | Whether the DLP scan ran and what it found — kept separate so "did not run" is never confused with "ran clean". |
status, error_type, error_message | Outcome beyond the policy verb (success/error/timeout/blocked). |
latency_ms, input_tokens, output_tokens, total_tokens, estimated_cost_usd, model_id, provider | LLM telemetry for proxy rows. |
client_ip, client_name, client_version, controlzero_sdk_version, source, surface, trace_id | Provenance: IP, host CLI, SDK package version, coarse surface class, W3C trace id. |
args_*, input_/output_ciphertext and the io_* columns | Opt-in content (see below). |
credential_* columns | Credential-leak events: pattern id, severity, a one-way keyed per-org fingerprint of the value (16 hex characters) used to group repeat leaks, and a masked context window. The leaked value itself is never stored. |
tags | Structured extras (enforcement outcome, browser-only metadata). |
Arguments and captured I/O (opt-in)
- Arguments are governed by the per-org
audit_redaction_mode:redact(default, values dropped),redact_with_admin_unmask(values sealed with a per-org DEK, admin-unmaskable), orstore_full(plaintext JSON, explicit opt-in). The ingest handler resolves the mode once per batch and defaults toredacton any storage error — "drop values, never leak them". - Captured input/output is off by default and cascaded through org tier, org default, project override, and per-call SDK hint. Before it is sealed, it is run through two redaction passes — one for secret-shaped tokens, one for structured PII — then sealed with a per-record DEK wrapped under the org KEK.
The browser extension goes further: detected values are never stored.
Only a keyed per-device fingerprint of a match is persisted; a non-fingerprinted
value is dropped and marked redacted.
2. Control-plane admin actions — admin_audit_log
admin_audit_log
records who changed the governance configuration and other
security-relevant control-plane actions. Fields: admin_email, action,
target_type, target_id, old_values, new_values (JSONB),
ip_address, user_agent, created_at, trace_id, and actor provenance:
actor_type (user / api_key / system), actor_id, key_id (the API
key that acted, when one did) and source (dashboard / api / cli /
mcp / migration / system).
Action families:
- Authentication:
auth.login.success,auth.login.failure,auth.login.lockout,auth.password.changed, … - SSO:
sso_config.created/updated/deleted,sso.login.success/denied,sso.enforce.*,sso.break_glass.*. - SCIM provisioning:
scim.user.provisioned/deprovisioned/role_changed,scim.auth.failure. - Billing:
billing.subscription.upgraded/changed/downgraded. - Audit retention:
audit_retention_changed. - Report exports:
report_export.queued/cancelled/downloaded. - Account self-deletion:
account.self.deleted.
Secrets are never written: SSO secret fields record only *_set presence
booleans.
3. Who looked at the data — the three "self-incriminating" trails
These are the tables that directly answer "who accessed what customer
data". audit_unmask_log and audit_view_log rows are written before any
decrypted bytes are returned; a manual purge's audit_purge_log row is written
before the crypto-shred runs, while the scheduled retention shred records its
row afterwards, on a best-effort basis.
| Table | Written by | Fields recorded |
|---|---|---|
audit_unmask_log | POST /api/orgs/{orgID}/audit/{auditRowID}/unmask | org_id, actor_user_id, target_audit_row_id, mfa_assertion_id, reason, unmasked_at. |
audit_view_log | POST /api/orgs/{orgID}/audit/{auditRowID}/view-io and POST …/io-capture/reveal | org_id, actor_user_id, target_audit_row_id, viewed_field (input/output), reason, viewed_at. |
audit_purge_log | POST …/io-capture/purge | org_id, actor_user_id, purge_kind, reason, rows_shredded, purged_at. |
Access floors for the two reveal paths are enforced in the handlers:
authenticated org member/admin, a non-empty reason, and an explicit
acknowledged attestation; native-auth callers additionally require a
recent step-up. If the paired row write fails, the request 500s and
no plaintext leaves the function.
4. Where it is stored
audit_logs: a dedicated Postgres audit database, separate from the transactional database (both run on the same Postgres server in the default self-hosted bundle).admin_audit_log,audit_unmask_log,audit_view_log,audit_purge_log: the transactional Postgres database.audit_logsin Postgres is append-only at the database level. A trigger rejects everyUPDATEexcept the captured-I/O crypto-shred, which may only clear the payload key and set the purge flags. It also rejects everyDELETEunless the transaction explicitly opts in. The backend opts in only for org-deletion erasure (GDPR Art. 17 / PIPA Art. 21) and for moving rows between monthly partitions. The opt-in is a session setting, so it stops ad-hoc deletes, not a database operator who sets it deliberately.