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:
parent
d19e6b1633
commit
91c4e7b527
6 changed files with 144 additions and 62 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue