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.
5. How long it is kept
Two independent windows:
- Per-org retention setting:
organizations.audit_retention_days, default 30. The org owner sets it between 1 and 2555 days (7 years) viaPOST /api/orgs/{orgID}/settings/audit-retention, and every change is recorded inadmin_audit_log. On the Postgres audit store that production runs, no worker currently deletesaudit_logsrows past this window: the per-org retention worker runs only against the retired legacy audit store. Decision rows are removed only by org-deletion erasure (or cold-tier export, when armed); if that erasure fails, the organization deletion still completes and the failure is logged for manual follow-up. - Cold tier — older month partitions of
audit_logscan be moved to object storage as Parquet+zstd, with a verified dual-cloud export before any drop. This is dormant by default (AUDIT_COLD_TIER_ENABLED=truerequired); it is not active unless explicitly armed.
6. Who can read what
- Decision ledger (
audit_logs): authenticated org members, scoped to their org and optionally one project, viaGET /api/audit-log,/api/audit-log/{id},/api/audit-log/stats,/api/audit-log/tools. The list carries metadata only — never ciphertext. - Args unmask: org admin/owner route, plus the reason + attestation floor.
- Captured-I/O reveal: authenticated org member, plus reason + attestation.
- Captured-I/O purge: org owner.
admin_audit_log: platform admins via the admin console. This is Control Zero's own admin surface, not a customer-facing endpoint.
7. How a customer obtains the ledger
- Dashboard audit log — the list/detail pages with filters for decision, tool, machine, surface class, enforcement status, and DLP outcome.
- Report export —
POST /api/orgs/{orgID}/report-exportscreates an async job; completed parts are gzip-compressed CSV (events-part-NNNNN.csv.gz) downloadable with a SHA-256 ETag and an expiry (AvailableUntil). Queue, cancel, and download are recorded inadmin_audit_logon a best-effort basis: a download is streamed before its audit record is written, and a failed audit write does not block it, so a download can occur without a ledger row. - SIEM forwarding: a syslog forwarder, configured deployment-wide by
whoever operates the backend (
SIEM_SYSLOG_ADDR), pushes decisions stored through the CLI-SDK and API-key ingest paths to a collector. Browser-extension rows are not forwarded. It is fire-and-forget: it never blocks ingest, and when its queue is full rows are dropped (and counted in a metric). - Cold reads: when the cold tier is armed, the operator can read cold Parquet objects back with an operator-side tool. No dashboard page or API endpoint serves cold data.
8. Partial implementation — named gaps
- There is no single disclosure-boundary table and no unified
customer-facing endpoint that lists the
audit_unmask_log/audit_view_log/audit_purge_loghistory. The only customer-visible surface of those trails today isio_last_viewed_by/io_last_viewed_at/io_last_viewed_reasonon a decision row that has captured I/O; the full history tables are server-side. - The audit cold tier is dormant by default and is gated behind explicit configuration.
- The audit-list read default range is the last 24 hours; older data
requires an explicit
start_time/end_timeor the retention window.