Deliver database credentials as a rotatable mounted directory
AUDIT-WP-0005-T02 (progress). rapp-postgres has landed platform-pg with the audit_core database, roles, and dynamic credential provisioning, so audit-core's side is now built against it. In-cluster delivery is a mounted directory rather than environment variables. A dynamic lease rotates while the pod runs and an env var is fixed at process start, so env delivery would force a restart on every rotation - and every restart is a delivery gap, which is what this task forbids. CredentialDirectory is re-read on every connection attempt via psycopg_pool's callable kwargs, so a rotated lease takes effect with no restart. Rotation is logged by password fingerprint, never by value. deploy/externalsecrets.yaml follows the ClusterSecretStore -> ExternalSecret -> Secret pattern already used by activity-core and rapp-qonto, at a 15m refresh rather than the default 1h since the interval bounds how long a revoked lease can stay mounted. All manifests validated --dry-run=server --validate=strict. The rotation test was initially vacuous: it passed against a deliberately naive implementation that read credentials once at startup, because pooled sessions stay authenticated after a password change and nothing forced a reconnect. It now terminates the role's sessions first, and is verified to fail against the naive implementation and pass against the real one. Tests 82 -> 84. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fc48378a3f
commit
7636e83dcc
7 changed files with 364 additions and 20 deletions
87
audit_core/credentials.py
Normal file
87
audit_core/credentials.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Database credentials read from a mounted directory (AUDIT-WP-0005-T02).
|
||||
|
||||
The credential broker issues *dynamic* PostgreSQL leases, so the password the
|
||||
pod should use changes while the pod is running. Environment variables cannot
|
||||
express that: they are fixed at process start, so every rotation would need a
|
||||
restart and every restart is a delivery gap.
|
||||
|
||||
A mounted Secret can. Kubernetes updates the files in place when the
|
||||
ExternalSecret refreshes, so reading them at connection time — rather than at
|
||||
startup — makes rotation invisible to senders.
|
||||
|
||||
Expected layout, one value per file, matching what an ExternalSecret template
|
||||
produces::
|
||||
|
||||
/etc/audit-core/db/username
|
||||
/etc/audit-core/db/password
|
||||
/etc/audit-core/db/host
|
||||
/etc/audit-core/db/port
|
||||
/etc/audit-core/db/dbname
|
||||
|
||||
A single ``dsn`` file is also accepted for stores that vend one string.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
log = logging.getLogger("audit_core.credentials")
|
||||
|
||||
# Maps a file name to its libpq connection keyword.
|
||||
_FIELDS = {
|
||||
"username": "user",
|
||||
"user": "user",
|
||||
"password": "password",
|
||||
"host": "host",
|
||||
"port": "port",
|
||||
"dbname": "dbname",
|
||||
"database": "dbname",
|
||||
"sslmode": "sslmode",
|
||||
}
|
||||
|
||||
|
||||
class CredentialDirectory:
|
||||
"""Connection parameters re-read from disk on every use."""
|
||||
|
||||
def __init__(self, path: str | pathlib.Path) -> None:
|
||||
self.path = pathlib.Path(path)
|
||||
if not self.path.is_dir():
|
||||
raise ValueError(f"credential directory does not exist: {self.path}")
|
||||
self._last_fingerprint: str | None = None
|
||||
|
||||
def read(self) -> dict[str, str]:
|
||||
"""Return the current connection parameters.
|
||||
|
||||
Called per connection attempt, so a rotated password is picked up by
|
||||
the next connection the pool opens without any restart.
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
dsn_file = self.path / "dsn"
|
||||
if dsn_file.exists():
|
||||
params["dsn"] = dsn_file.read_text().strip()
|
||||
self._note_rotation(params["dsn"])
|
||||
return params
|
||||
|
||||
for name, keyword in _FIELDS.items():
|
||||
candidate = self.path / name
|
||||
if candidate.exists():
|
||||
value = candidate.read_text().strip()
|
||||
if value:
|
||||
params.setdefault(keyword, value)
|
||||
if "user" not in params:
|
||||
raise ValueError(f"no username in credential directory {self.path}")
|
||||
# Fingerprint the password so rotation is visible in logs without the
|
||||
# password itself ever being logged.
|
||||
self._note_rotation(params.get("password", ""))
|
||||
return params
|
||||
|
||||
def _note_rotation(self, secret: str) -> None:
|
||||
import hashlib
|
||||
|
||||
fingerprint = hashlib.sha256(secret.encode()).hexdigest()[:12]
|
||||
if self._last_fingerprint is None:
|
||||
self._last_fingerprint = fingerprint
|
||||
elif fingerprint != self._last_fingerprint:
|
||||
log.info("database credential rotated (fingerprint %s)", fingerprint)
|
||||
self._last_fingerprint = fingerprint
|
||||
|
|
@ -480,17 +480,19 @@ def build_backend() -> IdempotentAuditBackend:
|
|||
the wrong store.
|
||||
"""
|
||||
url = os.environ.get("AUDIT_CORE_DATABASE_URL")
|
||||
credential_dir = os.environ.get("AUDIT_CORE_CREDENTIAL_DIR")
|
||||
brokered = bool(os.environ.get("PGHOST") and os.environ.get("PGUSER"))
|
||||
if url or brokered:
|
||||
if url or brokered or credential_dir:
|
||||
from audit_core.postgres_backend import PostgresAuditBackend
|
||||
|
||||
retention = os.environ.get("AUDIT_CORE_RETENTION_DAYS")
|
||||
log.info(
|
||||
"custody backend: postgresql (%s)",
|
||||
"AUDIT_CORE_DATABASE_URL" if url else "brokered libpq environment",
|
||||
)
|
||||
source = ("mounted credential directory" if credential_dir
|
||||
else "AUDIT_CORE_DATABASE_URL" if url
|
||||
else "brokered libpq environment")
|
||||
log.info("custody backend: postgresql (%s)", source)
|
||||
return PostgresAuditBackend(
|
||||
url or "",
|
||||
credential_dir=credential_dir,
|
||||
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")),
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from audit_core.interface import (
|
|||
RetentionPolicy,
|
||||
validate_event,
|
||||
)
|
||||
from audit_core.credentials import CredentialDirectory
|
||||
from audit_core.redaction import Finding
|
||||
|
||||
try: # pragma: no cover - import guard
|
||||
|
|
@ -139,14 +140,21 @@ class PostgresAuditBackend:
|
|||
max_size: int = 8,
|
||||
statement_timeout_ms: int = 30_000,
|
||||
migrate: bool = True,
|
||||
credential_dir: str | None = None,
|
||||
) -> None:
|
||||
# A mounted credential directory takes precedence: it is the only
|
||||
# source that can change while the process runs, which is what dynamic
|
||||
# leases require.
|
||||
self.credentials = (
|
||||
CredentialDirectory(credential_dir) if credential_dir else None
|
||||
)
|
||||
# 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():
|
||||
if not self.dsn and not self.credentials 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)"
|
||||
|
|
@ -155,17 +163,32 @@ class PostgresAuditBackend:
|
|||
raise ValueError(f"unsafe schema name: {schema!r}")
|
||||
self.schema = schema
|
||||
self.retention_days = retention_days
|
||||
base_kwargs = {
|
||||
"autocommit": True,
|
||||
# A stalled write must surface as unavailable rather than hold a
|
||||
# request open indefinitely.
|
||||
"options": f"-c statement_timeout={int(statement_timeout_ms)}",
|
||||
}
|
||||
# psycopg_pool resolves a callable on every connection attempt, so
|
||||
# passing one is what makes a rotated lease take effect without a
|
||||
# restart. A fixed dict would freeze the credential at startup.
|
||||
kwargs: Any = base_kwargs
|
||||
if self.credentials:
|
||||
def kwargs() -> dict: # type: ignore[misc]
|
||||
params = self.credentials.read()
|
||||
if "dsn" in params:
|
||||
raise ValueError(
|
||||
"a 'dsn' file is not supported with pooled connections; "
|
||||
"supply username/password/host/port/dbname files"
|
||||
)
|
||||
return {**base_kwargs, **params}
|
||||
|
||||
try:
|
||||
self.pool = ConnectionPool(
|
||||
self.dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={
|
||||
"autocommit": True,
|
||||
# A stalled write must surface as unavailable rather than
|
||||
# hold a request open indefinitely.
|
||||
"options": f"-c statement_timeout={int(statement_timeout_ms)}",
|
||||
},
|
||||
kwargs=kwargs,
|
||||
open=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue