Control Tower v0.2.1

Audit log

Enterprise: the audit log needs a Control Tower Enterprise license. Nothing is recorded without one.

The audit log records every change made in Control Tower, and every attempt that was refused. It covers people in the console, scripts using the admin key, and single sign-on. Admins read it under Audit log.

The audit log, verified

What's recorded

EventWhen
Every change through the admin APICreating, changing or deleting anything: providers, models, keys, gates, zones, approvals, people, alerts, exports, guardrails, imports. Also approving or denying a held call, whether in the Tower or after following a Slack or email link
Refused attemptsSomeone not signed in, a viewer or approver trying something their role doesn't allow, a missing CSRF header, a person who hasn't yet replaced their one-time password. Refusals of people who aren't signed in are capped at 20 a minute per address, so a scanner can't flood the log
Sign-insSuccessful and failed, with password or single sign-on; sign-outs; rate-limited guessing (once a minute)
Setup and passwordsFirst-run setup (including wrong setup codes) and password changes

Reading data isn't recorded: browsing Flights, the map or the Ledger adds nothing.

Each event says:

  • who: a person (email and role), the admin key, or someone not signed in;
  • what: an action such as keys.create, rules.delete, approvals.decide or auth.sign_in, and the ID of what it touched;
  • the outcome: done, refused or failed, with the HTTP status;
  • where from: address, browser and request ID;
  • the request itself.

One event opened: SCIM moved someone into an admin group

Secrets are never recorded. Fields such as passwords, API keys, tokens, credentials and webhook URLs are replaced with [redacted]. Credentials inside URLs, and anything that looks like a provider key, are removed from the rest. Answers aren't recorded either, so the key or one-time password a change returns never reaches the log.

Tamper evidence

Events are numbered in one sequence, even across several instances sharing a database. Each event stores the SHA-256 hash of its own contents and of the event before it. Verify (or GET /admin/api/audit/verify) walks the whole chain, and names the first event that was edited, removed or put out of order by someone with direct database access. The oldest event still kept is where checking starts, since older events leave with retention.

For evidence that survives someone with full database access, send the log to your SIEM or export it to storage they can't change. Your SIEM can check the chain itself.

Export

CSV and JSON Lines on the page (or GET /admin/api/audit/export?format=csv|jsonl&since=&until=) download the whole log for the chosen window, oldest first. CSV cells that a spreadsheet would treat as a formula are defused.

Send it to your SIEM

Any export destination can receive the audit log as it happens: Splunk, Datadog, OpenTelemetry (and through it Elastic, Sentinel and others), S3 or a webhook. Events arrive in order, with their chain hashes, and nothing is lost while the SIEM is down. See Audit log to your SIEM.

Retention

Events are kept for 365 days (CT_AUDIT_RETENTION_DAYS; 0 keeps them forever), separately from flights. The oldest go first, so what remains still verifies.

API

Admins only. Approvers and viewers get 403.

MethodPath
GET/admin/api/auditNewest first. Filters: since, until (milliseconds or ISO time), actor (email), action (prefix, e.g. keys or auth.sign_in), outcome (success, denied, failure), limit (up to 1000), before (the next value from the previous page)
GET/admin/api/audit/exportformat=jsonl (default) or csv, with since and until. Oldest first, as a download
GET/admin/api/audit/verify{ok, events, first_seq, last_seq}, plus broken_at and reason if the chain is broken

An event:

{
  "seq": 42,
  "time": "2026-09-29T14:03:11.204Z",
  "actor": { "type": "person", "id": "01J…", "email": "dana@example.com", "role": "admin" },
  "action": "providers.create",
  "outcome": "success",
  "status": 201,
  "target": { "type": "providers", "id": "01J…" },
  "detail": { "method": "POST", "route": "/admin/api/providers", "body": { "name": "OpenAI", "credentials": "[redacted]" } },
  "ip": "10.0.4.17",
  "user_agent": "Mozilla/5.0 …",
  "request_id": "req-1a",
  "prev_hash": "9f2c…",
  "hash": "4b7e…"
}