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