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

@ -2,7 +2,7 @@
# Custodian Brief — ops-mason
**Domain:** infotech
**Last synced:** 2026-10-18 10:31 UTC
**Last synced:** 2026-10-18 10:45 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
@ -24,13 +24,6 @@ Progress: 2/4 done | workplan_id: `e5656747-a974-581a-a3d4-4e6bb7262f4c`
- ! Make an undescribed path visible before it accumulates `9a0cea06`
*(wait: After T03. A scheduled --undescribed report needs an unattended metadata-read credential and a named reader; survey grants are human-minted and expire within an hour.)*
### Check the target's readiness tier before a direct Kubernetes apply
Progress: 5/6 done | workplan_id: `1b900161-51ff-544f-8412-ba7fcab64bb5`
**Open tasks:**
- ! Review on 2026-12-21 `49dd5f9a`
*(wait: Plan-approval exception accepted under D2; closes when policy-nexus ArgoCD onboarding is confirmed.)*
### Materialize SecurityCases from their type, with acceptance as the only gate
Progress: 2/4 done | workplan_id: `eabf2c39-94bd-5a11-8f2e-76d7a95d1583`
@ -47,6 +40,13 @@ Progress: 4/6 done | workplan_id: `483e56d6-6601-5377-9066-66225214c046`
*(wait: After T05 verification; only ops-warden activates the entry.)*
- ► Verify capabilities and record metadata-only evidence `4c205be7`
### Check the target's readiness tier before a direct Kubernetes apply
Progress: 5/6 done | workplan_id: `1b900161-51ff-544f-8412-ba7fcab64bb5`
**Open tasks:**
- ! Review on 2026-12-21 `49dd5f9a`
*(wait: Plan-approval exception accepted under D2; closes when policy-nexus ArgoCD onboarding is confirmed.)*
### Place the KeyCape secrets-engine-openbao client secret in OpenBao
Progress: 4/5 done | workplan_id: `d9be08f5-a0f5-5cd1-8f87-0a6868661d77`

View file

@ -91,7 +91,7 @@ tooling_contacts:
- id: openbao-approle-credential-delivery
shape: "5.3"
module: src/ops_mason/executor.py
symbol: build_approle_kv_lane
symbol: deliver_approle_credentials
tooling: OpenBao
operation: "bao read auth/approle/role/*/role-id; bao write -f auth/approle/role/*/secret-id"
write: true

View file

