feat(mvp): working secrets-engine CLI for the whynot-design npm publish lane
Implements SECRETS-WP-0002 end to end as a uv-managed Python package: - catalog: non-secret lane registry + strict validator (build/test/prod) - stage roles + OpenBao ACL policies; guards refuse wildcards, sys/, identity/, admin names, and cross-stage paths before any backend call - plan/apply: dry-run-first, idempotent policy + approle apply, decision-gated - decisions: State Hub lookup with local-fixture fallback; non-secret evidence to JSONL + hub progress, scrubbed of any value - provision/verify: mode-0600 file import + generated test values; positive/ negative checks that never print the value - exec delivery: `exec --catalog ... -- npm publish` injects the token via a temp .npmrc for the child only, cleaned up on exit/failure/interrupt - ops-warden routing contract + hardening backlog docs - 34 tests incl. live OpenBao integration; scripts/demo-e2e.sh runs the full chain against a throwaway bao dev server Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
58c24cff53
commit
a852d3f1ff
47 changed files with 3743 additions and 122 deletions
232
src/secrets_engine/openbao.py
Normal file
232
src/secrets_engine/openbao.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""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
|
||||
|
||||
|
||||
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.
|
||||
for parent in path.resolve().parents:
|
||||
if (parent / ".git").exists():
|
||||
raise ProvisioningError(
|
||||
f"bootstrap token file {path} is inside a Git worktree ({parent}); "
|
||||
"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 and "already in use" not in enable.stderr:
|
||||
raise BackendError(f"could not enable approle: {enable.stderr.strip()}")
|
||||
|
||||
def write_approle(self, role_name: str, policies: list[str], ttl: str = "30m") -> None:
|
||||
self._run_ok(
|
||||
[
|
||||
"write",
|
||||
f"auth/approle/role/{role_name}",
|
||||
f"token_policies={','.join(policies)}",
|
||||
f"token_ttl={ttl}",
|
||||
f"token_max_ttl={ttl}",
|
||||
"secret_id_num_uses=0",
|
||||
"token_num_uses=0",
|
||||
]
|
||||
)
|
||||
|
||||
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._run_ok(
|
||||
["read", "-field=role_id", f"auth/approle/role/{role_name}/role-id"]
|
||||
).strip()
|
||||
secret_id = self._run_ok(
|
||||
["write", "-field=secret_id", "-f", f"auth/approle/role/{role_name}/secret-id"]
|
||||
).strip()
|
||||
token = self._run_ok(
|
||||
[
|
||||
"write",
|
||||
"-field=token",
|
||||
"auth/approle/login",
|
||||
f"role_id={role_id}",
|
||||
f"secret_id={secret_id}",
|
||||
]
|
||||
).strip()
|
||||
return token
|
||||
|
||||
# -- 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 and "already in use" not in enable.stderr:
|
||||
raise BackendError(f"could not enable kv at {mount}: {enable.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}"])
|
||||
Loading…
Add table
Add a link
Reference in a new issue