Accept a brokered libpq environment as connection information
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-11 23:24:25 +02:00
parent ec8bacbdeb
commit fc48378a3f
4 changed files with 67 additions and 10 deletions

View file

@ -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 |

View file

@ -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 %sAUDIT_CORE_DATABASE_URL is unset, so this "
"is not the production store", path,
"custody backend: sqlite at %sneither AUDIT_CORE_DATABASE_URL nor a "
"brokered PG* environment is set, so this is not the production store", path,
)
return SQLiteAuditBackend(path)

View file

@ -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

View file

@ -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()