MASON-WP-0015-T04: structure-only AppRole build, post-build policy check

build_approle_kv_lane no longer reads role-id or mints secret-id;
deliver_approle_credentials is the separate founder/applier step. New
policies are read back and compared before the role is written. The
Kubernetes build uses token_policies/token_ttl (legacy policies is denied
by ops-mason-construct).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 278674@bnt-lap001
Assistant-Session: 64827407-e196-4ce3-b70c-76e7b537493f
This commit is contained in:
tegwick 2026-10-18 12:52:57 +02:00
parent d19e6b1633
commit 91c4e7b527
6 changed files with 144 additions and 62 deletions

View file

@ -333,6 +333,18 @@ def _verify_policy(bao_bin: str, name: str, expected: str) -> None:
raise BuildRefused("live reused policy does not match the approved content pin")
def _verify_written_policy(bao_bin: str, name: str, expected: str) -> None:
"""Post-build check: the stored policy is the text the plan approved.
ops-mason-construct can write any content under workload-kv-read-*, so
this is the detective control (MASON-IN-0005). OpenBao may drop a
trailing newline.
"""
content = _run(bao_bin, ["policy", "read", name])
if content.rstrip("\n") != expected.rstrip("\n"):
raise BuildError(f"written policy {name} does not match the approved text; build not recorded")
@contextmanager
def _delivery_files(path: Path):
"""Reserve private files before Bao writes, without following any symlink.
@ -391,33 +403,64 @@ def _write_credential(fd: int, value: str) -> None:
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.
"""Create the policy + AppRole for an `openbao-approle-kv` plan. Structure only.
Refuses unless plan.is_approved(). Returns object names only (no
secret material) and appends a metadata-only audit record.
Refuses unless plan.is_approved(). Never reads a role_id or mints a
secret_id: ops-mason-construct denies both (MASON-IN-0005), so delivery
is the founder's step, `deliver_approle_credentials` under a founder or
applier session. Returns object names only and appends a metadata-only
audit record.
"""
spec = replace(spec)
plan = _approved_build(plan, spec)
if spec.reuse_policy:
_verify_policy(spec.bao_bin, spec.policy_name, spec.reuse_policy_sha256)
else:
policy_hcl = _policy_hcl(spec.kv_path, spec.kv_capabilities)
_run(spec.bao_bin, ["policy", "write", spec.policy_name, "-"], input_text=policy_hcl)
_verify_written_policy(spec.bao_bin, spec.policy_name, 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}",
],
)
objects = {
"approved_spec_sha256": plan.approved_spec_sha256,
"policy_name": spec.policy_name,
"approle_name": spec.approle_name,
"kv_path": spec.kv_path,
"credential_delivery": f"founder: deliver_approle_credentials to {spec.delivery_dir}",
}
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
def deliver_approle_credentials(plan: ConstructionPlan, spec: AppRoleKVSpec) -> dict[str, str]:
"""Deliver role_id/secret_id for an approved, built AppRole lane, write-only.
Not ops-mason's act: ops-mason-construct denies role-id and secret-id, so
this runs only under a founder or applier session (MASON-IN-0005). Values
go to private files and are never returned or logged.
"""
spec = replace(spec)
plan = _approved_build(plan, spec)
with _delivery_files(spec.delivery_dir) as files:
if not spec.reuse_policy:
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()
@ -428,14 +471,11 @@ def build_approle_kv_lane(plan: ConstructionPlan, spec: AppRoleKVSpec) -> dict[s
_write_credential(files["role_id"], role_id)
_write_credential(files["secret_id"], secret_id)
delivered_to = str(spec.delivery_dir)
objects = {
"approved_spec_sha256": plan.approved_spec_sha256,
"policy_name": spec.policy_name,
"approle_name": spec.approle_name,
"kv_path": spec.kv_path,
"delivered_to": delivered_to,
"delivered_to": str(spec.delivery_dir),
}
record_build(
plan_id=plan.id,
@ -463,8 +503,8 @@ def build_kubernetes_kv_lane(
f"auth/kubernetes/role/{spec.role_name}",
f"bound_service_account_names={','.join(spec.service_account_names)}",
f"bound_service_account_namespaces={','.join(spec.service_account_namespaces)}",
f"policies={spec.policy_name}",
f"ttl={spec.token_ttl}",
f"token_policies={spec.policy_name}",
f"token_ttl={spec.token_ttl}",
],
)
objects = {