Audit Log — Chain of Custody Trust Model¶
Thalian's audit log is designed to provide tamper-evidence for SOC 2 Type II attestation. This document describes the design, the guarantees it provides, and the limitations auditors should be aware of.
What the chain proves¶
Every audit log entry written after the chain was activated (2026-05-23) is linked to all previous entries by a per-workspace SHA-256 hash chain. The chain provides the following guarantees:
- Append-only: rows cannot be deleted. A PostgreSQL
BEFORE DELETEtrigger raises an exception on anyDELETEfromaudit_log. - Immutable: rows cannot be modified after insertion. A PostgreSQL
BEFORE UPDATEtrigger raises an exception on anyUPDATEfromaudit_log, with a single documented exception (see "Migration 20260523" below). - Linked: each row's
row_hashcovers theprev_hashof the row that preceded it in the same workspace. Deleting or inserting a row in the middle of the chain breaks theprev_hashlinkage on every subsequent row. - Pre-cutover sealed: rows that existed before the chain was activated are covered by a
precutover_seal_root— a SHA-256 over the canonical(id:row_hash)pairs of all pre-cutover rows. This seal is stored in the genesis row and can be recomputed offline.
Hash formula¶
Each row's row_hash is computed by the compute_audit_chain_hash() PostgreSQL trigger as:
SHA-256(
json_build_object(
'action', <action>,
'chain_version', <chain_version>,
'created_at_us', <microseconds since epoch, integer-split>,
'details', <details JSONB>,
'id', <row UUID>,
'prev_hash', <prev_hash or null>,
'sequence_number', <sequence_number>,
'target_id', <target_id>,
'target_type', <target_type>,
'user_id', <user_id>,
'workspace_id', <workspace_id>
)::text
)
Key serialization properties (important for external verification):
- Key order is alphabetical as declared in
json_build_object(PostgreSQL preserves declared order forjson, notjsonb). - Whitespace: outer object uses
" : "(space colon space) and", "(comma space). InnerdetailsJSONB values are serialized in PostgreSQL's compact JSONB format (sorted keys, no extra whitespace). created_at_usis a bigint (integer-split to avoid float64 drift on old PostgreSQL versions):floor(epoch_seconds) * 1_000_000 + microsecond_of_second.- The formula uses
json_build_object(notjsonb_build_object) for deterministic key-order output. The two are not bit-identical.
Chain topology¶
[genesis row]
↓ prev_hash = genesis.row_hash
[first post-cutover event]
↓ prev_hash = first_event.row_hash
[second post-cutover event]
↓ ...
[latest event]
- Genesis row (
action = 'audit_chain.genesis'): created automatically when a workspace is created (or backfilled for existing workspaces). Containsprecutover_seal_rootandcutover_atin itsdetailsfield.prev_hashis NULL. - Pre-cutover rows: all rows with
prev_hash IS NULLandaction != 'audit_chain.genesis'. Their integrity is covered byprecutover_seal_root. - Chained rows: all rows with
prev_hash IS NOT NULL. Each links to the workspace's highest-sequence_numberrow at the time of insert.
Workspace isolation: each workspace has its own independent chain. A PostgreSQL advisory lock (pg_advisory_xact_lock) serializes concurrent inserts within a workspace to prevent chain forks.
Verification¶
In-product verifier¶
Compliance → Audit Log → Chain Integrity runs the verify_audit_chain() PostgreSQL function against live data. This function:
- Recomputes
precutover_seal_rootfrom stored(id, row_hash)pairs and compares with the genesis row. - Walks post-cutover chained rows in sequence order, checking
prev_hashlinkage. - Recomputes each post-cutover
row_hashusing the same PostgreSQL formula and compares with the stored value. - Returns:
ok(boolean),row_count,link_break_count,hash_break_count, locations of first breaks.
verify_audit_chain is SECURITY INVOKER — it runs as the calling user and performs a workspace membership check. It does not expose raw hash chains to the caller.
Offline verifier (Python)¶
The public Python verifier (verify-audit-export.py, available at https://github.com/thalian-ai/thalian/blob/main/tools/verify-audit-export.py) verifies a JSONL export downloaded from Compliance → Audit Log → Download export.
The offline verifier checks:
1. Genesis row present and unique.
2. precutover_seal_root matches the recomputed seal over pre-cutover rows.
3. Post-cutover chain linkage (prev_hash consistency for every chained row).
Limitation: the offline verifier does not recompute individual row_hash values (hash content integrity). This requires exact reproduction of PostgreSQL's json_build_object text serialization, which is not straightforward in Python. Use the in-product verifier for full content integrity verification.
Known limitations (auditor disclosure)¶
H2: Service-role credential can fabricate authentic-looking entries¶
The PostgreSQL service_role credential (and authenticated users with sufficient Supabase permissions) can insert rows that pass all hash and chain checks. The chain proves integrity of stored rows — that no row was modified, deleted, or inserted between two existing rows after the fact. It does not prove that all rows represent genuine Thalian application events.
Compensating controls for this limitation:
- The SUPABASE_SERVICE_KEY is stored only in Cloudflare Pages environment variables, never logged or committed to source code.
- All direct service-role writes to audit_log from application code go through insertAuditLog() in functions/api/_audit.js, which sets user_id from the authenticated JWT — not from a caller-supplied parameter.
- Credential rotation is covered by Thalian's access control policy (CC6.1).
- A follow-up enhancement (app-layer HMAC over each row using a rotating signing key) is planned for after the SOC 2 observation window closes. This will provide authenticity guarantees in addition to the current integrity guarantees.
Pre-cutover rows (rows before 2026-05-23)¶
Rows inserted before the chain was activated have content_hash computed by the application-layer _audit.js helper (using JavaScript's JSON.stringify, no extra whitespace). These rows are sealed by precutover_seal_root in the genesis row, which covers all of them as a batch.
Individual pre-cutover rows cannot be hash-recomputed by the PostgreSQL verifier because the PostgreSQL json_build_object formula produces different whitespace than JavaScript's JSON.stringify. The seal root provides batch-level tamper evidence for this cohort.
Workspace deletion severs chain linkability¶
When a workspace is deleted, audit_log.workspace_id is set to NULL (the FK is ON DELETE SET NULL, not CASCADE). The rows are retained for 3 years per Thalian's data processing agreement, but the workspace_id column is nulled and the row content is anonymized (user_id = NULL, details = {}). This satisfies GDPR Article 17 right-to-erasure while preserving the audit trail.
Once workspace_id is NULL, chain verification by workspace is no longer possible. Chain integrity at write time was preserved; chain linkability is intentionally severed at deletion.
Migration 20260523 was the only legitimate UPDATE on audit_log¶
The audit_log_no_update immutability trigger blocks all UPDATEs. Migration 20260523_audit_log_chain_root.sql temporarily disabled this trigger inside a single ACCESS EXCLUSIVE LOCK transaction to:
1. Backfill content_hash for ~1,500 rows inserted before the hash trigger existed.
2. Backfill row_hash = content_hash for all pre-cutover rows.
No production traffic could observe audit_log in a mutable state during this window. The migration is the only exception to the immutability rule. All future maintenance that requires row modification must follow the same pattern (document, lock, disable trigger, update, re-enable, commit) and must be logged here.
Chain version history¶
| Version | Activated | Description |
|---|---|---|
| v1 | 2026-05-23 | Initial chain. Alphabetical json_build_object, integer-split microsecond epoch, per-workspace genesis, precutover_seal_root. |
A new chain version would be introduced via a cutover row with action = 'audit_chain.version_bump' and a new chain_version value. Verifiers must handle multiple chain version segments within a single workspace's history.
References¶
- Python verifier: github.com/thalian-ai/thalian/blob/main/tools/verify-audit-export.py