An audit log is only worth having if it cannot be altered by the same access that alters the data it audits. This spec defines a log that is append-only at the database level and tamper-evident by hash chaining, so a missing or edited entry is detectable rather than silent. It is the evidentiary backbone regulated systems are asked to produce.
Entity model
Entity-relationship model
Each event records the actor, the action, the entity affected, and the full before/after state, plus the hash of the previous event and its own hash. Events form a chain per tenant: every entry commits to the one before it.
Isolation policy
Append-only enforcement
Append-only is a database guarantee, not an application convention. The application role is granted INSERT and SELECT only; UPDATE and DELETE are revoked, and a trigger rejects them outright so even a privileged path cannot rewrite history. The log can grow and be read — never modified.
REVOKE UPDATE, DELETE ON audit_events FROM app_role;
CREATE OR REPLACE FUNCTION reject_mutation() RETURNS trigger AS $body$
BEGIN
RAISE EXCEPTION 'audit_events is append-only';
END;
$body$ LANGUAGE plpgsql;
CREATE TRIGGER audit_no_mutation
BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION reject_mutation();Definition
Tamper-evidence via hash chaining
Each event's hash is computed over the previous event's hash and this event's canonical payload. A single altered or removed row breaks the chain from that point forward, so tampering is detectable by re-walking the chain — you cannot change one entry without recomputing every entry after it.
-- Recompute each row's hash from the prior hash + payload and
-- assert it matches the stored hash. Any mismatch = tampering.
WITH ordered AS (
SELECT id, prev_hash, hash,
digest(COALESCE(prev_hash, '') ||
entity_type || entity_id::text ||
COALESCE(before::text,'') || COALESCE(after::text,''),
'sha256') AS recomputed
FROM audit_events
ORDER BY created_at
)
SELECT id FROM ordered WHERE hash <> recomputed; -- expect zero rowsContract
What every event must capture
| Field | Requirement | Why |
|---|---|---|
| actor_id | Always set (or a system sentinel) | Every change is attributable |
| action | create / update / delete | The nature of the change |
| before / after | Full state, both sides | Reconstruct exactly what changed |
| hash chain | prev_hash + hash | Tamper-evidence |
| created_at | Server time, immutable | Ordering and evidentiary timeline |
Invariants this spec guarantees
- The log is append-only: UPDATE and DELETE are rejected at the database, not just discouraged in code.
- Every event commits to the previous one by hash, so altering or removing any entry is detectable.
- Every event is attributable to an actor and records full before/after state.
- The chain is verifiable at any time by re-walking it; a break localises the tampering.