From c404c910cd726415336382d5ca4c8b48e15ac40d Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 13 Aug 2026 12:25:32 +0200 Subject: [PATCH] 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. --- audit_core/credentials.py | 43 +++++++++++++++++++++------ deploy/clustersecretstore.yaml | 39 ++++++++++-------------- tests/test_credentials.py | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 33 deletions(-) create mode 100644 tests/test_credentials.py diff --git a/audit_core/credentials.py b/audit_core/credentials.py index 759c5ef..0936fd6 100644 --- a/audit_core/credentials.py +++ b/audit_core/credentials.py @@ -55,25 +55,50 @@ class CredentialDirectory: 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 = self.path / "dsn" + dsn_file = root / "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 + candidate = root / 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: diff --git a/deploy/clustersecretstore.yaml b/deploy/clustersecretstore.yaml index 5228ca8..89813e9 100644 --- a/deploy/clustersecretstore.yaml +++ b/deploy/clustersecretstore.yaml @@ -1,9 +1,8 @@ # Template of the railiance-platform add-on store. Prefer applying from: # ~/railiance-platform/argocd/platform-addons/openbao-secretstore/openbao-audit-core.clustersecretstore.yaml # -# AppRole auth (ops-mason plan audit-core-openbao-runtime-custody). -# Prerequisite: Secret external-secrets/openbao-audit-core-approle -# (role-id / secret-id), delivered by mason phase 4. +# Token auth for database/creds (see comment on tokenSecretRef). +# AppRole secret remains for a later lease-aware generator. --- apiVersion: external-secrets.io/v1 kind: ClusterSecretStore @@ -20,21 +19,19 @@ spec: path: platform version: v2 auth: - appRole: - path: approle - roleRef: - name: openbao-audit-core-approle - namespace: external-secrets - key: role-id - secretRef: - name: openbao-audit-core-approle - namespace: external-secrets - key: secret-id + # Dynamic DB leases are revoked when the requesting token dies. + # ESO AppRole login+discard therefore DROP ROLEs the lease it just + # stored. A renewable orphan token is the working ESO client for + # database/creds; the AppRole remains for a later lease-aware generator. + tokenSecretRef: + name: openbao-audit-core-eso-token + namespace: external-secrets + key: token conditions: - namespaces: - audit-core --- -# Database engine, not KV. Same AppRole, different mount. +# Database engine, not KV. Same ESO token, different mount. apiVersion: external-secrets.io/v1 kind: ClusterSecretStore metadata: @@ -50,16 +47,10 @@ spec: path: database version: v1 auth: - appRole: - path: approle - roleRef: - name: openbao-audit-core-approle - namespace: external-secrets - key: role-id - secretRef: - name: openbao-audit-core-approle - namespace: external-secrets - key: secret-id + tokenSecretRef: + name: openbao-audit-core-eso-token + namespace: external-secrets + key: token conditions: - namespaces: - audit-core diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..8eddf0d --- /dev/null +++ b/tests/test_credentials.py @@ -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