Add the PostgreSQL audit backend and a shared conformance suite
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in
Docker; the Railiance cluster was not needed.

tests/test_backend_conformance.py is one suite run against every backend, so
"the Postgres backend is done" means it satisfies the same contract SQLite
already does rather than having its own green tests. It skips cleanly with no
server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71.

RetentionPolicy declares immutable=True and earns it: migration 0002 installs
a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime
credential can append but cannot rewrite or erase the trail. That materially
narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays
False because nothing here would prove a database owner had dropped the
trigger - hash-chaining or external anchoring would be needed and is not
implemented.

Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING),
verified to behave identically to the SQLite backend under 12 concurrent
submissions of the same event. Migrations are ordered, recorded and
idempotent. Replay reconciles rather than duplicating - the piece deferred out
of WP-0004-T05 - and is tested to leave exactly one custody record.

Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a
warning so a deployment that lost its URL is visible rather than quietly
running on the wrong store.

Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion
silently did nothing. Found during end-to-end smoke.

Counting semantics documented: occurrences counts transmissions, not stored
events, so a retry of a secret-shaped field increments it again. That is the
sender behaviour being optimized away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-10 17:09:46 +02:00
parent 576caa2665
commit bd274f6269
9 changed files with 806 additions and 9 deletions

View file

@ -49,7 +49,7 @@ requirement and consumes it; it does not implement it here.
```task
id: AUDIT-WP-0005-T01
status: todo
status: done
priority: high
state_hub_task_id: "b1601d0b-922a-40f7-92c0-ea06af6c4468"
```
@ -76,6 +76,41 @@ Done when the backend passes the same contract tests as the existing backends,
concurrent duplicate submissions produce exactly one record, and a database
restart mid-write does not produce an acknowledged-but-absent event.
Done 2026-08-10, built and verified against PostgreSQL 16 locally in Docker —
the Railiance cluster was not needed for any of it.
`tests/test_backend_conformance.py` is a single suite run against every
backend, so "the Postgres backend is done" means it satisfies the same
contract SQLite already does rather than having its own tests that happen to
be green. It skips cleanly when no server is reachable; `make pg-test-up` and
`make test-pg` run it. Suite 50 -> 71.
`RetentionPolicy` declares `immutable=True`, and that is earned: migration
0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a
leaked runtime credential can append but cannot rewrite or erase the trail.
This materially narrows the residual risk ADR-0001 §5 called out — a leaked
credential could previously forge the audit record. `tamper_evidence` stays
False, because nothing here would *prove* a database owner had dropped the
trigger; hash-chaining or external anchoring would be needed and is not
implemented.
Migrations are ordered, recorded in `schema_migrations`, and idempotent.
Replay reconciles rather than duplicating — the piece deferred out of
WP-0004-T05 — and is tested to leave exactly one custody record.
Backend selection is by `AUDIT_CORE_DATABASE_URL`; falling back to SQLite logs
a warning, so a deployment that lost its URL is visible rather than quietly
running on the wrong store. End-to-end smoke through waitress against Postgres
confirmed accept, duplicate, cross-tenant refusal, read/write privilege
separation, visible redaction, secret-finding counters, and readiness
reporting `custody_class=archive`.
Also fixed: `audit_core.ingestion` had no `__main__` guard, so `python -m
audit_core.ingestion` silently did nothing.
Not covered here: behaviour across a real database failover, which needs the
cluster and belongs to T05.
## T02 - Provision storage through the platform lane
```task