audit-core/audit_core/credentials.py
tegwick c404c910cd Read mounted DB credentials from a Kubernetes snapshot
Secret volume rotation swaps ..data. Sequential reads of username then
password can tear across two leases. Resolve the snapshot once.

Also document why ESO AppRole login cannot parent database/creds leases:
the token discard DROP ROLEs the role ESO just stored.
2026-08-13 12:25:32 +02:00

112 lines
3.9 KiB
Python

"""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.
Kubernetes Secret volumes update by swapping the ``..data`` directory.
Reading ``username`` then ``password`` from the mount point can tear
across that swap and pair a new user with an old password. Resolve
``..data`` once and read every file from that snapshot.
"""
last_error: Exception | None = None
for _ in range(5):
try:
params = self._read_snapshot()
except (FileNotFoundError, ValueError) as exc:
last_error = exc
continue
if "dsn" in params:
self._note_rotation(params["dsn"])
return params
if "user" not in params:
last_error = ValueError(f"no username in credential directory {self.path}")
continue
self._note_rotation(params.get("password", ""))
return params
if last_error is not None:
raise last_error
raise ValueError(f"no username in credential directory {self.path}")
def _snapshot_dir(self) -> pathlib.Path:
data = self.path / "..data"
if data.exists():
return data.resolve()
return self.path
def _read_snapshot(self) -> dict[str, str]:
root = self._snapshot_dir()
params: dict[str, str] = {}
dsn_file = root / "dsn"
if dsn_file.exists():
params["dsn"] = dsn_file.read_text().strip()
return params
for name, keyword in _FIELDS.items():
candidate = root / name
if candidate.exists():
value = candidate.read_text().strip()
if value:
params.setdefault(keyword, value)
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