"""Phase 4: execute an approved construction plan against OpenBao. Narrow on purpose (MASON-WP-0001-T04) — implements only the operations the first real plan (rein-openweights-openrouter-approle, credential_type: openbao-approle-kv) needs: a read-only policy scoped to one KV path, an AppRole bound to it, and role_id/secret_id delivery. Never writes to the KV path itself and never touches the downstream secret value (the OpenRouter API key). The KV path a policy references comes into existence later, when the founder does a paste-once-provision through ops-warden's desk (`bao kv put`) — not through this executor. role_id/secret_id are the AppRole's own access credential, not the downstream secret; generating and delivering them is squarely ops-mason's job (INTENT.md: "create ... credentials, tokens"), but they are still handled write-only — never logged, never returned in a printable form beyond confirmation that delivery happened. """ from __future__ import annotations import os import subprocess from dataclasses import dataclass, field from pathlib import Path from ops_mason.audit import record_build from ops_mason.plan import ConstructionPlan class BuildRefused(RuntimeError): """The plan is not approved. This is the one place a bug is a security incident.""" class BuildError(RuntimeError): """The plan was approved but a bao operation failed.""" @dataclass class AppRoleKVSpec: policy_name: str kv_path: str approle_name: str # token_num_uses has no default on purpose: OpenBao's own default (0) # means *unlimited* uses, the opposite of "bounded" -- a plan that says # "bounded token_num_uses" (the agent-harness-binky-mail shape, which # actually uses 8) must have that number chosen explicitly, not # inherited silently from whatever OpenBao considers a sane default. token_num_uses: int kv_capabilities: tuple[str, ...] = ("read",) token_ttl: str = "15m" token_max_ttl: str = "30m" secret_id_ttl: str = "0" delivery_dir: Path | None = None bao_bin: str = "bao" audit_log_path: Path | None = None def _run(bao_bin: str, args: list[str], input_text: str | None = None) -> str: proc = subprocess.run( [bao_bin, *args], input=input_text, capture_output=True, text=True, timeout=30, ) if proc.returncode != 0: raise BuildError(f"`{bao_bin} {' '.join(args)}` failed: {proc.stderr.strip()[:300]}") return proc.stdout def _policy_hcl(kv_path: str, capabilities: tuple[str, ...]) -> str: """Policy HCL for a KV v2 path — grants on both data/ and metadata/ sub-paths. KV v2 routes actual secret reads/writes through `/data/`; a policy written against the bare `/` (the KV v1 shape) silently denies everything on a v2 mount. Caught live during MASON-WP-0001-T05's build — `bao token capabilities` on the bare path reported full access, but the actual `kv get` still 403'd, because OpenBao evaluates policy against the real `data/`-prefixed path, not the one a caller might naively check capabilities against. """ mount, _, rest = kv_path.partition("/") caps = ", ".join(f'"{c}"' for c in capabilities) return ( f'path "{mount}/data/{rest}" {{\n capabilities = [{caps}]\n}}\n\n' f'path "{mount}/metadata/{rest}" {{\n capabilities = [{caps}]\n}}\n' ) def build_approle_kv_lane(plan: ConstructionPlan, spec: AppRoleKVSpec) -> dict[str, str]: """Create the policy + AppRole for an `openbao-approle-kv` plan, deliver role_id/secret_id. Refuses unless plan.is_approved(). Returns object names only (no secret material) and appends a metadata-only audit record. """ if not plan.is_approved(): raise BuildRefused( f"plan {plan.id!r} is not approved " f"(status={plan.status!r}, approved_by={plan.approved_by!r}, " f"approved_at={plan.approved_at!r}) — refusing to build" ) policy_hcl = _policy_hcl(spec.kv_path, spec.kv_capabilities) _run(spec.bao_bin, ["policy", "write", spec.policy_name, "-"], input_text=policy_hcl) _run( spec.bao_bin, [ "write", f"auth/approle/role/{spec.approle_name}", f"token_policies={spec.policy_name}", f"token_ttl={spec.token_ttl}", f"token_max_ttl={spec.token_max_ttl}", f"token_num_uses={spec.token_num_uses}", f"secret_id_ttl={spec.secret_id_ttl}", ], ) role_id = _run( spec.bao_bin, ["read", "-field=role_id", f"auth/approle/role/{spec.approle_name}/role-id"] ).strip() secret_id = _run( spec.bao_bin, ["write", "-field=secret_id", "-f", f"auth/approle/role/{spec.approle_name}/secret-id"], ).strip() delivered_to = "" if spec.delivery_dir is not None: spec.delivery_dir.mkdir(parents=True, exist_ok=True) os.chmod(spec.delivery_dir, 0o700) role_id_path = spec.delivery_dir / "role_id" secret_id_path = spec.delivery_dir / "secret_id" role_id_path.write_text(role_id + "\n") secret_id_path.write_text(secret_id + "\n") os.chmod(role_id_path, 0o600) os.chmod(secret_id_path, 0o600) delivered_to = str(spec.delivery_dir) objects = { "policy_name": spec.policy_name, "approle_name": spec.approle_name, "kv_path": spec.kv_path, "delivered_to": delivered_to, } record_build( plan_id=plan.id, plan_path=str(plan.path), approved_by=plan.approved_by or "", approved_at=plan.approved_at or "", objects=objects, log_path=spec.audit_log_path, ) return objects