@ -93,7 +93,7 @@
| task | MASON-WP-0013-T04 | wait | — | workplans/MASON-WP-0013-reuse-surface-private-source-reader.md |
| task | MASON-WP-0014-T01 | done | — | workplans/MASON-WP-0014-mas-tem-relay-smtp.md |
| task | MASON-WP-0014-T02 | done | — | workplans/MASON-WP-0014-mas-tem-relay-smtp.md |
| task | MASON-WP-0014-T03 | wait | — | workplans/MASON-WP-0014-mas-tem-relay-smtp.md |
| task | MASON-WP-0014-T03 | done | — | workplans/MASON-WP-0014-mas-tem-relay-smtp.md |
| task | MASON-WP-0014-T04 | wait | — | workplans/MASON-WP-0014-mas-tem-relay-smtp.md |
| task | MASON-WP-0015-T01 | done | — | workplans/MASON-WP-0015-construct-grant.md |
| task | MASON-WP-0015-T02 | done | — | workplans/MASON-WP-0015-construct-grant.md |

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 = {

View file

@ -18,6 +18,7 @@ from ops_mason.executor import (
build_spec_document,
build_spec_digest,
build_approle_kv_lane,
deliver_approle_credentials,
build_kubernetes_kv_lane,
)
@ -83,14 +84,17 @@ def test_refusal_never_calls_bao(tmp_path) -> None:
run.assert_not_called()
def test_build_writes_policy_approle_and_delivers_credentials(tmp_path) -> None:
plan = _plan(tmp_path)
spec = _spec(tmp_path)
def _fake_bao(stored_policy=None):
"""Fake bao: `policy read` returns what `policy write` stored (or stored_policy)."""
written = {}
def fake_run(cmd, input=None, capture_output=True, text=True, timeout=30):
result = MagicMock(returncode=0, stderr="")
if cmd[1:3] == ["policy", "read"]:
result.stdout = POLICY
if cmd[1:3] == ["policy", "write"]:
written[cmd[3]] = input
result.stdout = ""
elif cmd[1:3] == ["policy", "read"]:
result.stdout = stored_policy if stored_policy is not None else written.get(cmd[3], POLICY)
elif cmd[1:3] == ["read", "-field=role_id"]:
result.stdout = "role-id-value\n"
elif "-field=secret_id" in cmd:
@ -99,13 +103,57 @@ def test_build_writes_policy_approle_and_delivers_credentials(tmp_path) -> None:
result.stdout = ""
return result
with patch("ops_mason.executor.subprocess.run", side_effect=fake_run) as run:
return fake_run
def test_build_writes_policy_and_approle_structure_only(tmp_path) -> None:
plan = _plan(tmp_path)
spec = _spec(tmp_path)
with patch("ops_mason.executor.subprocess.run", side_effect=_fake_bao()) as run:
objects = build_approle_kv_lane(plan, spec)
assert objects["policy_name"] == "workload-kv-read-test-lane"
assert objects["approle_name"] == "test-lane"
assert objects["kv_path"] == "reins/test/openrouter"
assert objects["credential_delivery"].startswith("founder:")
# MASON-IN-0005: the build never becomes the role it built.
bao_cmds = [c.args[0] for c in run.call_args_list]
assert not any("role-id" in " ".join(cmd) or "secret-id" in " ".join(cmd) for cmd in bao_cmds)
assert not spec.delivery_dir.exists()
# policy write call carried the HCL on stdin, not argv -- never in a log line
# -- KV v2 shape: data/ and metadata/ sub-paths, not the bare path
policy_call = next(c for c in run.call_args_list if c.args[0][:3] == ["bao", "policy", "write"])
assert 'path "reins/data/test/openrouter"' in policy_call.kwargs["input"]
assert 'path "reins/metadata/test/openrouter"' in policy_call.kwargs["input"]
assert 'path "reins/test/openrouter"' not in policy_call.kwargs["input"]
# post-build check read the policy back before the role was written
assert ["bao", "policy", "read", "workload-kv-read-test-lane"] in bao_cmds
def test_build_refuses_when_written_policy_differs(tmp_path) -> None:
plan = _plan(tmp_path)
spec = _spec(tmp_path)
with patch("ops_mason.executor.subprocess.run", side_effect=_fake_bao(stored_policy="path \"platform/data/*\" {}\n")) as run:
with pytest.raises(BuildError, match="does not match the approved text"):
build_approle_kv_lane(plan, spec)
bao_cmds = [c.args[0] for c in run.call_args_list]
assert not any(cmd[1:3] == ["write", "auth/approle/role/test-lane"] for cmd in bao_cmds)
assert not spec.audit_log_path.exists()
def test_delivery_writes_private_credentials(tmp_path) -> None:
plan = _plan(tmp_path)
spec = _spec(tmp_path)
with patch("ops_mason.executor.subprocess.run", side_effect=_fake_bao()):
objects = deliver_approle_credentials(plan, spec)
assert objects["delivered_to"] == str(spec.delivery_dir)
role_id_file = spec.delivery_dir / "role_id"
secret_id_file = spec.delivery_dir / "secret_id"
assert role_id_file.read_text() == "role-id-value\n"
@ -113,16 +161,9 @@ def test_build_writes_policy_approle_and_delivers_credentials(tmp_path) -> None:
assert oct(role_id_file.stat().st_mode)[-3:] == "600"
assert oct(secret_id_file.stat().st_mode)[-3:] == "600"
# policy write call carried the HCL on stdin, not argv -- never in a log line
# -- KV v2 shape: data/ and metadata/ sub-paths, not the bare path
policy_call = next(c for c in run.call_args_list if c.args[0][:2] == ["bao", "policy"])
assert 'path "reins/data/test/openrouter"' in policy_call.kwargs["input"]
assert 'path "reins/metadata/test/openrouter"' in policy_call.kwargs["input"]
assert 'path "reins/test/openrouter"' not in policy_call.kwargs["input"]
# audit record landed at the explicit path, metadata only, no role_id/secret_id values
audit_text = spec.audit_log_path.read_text()
assert "policy_name" in audit_text
assert "delivered_to" in audit_text
assert "role-id-value" not in audit_text
assert "secret-id-value" not in audit_text
@ -158,13 +199,8 @@ def test_build_appends_audit_record(tmp_path) -> None:
plan = _plan(tmp_path)
spec = _spec(tmp_path)
def fake_run(cmd, input=None, capture_output=True, text=True, timeout=30):
result = MagicMock(returncode=0, stderr="")
result.stdout = "value\n"
return result
with (
patch("ops_mason.executor.subprocess.run", side_effect=fake_run),
patch("ops_mason.executor.subprocess.run", side_effect=_fake_bao()),
patch("ops_mason.executor.record_build") as record_build_mock,
):
build_approle_kv_lane(plan, spec)
@ -224,8 +260,8 @@ def test_kubernetes_lane_builds_exact_service_account_binding(tmp_path) -> None:
"auth/kubernetes/role/external-secrets-rapp-qonto",
"bound_service_account_names=external-secrets",
"bound_service_account_namespaces=external-secrets",
"policies=workload-kv-read-binky-qonto-api",
"ttl=15m",
"token_policies=workload-kv-read-binky-qonto-api",
"token_ttl=15m",
]
assert objects["kubernetes_role_name"] == "external-secrets-rapp-qonto"
audit.assert_called_once()
@ -365,7 +401,7 @@ def test_credentials_are_private_before_first_bao_call_even_with_open_umask(tmp_
previous = os.umask(0)
try:
with patch("ops_mason.executor.subprocess.run", side_effect=fake_run):
build_approle_kv_lane(plan, spec)
deliver_approle_credentials(plan, spec)
finally:
os.umask(previous)
assert "synthetic-credential" not in spec.audit_log_path.read_text()
@ -397,7 +433,7 @@ def test_unsafe_delivery_refuses_without_mutation_or_overwrite(tmp_path, case):
plan = _plan(tmp_path, spec=spec)
with patch("ops_mason.executor.subprocess.run") as run:
with pytest.raises(BuildRefused):
build_approle_kv_lane(plan, spec)
deliver_approle_credentials(plan, spec)
run.assert_not_called()
assert victim.read_text() == "leave untouched"
if case == "existing":
@ -413,7 +449,7 @@ def test_command_errors_never_echo_credentials(tmp_path, failure):
{"side_effect": subprocess.TimeoutExpired(["bao"], 30, output=marker, stderr=marker)})
with patch("ops_mason.executor.subprocess.run", **kwargs):
with pytest.raises(BuildError) as error:
build_approle_kv_lane(plan, spec)
deliver_approle_credentials(plan, spec)
assert marker not in str(error.value)
assert not spec.audit_log_path.exists()
assert stat.S_IMODE((spec.delivery_dir / "secret_id").stat().st_mode) == 0o600

View file

@ -75,7 +75,7 @@ Run 1 found the comma-string bypass (fixed in 482a08b); run 2 passed all 31 chec
```task
id: MASON-WP-0015-T04
status: todo
status: done
priority: high
state_hub_task_id: "7b7a436f-c585-5fa8-8afc-5bb033f205f8"
```
@ -87,6 +87,12 @@ reads each written policy and compares it with the plan's policy file
(normalising the trailing newline). Run the check in every build; a mismatch
fails the build record.
Done 2026-10-18: `build_approle_kv_lane` now builds structure only, and
`deliver_approle_credentials` handles delivery as a founder or applier step.
`_verify_written_policy` reads each new policy back before the role is
written. `build_kubernetes_kv_lane` now writes `token_policies`/`token_ttl`,
because the legacy `policies` is denied by ops-mason-construct. 260 tests pass.
## Align ops-warden and railiance-platform references
```task