Audit log: integrity, export, forwarding

Embed
Preview of the card
<a href="https://forumulate.com/p/watchtower/docs/audit"><img src="https://forumulate.com/api/public/orgs/watchtower/spaces/docs/pages/8ee6c0a3-9c47-45a3-928e-99ad3efee5a8/social-card.svg?v=3e7ae33eec8b" alt="Audit log: integrity, export, forwarding" width="600" height="315"></a>

Every audit event is appended to a per-org hash chain: hash = SHA-256(prev_hash || canonical_json(row)), with a gap-free seq per org (a per-org advisory lock serializes appends). Rows written before the chain existed are chained once by the janitor job audit_backfill, in (at, id) order.

API (org scope; ManageSecurity unless noted)

Route

Purpose

GET /v1/orgs/{org}/audit-events (ManageOrg)

Filtered list: actor, action, target_type, project, from, to, cursor paging. Reads are audited (audit.read).

GET /v1/orgs/{org}/audit/verify?from_seq&to_seq

Recomputes the chain: {ok, checked, first_bad_seq?}. Modified, deleted or reordered rows are reported.

GET /v1/orgs/{org}/audit/report?kind=

deletions, permission_changes, credential_changes, integration_changes, admin_access: grouped counts plus events (max 1,000).

POST /v1/orgs/{org}/audit/exports

{from, to, format: csv, ndjson or ocsf}. Up to 100,000 rows stream inline; larger ranges are queued (202) and fetched with GET .../exports/{id} and .../download for 24 hours. Audited (audit.export).

GET/PUT /v1/orgs/{org}/audit/retention

days, 30 to 3650 (default 400).

POST/GET/PATCH/DELETE /v1/orgs/{org}/audit/forwarders

S3, webhook, syslog or Kafka; format json or ocsf. Credentials are write-only.

Every export ends with a manifest line (first_seq, last_seq, rows, last_hash). The last hash commits to every earlier row, so the file can be checked against verify.

Forwarding

The janitor job audit_forward reads events after each forwarder's cursor_seq, in order, and advances the cursor only after the sink accepts a batch: delivery is at-least-once, so consumers must dedupe on metadata.uid (OCSF) or id. Failures back off (30 s doubling to 1 h) and show in last_error; audit writes never wait.

  • S3: one gzip NDJSON object per batch, audit/{org}/{date}/{hour}/{first}-{last}.ndjson.gz.

  • Webhook: NDJSON batch, X-Watchtower-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t.body)>, X-Watchtower-Idempotency-Key.

  • Syslog: RFC 5424 over TLS (default port 6514), RFC 6587 octet counting, facility 13, severity 6.

  • Kafka: one record per event, partition 0.

All endpoints pass the SSRF rules (public addresses only). Credentials are sealed with WT_SECRET_KEY.

Retention

audit_prune deletes the contiguous prefix older than the org's retention and records anchor_seq/anchor_hash on the chain head; verify starts from the anchor, so a pruned log still verifies.

Honest limits

The chain is tamper-evident, not tamper-proof: someone with database write access can rewrite the whole chain. Forward to an external store (S3 with object lock, a SIEM) to keep an independent copy. Large exports are downloaded through the authenticated API, not a presigned URL.

Updated