Deliver database credentials as a rotatable mounted directory
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

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:
tegwick 2026-08-12 01:36:28 +02:00
parent fc48378a3f
commit 7636e83dcc
7 changed files with 364 additions and 20 deletions

87
audit_core/credentials.py Normal file
View 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