From fc48378a3f9e727c123c6b00472628c131f75804 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 11 Aug 2026 23:24:25 +0200 Subject: [PATCH] Accept a brokered libpq environment as connection information AUDIT-WP-0005-T02. The rapp-postgres credential playbook has the railiance-platform broker inject PGUSER/PGPASSWORD/PGHOST/PGPORT/PGDATABASE into the child process. audit-core only accepted AUDIT_CORE_DATABASE_URL, so consuming a brokered lease would have meant assembling a DSN by hand from the injected variables - putting the credential back into audit-core's own configuration, which is what the lane exists to avoid. An empty conninfo lets libpq read those variables directly, so a brokered lease now needs no DSN at all. AUDIT_CORE_DATABASE_URL still works for local and test use. Missing both is a clear startup error naming each option. Tests 80 -> 82. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 4 ++-- audit_core/ingestion.py | 14 +++++++---- audit_core/postgres_backend.py | 19 ++++++++++++--- tests/test_backend_conformance.py | 40 +++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 123e2b4..e5aebd3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -12,7 +12,7 @@ | workplan | AUDIT-WP-0002 | finished | — | workplans/AUDIT-WP-0002-pluggable-audit-backend.md | | workplan | AUDIT-WP-0003 | finished | — | workplans/AUDIT-WP-0003-user-engine-event-ingestion-service.md | | workplan | AUDIT-WP-0004 | finished | — | workplans/AUDIT-WP-0004-receiver-correctness-and-hardening.md | -| workplan | AUDIT-WP-0005 | proposed | — | workplans/AUDIT-WP-0005-postgres-store-and-production-deployment.md | +| workplan | AUDIT-WP-0005 | active | — | workplans/AUDIT-WP-0005-postgres-store-and-production-deployment.md | | task | AUDIT-WP-0001-T01 | done | — | workplans/AUDIT-WP-0001-statehub-bootstrap.md | | task | AUDIT-WP-0001-T02 | done | — | workplans/AUDIT-WP-0001-statehub-bootstrap.md | | task | AUDIT-WP-0001-T03 | done | — | workplans/AUDIT-WP-0001-statehub-bootstrap.md | @@ -32,5 +32,5 @@ | task | AUDIT-WP-0005-T02 | todo | — | workplans/AUDIT-WP-0005-postgres-store-and-production-deployment.md | | task | AUDIT-WP-0005-T03 | progress | — | workplans/AUDIT-WP-0005-postgres-store-and-production-deployment.md | | task | AUDIT-WP-0005-T04 | done | — | workplans/AUDIT-WP-0005-postgres-store-and-production-deployment.md | -| task | AUDIT-WP-0005-T05 | todo | — | workplans/AUDIT-WP-0005-postgres-store-and-production-deployment.md | +| task | AUDIT-WP-0005-T05 | progress | — | workplans/AUDIT-WP-0005-postgres-store-and-production-deployment.md | | task | AUDIT-WP-0005-T06 | todo | — | workplans/AUDIT-WP-0005-postgres-store-and-production-deployment.md | diff --git a/audit_core/ingestion.py b/audit_core/ingestion.py index c0a4497..e616e2b 100644 --- a/audit_core/ingestion.py +++ b/audit_core/ingestion.py @@ -480,13 +480,17 @@ def build_backend() -> IdempotentAuditBackend: the wrong store. """ url = os.environ.get("AUDIT_CORE_DATABASE_URL") - if url: + brokered = bool(os.environ.get("PGHOST") and os.environ.get("PGUSER")) + if url or brokered: from audit_core.postgres_backend import PostgresAuditBackend retention = os.environ.get("AUDIT_CORE_RETENTION_DAYS") - log.info("custody backend: postgresql") + log.info( + "custody backend: postgresql (%s)", + "AUDIT_CORE_DATABASE_URL" if url else "brokered libpq environment", + ) return PostgresAuditBackend( - url, + url or "", schema=os.environ.get("AUDIT_CORE_DATABASE_SCHEMA", "audit_core"), retention_days=int(retention) if retention else None, max_size=int(os.environ.get("AUDIT_CORE_DB_POOL_MAX", "8")), @@ -496,8 +500,8 @@ def build_backend() -> IdempotentAuditBackend: ) path = os.environ.get("AUDIT_CORE_DATABASE_PATH", "/data/audit-core.db") log.warning( - "custody backend: sqlite at %s — AUDIT_CORE_DATABASE_URL is unset, so this " - "is not the production store", path, + "custody backend: sqlite at %s — neither AUDIT_CORE_DATABASE_URL nor a " + "brokered PG* environment is set, so this is not the production store", path, ) return SQLiteAuditBackend(path) diff --git a/audit_core/postgres_backend.py b/audit_core/postgres_backend.py index cf79fb7..92d928f 100644 --- a/audit_core/postgres_backend.py +++ b/audit_core/postgres_backend.py @@ -140,9 +140,17 @@ class PostgresAuditBackend: statement_timeout_ms: int = 30_000, migrate: bool = True, ) -> None: - self.dsn = dsn or os.environ.get("AUDIT_CORE_DATABASE_URL") or "" - if not self.dsn: - raise ValueError("a DSN is required (AUDIT_CORE_DATABASE_URL)") + # An empty conninfo is valid: libpq then reads PGHOST/PGUSER/PGPASSWORD/ + # PGPORT/PGDATABASE from the environment. That is exactly the shape the + # railiance-platform credential broker injects into a child process, so + # a brokered lease needs no DSN assembled by hand — and no credential + # ever passes through audit-core's own configuration. + self.dsn = dsn if dsn is not None else os.environ.get("AUDIT_CORE_DATABASE_URL", "") + if not self.dsn and not _libpq_env_present(): + raise ValueError( + "no connection information: set AUDIT_CORE_DATABASE_URL, or supply " + "PGHOST/PGUSER/PGDATABASE (as the credential broker does)" + ) if not schema.isidentifier(): raise ValueError(f"unsafe schema name: {schema!r}") self.schema = schema @@ -393,6 +401,11 @@ class PostgresAuditBackend: raise BackendUnavailableError(str(exc)) from exc +def _libpq_env_present() -> bool: + """Whether libpq has enough in the environment to connect on its own.""" + return bool(os.environ.get("PGHOST") and os.environ.get("PGUSER")) + + def _timestamp(value: str | None) -> datetime | None: if not value: return None diff --git a/tests/test_backend_conformance.py b/tests/test_backend_conformance.py index ae5ead7..2bbd5ad 100644 --- a/tests/test_backend_conformance.py +++ b/tests/test_backend_conformance.py @@ -276,3 +276,43 @@ def test_migrations_are_idempotent_and_recorded(): with backend.pool.connection() as conn: conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') backend.close() + + +@pg_only +def test_connects_from_a_brokered_libpq_environment(monkeypatch): + """The credential broker injects PG* vars into the child process rather + than handing over a DSN, so an empty conninfo must work. + + This keeps the credential out of audit-core's configuration entirely. + """ + import urllib.parse + + from audit_core.postgres_backend import PostgresAuditBackend + + parsed = urllib.parse.urlparse(PG_URL) + monkeypatch.setenv("PGHOST", parsed.hostname or "127.0.0.1") + monkeypatch.setenv("PGPORT", str(parsed.port or 5432)) + monkeypatch.setenv("PGUSER", parsed.username or "postgres") + monkeypatch.setenv("PGPASSWORD", parsed.password or "") + monkeypatch.setenv("PGDATABASE", (parsed.path or "/postgres").lstrip("/")) + monkeypatch.delenv("AUDIT_CORE_DATABASE_URL", raising=False) + + schema = f"conf_{uuid.uuid4().hex[:12]}" + backend = PostgresAuditBackend(schema=schema) + try: + event = make_event("brokered-1") + assert backend.accept(event, digest(event)).duplicate is False + assert backend.get("brokered-1")["event_id"] == "brokered-1" + finally: + with backend.pool.connection() as conn: + conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') + backend.close() + + +def test_missing_connection_information_is_a_clear_error(monkeypatch): + for var in ("AUDIT_CORE_DATABASE_URL", "PGHOST", "PGUSER"): + monkeypatch.delenv(var, raising=False) + from audit_core.postgres_backend import PostgresAuditBackend + + with pytest.raises(ValueError, match="no connection information"): + PostgresAuditBackend()