Audit log: integrity, export, forwarding
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 |
|---|---|
| Filtered list: |
| Recomputes the chain: |
|
|
|
|
|
|
| S3, webhook, syslog or Kafka; |
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.