"""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 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 _check_token_file(path: Path) -> str: """Read a bootstrap token file after enforcing mode-0600 and out-of-repo.""" if not path.exists(): raise ProvisioningError(f"bootstrap token file not found: {path}") st = path.stat() if st.st_mode & 0o077: raise ProvisioningError( f"bootstrap token 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"bootstrap token 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"bootstrap token file {path} is empty") return token @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 # -- 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 approle_login_token(self, role_name: str) -> str: """Login as the approle and return a scoped child token. Used only for verification / exec delivery; never logged.""" role_id = self.read_approle_role_id(role_name) secret_id = self.create_approle_secret_id(role_name) token = self._run_ok( [ "write", "-field=token", "auth/approle/login", f"role_id={role_id}", f"secret_id={secret_id}", ] ).strip() return token 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_put(self, mount: str, path: str, field: str, value: str) -> None: """Write a single field. `value` is a secret and is passed via stdin-free argv only as a key=value to the local CLI; it is never logged or returned.""" self._run_ok(["kv", "put", f"{mount}/{path}", f"{field}={value}"]) 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_field_present(self, mount: str, path: str, field: str, *, token: str | None = None) -> bool: """Return whether `field` exists at the path — WITHOUT returning its value. If `token` is given, the read is attempted as that (scoped) token, so a True/False result doubles as a positive/negative access check. """ 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 False try: doc = json.loads(proc.stdout) except json.JSONDecodeError: return False data = doc.get("data", {}).get("data", {}) return field in data and bool(data[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}"])