diff --git a/.custodian-brief.md b/.custodian-brief.md index cb58108..1e3ed36 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -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` diff --git a/INTENT.md b/INTENT.md index 503744c..b66ff1b 100644 --- a/INTENT.md +++ b/INTENT.md @@ -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 diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ef01d46..08540a1 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/src/ops_mason/executor.py b/src/ops_mason/executor.py index b7ffd51..e81868e 100644 --- a/src/ops_mason/executor.py +++ b/src/ops_mason/executor.py @@ -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 = { diff --git a/tests/test_executor.py b/tests/test_executor.py index 92a3a91..67c5ce5 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -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 diff --git a/workplans/MASON-WP-0015-construct-grant.md b/workplans/MASON-WP-0015-construct-grant.md index 6b1aab7..a0ad419 100644 --- a/workplans/MASON-WP-0015-construct-grant.md +++ b/workplans/MASON-WP-0015-construct-grant.md @@ -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