Designing a PostGIS Lineage Audit Table
An append-only table stops accidental overwrites, but on its own it cannot prove that no row was ever quietly rewritten by someone with elevated privileges. This how-to adds tamper evidence: an audit table where every row carries a SHA-256 hash of its own contents chained to the hash of the previous row, plus a BEFORE UPDATE OR DELETE trigger that rejects mutation outright. It extends the core PostGIS Lineage Schema Design with a structure that lets an auditor detect a single altered byte anywhere in the history.
Prerequisites
- PostgreSQL 15+ with PostGIS 3.4+ and the
pgcryptoextension enabled. - Python 3.10+ with
psycopg3.1+ for the verification client. - A database role permitted to create tables, functions, and triggers.
- The
datasetandprocess_steptables from the parent schema already present (the audit table references their identifiers).
Implementation
The audit table stores one immutable event per row. Each row computes row_hash from its own payload columns concatenated with the prev_hash of the row before it, forming a hash chain: change any historical row and every subsequent row_hash fails to recompute. A BEFORE INSERT trigger fills in the chain server-side so clients cannot forge it, and a second trigger blocks UPDATE and DELETE.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE lineage_audit (
seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_time timestamptz NOT NULL DEFAULT clock_timestamp(),
actor text NOT NULL,
operation text NOT NULL, -- e.g. 'REPROJECT', 'CLIP'
dataset_id uuid NOT NULL,
extent geometry(Polygon, 4326),
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
prev_hash char(64) NOT NULL,
row_hash char(64) NOT NULL
);
-- Compute the chained hash before the row is written.
CREATE OR REPLACE FUNCTION lineage_audit_chain() RETURNS trigger AS $$
DECLARE
last_hash char(64);
canonical text;
BEGIN
SELECT row_hash INTO last_hash
FROM lineage_audit
ORDER BY seq DESC
LIMIT 1;
-- Genesis row chains from 64 zeroes.
NEW.prev_hash := COALESCE(last_hash, repeat('0', 64));
canonical := NEW.event_time::text || '|' || NEW.actor || '|'
|| NEW.operation || '|' || NEW.dataset_id::text || '|'
|| COALESCE(ST_AsText(NEW.extent), '') || '|'
|| NEW.payload::text || '|' || NEW.prev_hash;
NEW.row_hash := encode(digest(canonical, 'sha256'), 'hex');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER lineage_audit_chain_bi
BEFORE INSERT ON lineage_audit
FOR EACH ROW EXECUTE FUNCTION lineage_audit_chain();
-- Block any mutation of a written row.
CREATE OR REPLACE FUNCTION lineage_audit_freeze() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'lineage_audit is append-only; % rejected', TG_OP
USING ERRCODE = 'restrict_violation';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER lineage_audit_freeze_bud
BEFORE UPDATE OR DELETE ON lineage_audit
FOR EACH ROW EXECUTE FUNCTION lineage_audit_freeze();
Note the use of clock_timestamp() rather than now(): within a batch transaction, now() returns the same value for every row, which is fine for the chain but obscures true event ordering. clock_timestamp() advances per statement. The canonical string uses ST_AsText so the geometry contributes to the hash in a stable textual form; if you need bit-exact geometry hashing across PostGIS versions, substitute ST_AsBinary and hash the bytea directly.
The Python side simply inserts events; the server computes the chain, so the client never sets prev_hash or row_hash.
from __future__ import annotations
import json
import psycopg
def append_audit_event(
conn: psycopg.Connection,
*,
actor: str,
operation: str,
dataset_id: str,
extent_wkt: str | None,
payload: dict,
) -> int:
"""Append one tamper-evident audit event; returns its sequence number."""
row = conn.execute(
"""
INSERT INTO lineage_audit (actor, operation, dataset_id, extent, payload)
VALUES (
%s, %s, %s,
CASE WHEN %s IS NULL THEN NULL ELSE ST_GeomFromText(%s, 4326) END,
%s::jsonb
)
RETURNING seq
""",
(actor, operation, dataset_id, extent_wkt, extent_wkt, json.dumps(payload)),
).fetchone()
conn.commit()
return row[0]
Verification
First, prove the table is truly append-only by attempting a mutation and observing the rejection:
-- Should fail with: lineage_audit is append-only; UPDATE rejected
UPDATE lineage_audit SET actor = 'tamperer' WHERE seq = 1;
-- Should fail with: lineage_audit is append-only; DELETE rejected
DELETE FROM lineage_audit WHERE seq = 1;
Second, recompute the whole chain and confirm that every stored row_hash matches. This query walks the rows in order, rebuilds each canonical string using the previous row’s stored hash, and returns only rows where the recomputed hash disagrees — an empty result means the chain is intact:
WITH recomputed AS (
SELECT
seq,
row_hash AS stored,
encode(digest(
event_time::text || '|' || actor || '|' || operation || '|'
|| dataset_id::text || '|' || COALESCE(ST_AsText(extent), '') || '|'
|| payload::text || '|'
|| LAG(row_hash, 1, repeat('0', 64)) OVER (ORDER BY seq),
'sha256'), 'hex') AS rebuilt
FROM lineage_audit
)
SELECT seq, stored, rebuilt
FROM recomputed
WHERE stored <> rebuilt;
If an attacker altered row 5, that row and every row after it would appear in the result set, because each subsequent prev_hash no longer matches. A weekly job that runs this query and alerts on any output gives you continuous tamper detection.
Gotchas & edge cases
- Geometry text stability.
ST_AsTextoutput can vary in coordinate precision across PostGIS point releases. Pin the precision explicitly withST_AsText(extent, 8)in both the trigger and the verification query, or hashST_AsEWKBbytes, so an engine upgrade never invalidates an otherwise-untouched chain. - NULL extents. Non-spatial audit events have a null extent. The
COALESCE(..., '')in the canonical string keeps those rows hashable; if you forget it,digest()receives a NULL and the whole concatenation becomes NULL, producing an unverifiable row. - Concurrent inserts. Two transactions inserting at once can both read the same “last” row and chain from it, forking the hash chain. Serialize appends with an advisory lock (
pg_advisory_xact_lock) inside the trigger, or route all audit writes through a single queue, if strict linearity matters for your audit posture.