"""OpenBao backend adapter. Thin wrapper over the `bao` CLI. Isolated here so the rest of the engine speaks in lanes/plans, not in OpenBao endpoint quirks (FR: "isolate backend adapter"). Named engine auth providers (no implicit fallback) live in ``engine_auth``. ``OpenBaoClient.resolve`` only materializes an already-selected bootstrap file or environment token. This adapter NEVER returns a secret value to its callers except through the narrow `read_field_present()` (boolean) and the exec-delivery path, which writes straight into a child process and never logs. """ from __future__ import annotations import hashlib import json import os import shutil import stat import subprocess import tempfile from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from secrets_engine.errors import BackendError, ProvisioningError from secrets_engine.safe_paths import containing_git_worktree def read_strict_token_file(path: Path, *, purpose: str = "token") -> str: """Read token material only from a mode-0600 path outside Git worktrees.""" if not path.exists(): raise ProvisioningError(f"{purpose} file not found: {path}") st = path.stat() if st.st_mode & 0o077: raise ProvisioningError( f"{purpose} file {path} is group/other-accessible " f"(mode {oct(st.st_mode & 0o777)}); must be 0600" ) # Refuse a token file living inside a Git worktree. worktree = containing_git_worktree(path) if worktree is not None: raise ProvisioningError( f"{purpose} file {path} is inside a Git worktree ({worktree}); " "store it outside any repo" ) token = path.read_text(encoding="utf-8").strip() if not token: raise ProvisioningError(f"{purpose} file {path} is empty") return token def _check_token_file(path: Path) -> str: """Compatibility wrapper for bootstrap authentication input.""" return read_strict_token_file(path, purpose="bootstrap token") def accessor_fingerprint(accessor: str) -> str: """Non-secret handle for an accessor the caller already holds.""" return hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12] @dataclass class ScopedTokenSession: """One AppRole login token that revokes itself on close.""" client: "OpenBaoClient" accessor_fingerprint: str closed: bool = False revocation_attempted: bool = False revocation_succeeded: bool = False def evidence(self) -> dict[str, object]: """Return non-secret lifecycle evidence for this issued session.""" return { "session_handle": self.accessor_fingerprint, "established": True, "revocation_attempted": self.revocation_attempted, "revocation_succeeded": self.revocation_succeeded, } def close(self) -> None: if self.closed: return self.revocation_attempted = True try: self.client._run_ok(["token", "revoke", "-self"]) self.revocation_succeeded = True finally: # Drop the credential from the reusable client object even when # backend cleanup fails; TTL/use limits remain the final backstop. self.client.token = "" self.closed = True @dataclass class OpenBaoClient: addr: str token: str = "" bao_bin: str = "" @classmethod def resolve( cls, addr: str, *, bootstrap_token_file: str | Path | None = None ) -> "OpenBaoClient": token = "" if bootstrap_token_file: token = _check_token_file(Path(bootstrap_token_file)) else: token = os.environ.get("BAO_TOKEN", os.environ.get("VAULT_TOKEN", "")) bao_bin = shutil.which("bao") or shutil.which("vault") or "" return cls(addr=addr, token=token, bao_bin=bao_bin) # -- low level --------------------------------------------------------- def _run(self, args: list[str], *, stdin: str | None = None) -> subprocess.CompletedProcess: if not self.bao_bin: raise BackendError( "no 'bao' (or 'vault') CLI on PATH; cannot reach OpenBao backend" ) env = dict(os.environ) env["BAO_ADDR"] = self.addr env["VAULT_ADDR"] = self.addr if self.token: env["BAO_TOKEN"] = self.token env["VAULT_TOKEN"] = self.token try: return subprocess.run( [self.bao_bin, *args], input=stdin, env=env, capture_output=True, text=True, timeout=30, ) except FileNotFoundError as e: raise BackendError(f"backend binary not runnable: {e}") from e except subprocess.TimeoutExpired as e: raise BackendError(f"backend call timed out: {' '.join(args)}") from e def _run_ok(self, args: list[str], *, stdin: str | None = None) -> str: proc = self._run(args, stdin=stdin) if proc.returncode != 0: # stderr from bao does not contain the secret value for these calls. raise BackendError( f"bao {' '.join(args[:2])} failed (exit {proc.returncode}): " f"{proc.stderr.strip() or proc.stdout.strip()}" ) return proc.stdout def _run_ok_with_json_file(self, args: list[str], payload: dict) -> str: """Run a CLI operation with JSON from a strict temporary file reference.""" fd, temp_name = tempfile.mkstemp(prefix="se-bao-input-", suffix=".json") temp_path = Path(temp_name) try: os.fchmod(fd, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: fd = -1 json.dump(payload, fh) fh.write("\n") return self._run_ok([*args, f"@{temp_path}"]) finally: if fd >= 0: os.close(fd) try: temp_path.unlink() except FileNotFoundError: pass # -- health / capabilities -------------------------------------------- def is_reachable(self) -> bool: if not self.bao_bin: return False proc = self._run(["status", "-format=json"]) # status returns non-zero when sealed but still reachable; treat any # parseable JSON as reachable. try: json.loads(proc.stdout or "{}") return True except json.JSONDecodeError: return proc.returncode == 0 # -- policies ---------------------------------------------------------- def write_policy(self, name: str, hcl: str) -> None: self._run_ok(["policy", "write", name, "-"], stdin=hcl) def read_policy(self, name: str) -> str | None: proc = self._run(["policy", "read", name]) if proc.returncode != 0: return None return proc.stdout # -- approle ----------------------------------------------------------- def ensure_approle_enabled(self) -> None: proc = self._run(["auth", "list", "-format=json"]) if proc.returncode == 0: try: methods = json.loads(proc.stdout) if "approle/" in methods: return except json.JSONDecodeError: pass enable = self._run(["auth", "enable", "approle"]) if enable.returncode == 0: return stderr = enable.stderr or "" if "already in use" in stderr: return if "permission denied" in stderr.lower(): return raise BackendError(f"could not enable approle: {stderr.strip()}") def write_approle( self, role_name: str, policies: list[str], ttl: str = "30m", *, max_ttl: str | None = None, secret_id_ttl: str | None = None, secret_id_num_uses: int = 0, token_num_uses: int = 0, ) -> None: args = [ "write", f"auth/approle/role/{role_name}", f"token_policies={','.join(policies)}", f"token_ttl={ttl}", f"token_max_ttl={max_ttl or ttl}", f"secret_id_num_uses={secret_id_num_uses}", f"token_num_uses={token_num_uses}", ] if secret_id_ttl: args.append(f"secret_id_ttl={secret_id_ttl}") self._run_ok(args) def approle_exists(self, role_name: str) -> bool: proc = self._run(["read", f"auth/approle/role/{role_name}"]) return proc.returncode == 0 def read_approle_role_id(self, role_name: str) -> str: return self._run_ok( ["read", "-field=role_id", f"auth/approle/role/{role_name}/role-id"] ).strip() def create_approle_secret_id(self, role_name: str) -> str: return self._run_ok( ["write", "-field=secret_id", "-f", f"auth/approle/role/{role_name}/secret-id"] ).strip() def create_approle_session(self, role_name: str) -> ScopedTokenSession: """Login through AppRole without putting role/secret ids in argv.""" role_id = self.read_approle_role_id(role_name) secret_id = self.create_approle_secret_id(role_name) try: output = self._run_ok_with_json_file( ["write", "-format=json", "auth/approle/login"], {"role_id": role_id, "secret_id": secret_id}, ) finally: del secret_id try: auth = json.loads(output)["auth"] token = str(auth["client_token"]) accessor = str(auth.get("accessor", "")) except (json.JSONDecodeError, KeyError, TypeError) as e: raise BackendError("malformed AppRole login response") from e if not token: raise BackendError("AppRole login returned an empty token") fingerprint = accessor_fingerprint(accessor) scoped = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin) del token return ScopedTokenSession( client=scoped, accessor_fingerprint=fingerprint, ) @contextmanager def approle_session(self, role_name: str): """Yield a scoped client and revoke its token on every exit path.""" session = self.create_approle_session(role_name) try: yield session finally: session.close() def token_capabilities(self, path: str, *, token: str) -> list[str]: """Return token capabilities for a path without returning any secret value.""" client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin) out = client._run_ok(["token", "capabilities", "-format=json", path]) try: data = json.loads(out) except json.JSONDecodeError: return [line.strip() for line in out.splitlines() if line.strip()] if isinstance(data, list): return [str(item) for item in data] if isinstance(data, dict): caps = data.get("capabilities", []) if isinstance(caps, list): return [str(item) for item in caps] return [] def delete_policy(self, name: str) -> None: self._run_ok(["policy", "delete", name]) def delete_approle(self, role_name: str) -> None: self._run_ok(["delete", f"auth/approle/role/{role_name}"]) def login_jwt(self, mount: str, role: str, jwt: str) -> ScopedTokenSession: """Login through JWT auth without putting the JWT in argv.""" output = self._run_ok_with_json_file( ["write", "-format=json", f"auth/{mount}/login"], {"role": role, "jwt": jwt}, ) try: auth = json.loads(output)["auth"] token = str(auth["client_token"]) accessor = str(auth.get("accessor", "")) except (json.JSONDecodeError, KeyError, TypeError) as e: raise BackendError("malformed JWT login response") from e if not token: raise BackendError("JWT login returned an empty token") fingerprint = accessor_fingerprint(accessor) scoped = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin) del token return ScopedTokenSession(client=scoped, accessor_fingerprint=fingerprint) def revoke_accessor(self, accessor: str) -> None: """Revoke a token the operator already holds, by accessor only.""" if not accessor or any(ch.isspace() for ch in accessor): raise BackendError("token accessor is missing or invalid") self._run_ok(["token", "revoke", "-accessor", accessor]) # -- KV v2 ------------------------------------------------------------- def kv_mount_exists(self, mount: str) -> bool: proc = self._run(["secrets", "list", "-format=json"]) if proc.returncode != 0: return False try: return f"{mount}/" in json.loads(proc.stdout) except json.JSONDecodeError: return False def ensure_kv_mount(self, mount: str) -> None: if self.kv_mount_exists(mount): return enable = self._run(["secrets", "enable", "-path", mount, "kv-v2"]) if enable.returncode == 0: return stderr = enable.stderr or "" if "already in use" in stderr: return # Stage roles (e.g. secrets-engine-prod) cannot manage sys/mounts. When the # caller also cannot list mounts, a permission-denied enable means the mount # was operator-preprovisioned — continue to policy/approle apply. if "permission denied" in stderr.lower(): return raise BackendError(f"could not enable kv at {mount}: {stderr.strip()}") def kv_current_version(self, mount: str, path: str) -> int: """Return current KV v2 version from metadata, or 0 when absent. Metadata contains no secret values. Permission/backend errors fail closed rather than being misread as a new path. """ proc = self._run( ["kv", "metadata", "get", "-format=json", f"-mount={mount}", path] ) if proc.returncode != 0: message = f"{proc.stderr}\n{proc.stdout}".lower() if "no value found" in message or "not found" in message or "404" in message: return 0 raise BackendError( f"bao kv metadata get failed (exit {proc.returncode}): " f"{proc.stderr.strip() or proc.stdout.strip()}" ) try: version = json.loads(proc.stdout).get("data", {}).get("current_version", 0) return int(version) except (json.JSONDecodeError, TypeError, ValueError) as e: raise BackendError("malformed KV metadata response") from e def kv_patch_fields( self, mount: str, path: str, values: dict[str, str], *, expected_version: int | None = None, ) -> int: """Atomically update declared fields without exposing values in argv. Existing paths use server-side HTTP PATCH with CAS, preserving every unmentioned sibling. New paths use CAS=0 put. Values are written to a mode-0600 temporary JSON input file referenced by path and removed in a finally block. The returned integer is the version used as the CAS base. """ if not values: raise BackendError("refusing empty KV field update") if any(not isinstance(k, str) or not k for k in values): raise BackendError("KV field update contains an invalid field name") version = ( self.kv_current_version(mount, path) if expected_version is None else expected_version ) if version < 0: raise BackendError("expected KV version must be non-negative") if version == 0: args = [ "kv", "put", f"-mount={mount}", "-cas=0", path, ] else: args = [ "kv", "patch", f"-mount={mount}", "-method=patch", f"-cas={version}", path, ] self._run_ok_with_json_file(args, values) return version def kv_metadata_exists(self, mount: str, path: str) -> bool: proc = self._run(["kv", "metadata", "get", "-format=json", f"{mount}/{path}"]) return proc.returncode == 0 def kv_fields_present( self, mount: str, path: str, fields: list[str] | tuple[str, ...], *, token: str | None = None, ) -> dict[str, bool]: """Return declared-field presence after one read, never field values. If ``token`` is given, the read is attempted as that scoped token. A denied or malformed read reports every requested field as absent; no response data is returned to the caller. """ requested = tuple(dict.fromkeys(fields)) if not requested: return {} client = self if token is not None: client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin) proc = client._run(["kv", "get", "-format=json", f"{mount}/{path}"]) if proc.returncode != 0: return {field: False for field in requested} try: doc = json.loads(proc.stdout) except json.JSONDecodeError: return {field: False for field in requested} data = doc.get("data", {}).get("data", {}) if not isinstance(data, dict): return {field: False for field in requested} return {field: field in data and bool(data[field]) for field in requested} def kv_field_present( self, mount: str, path: str, field: str, *, token: str | None = None ) -> bool: """Compatibility wrapper for a single non-secret presence result.""" return self.kv_fields_present(mount, path, [field], token=token)[field] def kv_can_read(self, mount: str, path: str, *, token: str) -> bool: """True iff `token` is permitted to read the path at all (no value used).""" client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin) proc = client._run(["kv", "get", "-format=json", f"{mount}/{path}"]) return proc.returncode == 0 def kv_delete_metadata(self, mount: str, path: str) -> None: self._run_ok(["kv", "metadata", "delete", f"{mount}/{path}"])