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>
278 lines
8.9 KiB
Python
278 lines
8.9 KiB
Python
"""Behaviour every durable audit backend must satisfy.
|
|
|
|
One suite, run against every backend. This is what makes "the Postgres backend
|
|
is done" mean something: it is the same contract SQLite already passes, not a
|
|
parallel set of tests that happen to be green.
|
|
|
|
Postgres tests are skipped unless a server is reachable. Point
|
|
``AUDIT_CORE_TEST_DATABASE_URL`` at one, or run ``make pg-test-up`` to start a
|
|
throwaway container.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import threading
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from audit_core.interface import (
|
|
AuditEvent,
|
|
BackendUnavailableError,
|
|
EventConflictError,
|
|
)
|
|
from audit_core.redaction import Finding
|
|
from audit_core.sqlite_backend import SQLiteAuditBackend
|
|
|
|
PG_URL = os.environ.get("AUDIT_CORE_TEST_DATABASE_URL")
|
|
|
|
|
|
def _postgres_available() -> bool:
|
|
if not PG_URL:
|
|
return False
|
|
try:
|
|
import psycopg
|
|
|
|
with psycopg.connect(PG_URL, connect_timeout=3):
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
HAVE_PG = _postgres_available()
|
|
|
|
|
|
@pytest.fixture(params=["sqlite", "postgres"])
|
|
def backend(request, tmp_path):
|
|
if request.param == "sqlite":
|
|
yield SQLiteAuditBackend(str(tmp_path / "conformance.db"))
|
|
return
|
|
if not HAVE_PG:
|
|
pytest.skip("no PostgreSQL reachable (set AUDIT_CORE_TEST_DATABASE_URL)")
|
|
from audit_core.postgres_backend import PostgresAuditBackend
|
|
|
|
schema = f"conf_{uuid.uuid4().hex[:12]}"
|
|
instance = PostgresAuditBackend(PG_URL, schema=schema)
|
|
try:
|
|
yield instance
|
|
finally:
|
|
with instance.pool.connection() as conn:
|
|
conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
|
instance.close()
|
|
|
|
|
|
def make_event(event_id="evt-1", **kw):
|
|
fields = dict(
|
|
event_id=event_id, source="user-engine", action="membership.added",
|
|
resource="membership-1", outcome="recorded", tenant="tenant:friendly:binky",
|
|
scope="tenant", details={"correlation_id": "corr-1", "data": {"a": 1}},
|
|
observed_at="2026-08-09T00:00:00+00:00",
|
|
)
|
|
fields.update(kw)
|
|
return AuditEvent(**fields)
|
|
|
|
|
|
def digest(event: AuditEvent) -> str:
|
|
return hashlib.sha256(
|
|
json.dumps(event.as_record(), sort_keys=True).encode()
|
|
).hexdigest()
|
|
|
|
|
|
# --- the custody contract ---------------------------------------------------
|
|
|
|
def test_declares_a_retention_policy(backend):
|
|
policy = backend.retention_policy
|
|
assert policy.durable is True
|
|
assert policy.custody_class in ("development", "archive", "hot_search")
|
|
# A backend claiming tamper evidence must also claim immutability;
|
|
# the reverse is allowed.
|
|
if policy.tamper_evidence:
|
|
assert policy.immutable
|
|
|
|
|
|
def test_accepts_then_reports_duplicate(backend):
|
|
event = make_event()
|
|
first = backend.accept(event, digest(event))
|
|
assert first.duplicate is False
|
|
second = backend.accept(event, digest(event))
|
|
assert second.duplicate is True
|
|
assert second.reference == first.reference
|
|
|
|
|
|
def test_same_id_different_payload_conflicts(backend):
|
|
event = make_event()
|
|
backend.accept(event, digest(event))
|
|
with pytest.raises(EventConflictError):
|
|
backend.accept(event, "a-different-hash")
|
|
|
|
|
|
def test_rejects_an_invalid_event(backend):
|
|
from audit_core.interface import EventValidationError
|
|
|
|
with pytest.raises((EventValidationError, ValueError)):
|
|
backend.accept(make_event(tenant=""), "hash")
|
|
|
|
|
|
# --- concurrency ------------------------------------------------------------
|
|
|
|
def test_concurrent_accept_yields_exactly_one_first(backend):
|
|
"""The assertion the service rests on, checked per backend.
|
|
|
|
An early SQLite implementation passed every serial test while telling two
|
|
concurrent callers they were both first, so this is not theoretical.
|
|
"""
|
|
event = make_event("race-1")
|
|
payload_hash = digest(event)
|
|
results: list = []
|
|
lock = threading.Lock()
|
|
barrier = threading.Barrier(12)
|
|
|
|
def submit():
|
|
barrier.wait()
|
|
try:
|
|
outcome = backend.accept(event, payload_hash).duplicate
|
|
except Exception as exc: # recorded, not swallowed
|
|
outcome = type(exc).__name__
|
|
with lock:
|
|
results.append(outcome)
|
|
|
|
threads = [threading.Thread(target=submit) for _ in range(12)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert results.count(False) == 1, results
|
|
assert results.count(True) == 11, results
|
|
|
|
|
|
# --- durability and reads ---------------------------------------------------
|
|
|
|
def test_lookup_by_event_id_and_correlation(backend):
|
|
backend.accept(make_event("e1"), "h1")
|
|
backend.accept(make_event("e2"), "h2")
|
|
|
|
record = backend.get("e1")
|
|
assert record["event_id"] == "e1"
|
|
assert record["tenant"] == "tenant:friendly:binky"
|
|
assert record["accepted_at"]
|
|
|
|
assert backend.get("missing") is None
|
|
assert {e["event_id"] for e in backend.by_correlation("corr-1")} == {"e1", "e2"}
|
|
|
|
|
|
def test_dead_letters_withhold_secret_payloads(backend):
|
|
backend.record_rejection(
|
|
event_id="e9", reason="secret_shaped_field", payload_hash="h",
|
|
sender="user-engine", payload='{"password":"hunter2"}',
|
|
)
|
|
backend.record_rejection(
|
|
event_id="e8", reason="source_not_allowed", payload_hash="h2",
|
|
sender="user-engine", payload='{"source":"nope"}',
|
|
)
|
|
entries = {d["event_id"]: d for d in backend.dead_letters()}
|
|
assert entries["e9"]["payload"] is None
|
|
assert entries["e9"]["payload_withheld"] is True
|
|
assert entries["e8"]["payload"] is not None
|
|
assert entries["e8"]["payload_withheld"] is False
|
|
|
|
|
|
def test_secret_findings_count_per_path(backend):
|
|
findings = [Finding("data.auth_token", True)]
|
|
for _ in range(3):
|
|
backend.count_secret_findings(
|
|
sender="user-engine", source="user-engine",
|
|
action="membership.added", outcome="redacted", findings=findings,
|
|
)
|
|
row = backend.secret_findings()[0]
|
|
assert row["field_path"] == "data.auth_token"
|
|
assert row["occurrences"] == 3
|
|
assert row["persisted"] is True
|
|
|
|
|
|
def test_health_passes_on_a_live_backend(backend):
|
|
backend.health()
|
|
|
|
|
|
# --- postgres-specific guarantees -------------------------------------------
|
|
|
|
pg_only = pytest.mark.skipif(not HAVE_PG, reason="needs PostgreSQL")
|
|
|
|
|
|
@pg_only
|
|
def test_replay_reconciles_rather_than_duplicating():
|
|
"""Replay must never mint a second custody record for one source event."""
|
|
from audit_core.postgres_backend import PostgresAuditBackend
|
|
|
|
schema = f"conf_{uuid.uuid4().hex[:12]}"
|
|
backend = PostgresAuditBackend(PG_URL, schema=schema)
|
|
try:
|
|
event = make_event("replay-1")
|
|
backend.accept(event, digest(event))
|
|
|
|
outcome = backend.replay("replay-1")
|
|
assert outcome.duplicate is True
|
|
|
|
rows = backend._query(
|
|
f'SELECT count(*) FROM "{schema}".events WHERE event_id = %s', ("replay-1",)
|
|
)
|
|
assert rows[0][0] == 1
|
|
|
|
with pytest.raises(KeyError):
|
|
backend.replay("never-stored")
|
|
finally:
|
|
with backend.pool.connection() as conn:
|
|
conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
|
backend.close()
|
|
|
|
|
|
@pg_only
|
|
def test_stored_events_are_append_only():
|
|
"""The basis for declaring immutable=True.
|
|
|
|
Without this, a leaked runtime credential could rewrite or erase the audit
|
|
trail — and the retention policy would be claiming a guarantee it does not
|
|
have.
|
|
"""
|
|
from audit_core.postgres_backend import PostgresAuditBackend
|
|
|
|
schema = f"conf_{uuid.uuid4().hex[:12]}"
|
|
backend = PostgresAuditBackend(PG_URL, schema=schema)
|
|
try:
|
|
event = make_event("immutable-1")
|
|
backend.accept(event, digest(event))
|
|
assert backend.retention_policy.immutable is True
|
|
|
|
with pytest.raises(BackendUnavailableError):
|
|
backend._execute(
|
|
f'UPDATE "{schema}".events SET tenant = %s WHERE event_id = %s',
|
|
("tenant:coulomb", "immutable-1"),
|
|
)
|
|
with pytest.raises(BackendUnavailableError):
|
|
backend._execute(
|
|
f'DELETE FROM "{schema}".events WHERE event_id = %s', ("immutable-1",)
|
|
)
|
|
assert backend.get("immutable-1")["tenant"] == "tenant:friendly:binky"
|
|
finally:
|
|
with backend.pool.connection() as conn:
|
|
conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
|
backend.close()
|
|
|
|
|
|
@pg_only
|
|
def test_migrations_are_idempotent_and_recorded():
|
|
from audit_core.postgres_backend import MIGRATIONS, PostgresAuditBackend
|
|
|
|
schema = f"conf_{uuid.uuid4().hex[:12]}"
|
|
backend = PostgresAuditBackend(PG_URL, schema=schema, migrate=False)
|
|
try:
|
|
applied = backend.migrate()
|
|
assert applied == [m[0] for m in MIGRATIONS]
|
|
assert backend.migrate() == [] # second call is a no-op
|
|
finally:
|
|
with backend.pool.connection() as conn:
|
|
conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
|
backend.close()
|