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.
This commit is contained in:
tegwick 2026-08-13 12:25:32 +02:00
parent 52d8545952
commit c404c910cd
3 changed files with 103 additions and 33 deletions

View file

@ -55,25 +55,50 @@ class CredentialDirectory:
Called per connection attempt, so a rotated password is picked up by Called per connection attempt, so a rotated password is picked up by
the next connection the pool opens without any restart. 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] = {} params: dict[str, str] = {}
dsn_file = self.path / "dsn" dsn_file = root / "dsn"
if dsn_file.exists(): if dsn_file.exists():
params["dsn"] = dsn_file.read_text().strip() params["dsn"] = dsn_file.read_text().strip()
self._note_rotation(params["dsn"])
return params return params
for name, keyword in _FIELDS.items(): for name, keyword in _FIELDS.items():
candidate = self.path / name candidate = root / name
if candidate.exists(): if candidate.exists():
value = candidate.read_text().strip() value = candidate.read_text().strip()
if value: if value:
params.setdefault(keyword, 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 return params
def _note_rotation(self, secret: str) -> None: def _note_rotation(self, secret: str) -> None:

View file

@ -1,9 +1,8 @@
# Template of the railiance-platform add-on store. Prefer applying from: # Template of the railiance-platform add-on store. Prefer applying from:
# ~/railiance-platform/argocd/platform-addons/openbao-secretstore/openbao-audit-core.clustersecretstore.yaml # ~/railiance-platform/argocd/platform-addons/openbao-secretstore/openbao-audit-core.clustersecretstore.yaml
# #
# AppRole auth (ops-mason plan audit-core-openbao-runtime-custody). # Token auth for database/creds (see comment on tokenSecretRef).
# Prerequisite: Secret external-secrets/openbao-audit-core-approle # AppRole secret remains for a later lease-aware generator.
# (role-id / secret-id), delivered by mason phase 4.
--- ---
apiVersion: external-secrets.io/v1 apiVersion: external-secrets.io/v1
kind: ClusterSecretStore kind: ClusterSecretStore
@ -20,21 +19,19 @@ spec:
path: platform path: platform
version: v2 version: v2
auth: auth:
appRole: # Dynamic DB leases are revoked when the requesting token dies.
path: approle # ESO AppRole login+discard therefore DROP ROLEs the lease it just
roleRef: # stored. A renewable orphan token is the working ESO client for
name: openbao-audit-core-approle # database/creds; the AppRole remains for a later lease-aware generator.
namespace: external-secrets tokenSecretRef:
key: role-id name: openbao-audit-core-eso-token
secretRef: namespace: external-secrets
name: openbao-audit-core-approle key: token
namespace: external-secrets
key: secret-id
conditions: conditions:
- namespaces: - namespaces:
- audit-core - audit-core
--- ---
# Database engine, not KV. Same AppRole, different mount. # Database engine, not KV. Same ESO token, different mount.
apiVersion: external-secrets.io/v1 apiVersion: external-secrets.io/v1
kind: ClusterSecretStore kind: ClusterSecretStore
metadata: metadata:
@ -50,16 +47,10 @@ spec:
path: database path: database
version: v1 version: v1
auth: auth:
appRole: tokenSecretRef:
path: approle name: openbao-audit-core-eso-token
roleRef: namespace: external-secrets
name: openbao-audit-core-approle key: token
namespace: external-secrets
key: role-id
secretRef:
name: openbao-audit-core-approle
namespace: external-secrets
key: secret-id
conditions: conditions:
- namespaces: - namespaces:
- audit-core - audit-core

54
tests/test_credentials.py Normal file
View file

@ -0,0 +1,54 @@
"""Mounted-directory credential reads (AUDIT-WP-0005-T02)."""
from audit_core.credentials import CredentialDirectory
def _write(directory, **fields):
for name, value in fields.items():
(directory / name).write_text(value)
def test_reads_a_flat_directory(tmp_path):
_write(tmp_path, username="u1", password="p1", host="db", port="5432", dbname="audit_core")
params = CredentialDirectory(tmp_path).read()
assert params["user"] == "u1"
assert params["password"] == "p1"
assert params["host"] == "db"
def test_kubernetes_snapshot_is_not_torn(tmp_path):
"""A Secret volume swap must not pair lease A's user with lease B's password."""
first = tmp_path / "..ts1"
second = tmp_path / "..ts2"
first.mkdir()
second.mkdir()
_write(first, username="lease-a", password="secret-a", host="db", port="5432", dbname="audit")
_write(second, username="lease-b", password="secret-b", host="db", port="5432", dbname="audit")
data = tmp_path / "..data"
data.symlink_to(first.name)
for name in ("username", "password", "host", "port", "dbname"):
(tmp_path / name).symlink_to(f"..data/{name}")
creds = CredentialDirectory(tmp_path)
# Flip the snapshot after the directory is opened the way kubelet does:
# replace ..data to point at the new timestamped directory.
seen = creds.read()
data.unlink()
data.symlink_to(second.name)
after = creds.read()
assert seen["user"] == "lease-a" and seen["password"] == "secret-a"
assert after["user"] == "lease-b" and after["password"] == "secret-b"
def test_rotation_is_logged_by_fingerprint_not_value(tmp_path, caplog):
caplog.set_level("INFO", logger="audit_core.credentials")
_write(tmp_path, username="u", password="one", host="db", port="5432", dbname="audit")
creds = CredentialDirectory(tmp_path)
creds.read()
(tmp_path / "password").write_text("two")
creds.read()
text = caplog.text
assert "rotated" in text
assert "one" not in text
assert "two" not in text