"""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"). Auth resolution order for the token: 1. explicit bootstrap token file (--bootstrap-token-file), mode-checked; 2. BAO_TOKEN / VAULT_TOKEN environment variable; 3. otherwise unauthenticated (only dry-run / read-health works). 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 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") @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") import hashlib fingerprint = hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12] 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}"]) # -- 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}"])