Warden's attended login strips BAO_TOKEN and VAULT_TOKEN from the child, so the 2026-11-03 run minted and proved the key and then exited writer_token_failed. The minted key was deleted. Token-request failures now record a value-free reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 4541@bnt-lap001 Assistant-Session: 2099b3aa-d7d9-49a9-ab4e-fa8b7d0e39e8
384 lines
16 KiB
Python
384 lines
16 KiB
Python
"""MAS publisher gate records boundaries and refuses to keep secret values."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import pwd
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
spec = importlib.util.spec_from_file_location(
|
|
"mas_gate", ROOT / "scripts/mas_evidence_publisher_gate.py"
|
|
)
|
|
gate = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(gate)
|
|
|
|
|
|
def test_venv_symlink_is_not_the_system_interpreter():
|
|
system = "/usr/bin/python3"
|
|
venv = "/home/operator/.local/share/railiance-platform/backup-venv/bin/python"
|
|
assert gate.same_interpreter(system, system)
|
|
assert not gate.same_interpreter(system, venv)
|
|
|
|
|
|
def test_backup_python_stays_on_the_account_home_when_login_replaces_home(monkeypatch):
|
|
monkeypatch.setenv("HOME", "/tmp/warden-session")
|
|
account = pwd.getpwuid(os.getuid()).pw_dir
|
|
assert gate.selected_backup_python() == (
|
|
Path(account) / ".local/share/railiance-platform/backup-venv/bin/python"
|
|
)
|
|
assert "/tmp/warden-session" not in str(gate.selected_backup_python())
|
|
assert "/tmp/mas-bucket-policy-venv" not in str(gate.backup_python(account))
|
|
|
|
|
|
def test_s3_failure_keeps_the_code_and_drops_secret_material():
|
|
class Fake:
|
|
response = {
|
|
"Error": {
|
|
"Code": "MalformedPolicy",
|
|
"Message": "Invalid principal secret=abc SCWABCDEFGHIJKLMNOP signature=zzz",
|
|
},
|
|
"ResponseMetadata": {"HTTPStatusCode": 400},
|
|
}
|
|
|
|
detail = gate.s3_failure(Fake())
|
|
assert detail["error_code"] == "MalformedPolicy"
|
|
assert detail["http_status"] == 400
|
|
assert "Invalid principal" in detail["message"]
|
|
assert "abc" not in detail["message"]
|
|
assert "ABCDEFGHIJKLMNOP" not in detail["message"]
|
|
|
|
|
|
def test_supplier_pair_maps_aliases_and_refuses_partial_or_equal_values():
|
|
mapped = gate.supplier_pair({"ACCESS_KEY": "access-one", "SECRET_KEY": "secret-two"})
|
|
assert list(mapped) == ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
|
|
assert mapped["AWS_ACCESS_KEY_ID"] != mapped["AWS_SECRET_ACCESS_KEY"]
|
|
labeled = gate.supplier_pair({"Access Key": "access-one", "Secret Key": "secret-two"})
|
|
assert labeled["AWS_ACCESS_KEY_ID"] == "access-one"
|
|
blob = gate.supplier_pair(
|
|
{"credentials": '[default]\naccess_key = access-one\nsecret_key = secret-two\n'}
|
|
)
|
|
assert blob["AWS_SECRET_ACCESS_KEY"] == "secret-two"
|
|
with pytest.raises(gate.GateError) as partial:
|
|
gate.supplier_pair({"ACCESS_KEY": "access-one"}, "platform/suppliers/scaleway/example")
|
|
assert partial.value.detail["keys"] == ["ACCESS_KEY"]
|
|
assert "access-one" not in json.dumps(partial.value.detail)
|
|
with pytest.raises(gate.GateError):
|
|
gate.supplier_pair({"ACCESS_KEY": "same-value", "SECRET_KEY": "same-value"})
|
|
|
|
|
|
def test_policy_observation_requires_the_prefix_statements_without_delete():
|
|
document = {
|
|
"Statement": [
|
|
{
|
|
"Sid": "MasEvidenceBackupObjects",
|
|
"Action": ["s3:GetObject", "s3:PutObject"],
|
|
},
|
|
{
|
|
"Sid": "PublisherBucketProbe",
|
|
"Principal": {"SCW": ["application_id:" + gate.APPLICATION_ID]},
|
|
"Action": list(gate.PROBE_ACTIONS),
|
|
},
|
|
{"Sid": "MasEvidenceBackupList", "Action": ["s3:ListBucket"]},
|
|
{"Sid": "Other", "Action": ["s3:DeleteObject"]},
|
|
]
|
|
}
|
|
observed = gate.policy_observation(document)
|
|
assert observed["mas_sids_present"] == list(gate.MAS_SIDS)
|
|
assert observed["object_delete_absent"] is True
|
|
assert observed["probe_includes_publisher"] is True
|
|
assert observed["principal"] == gate.APPLICATION_ID
|
|
document["Statement"][0]["Action"].append("s3:DeleteObject")
|
|
assert gate.policy_observation(document)["object_delete_absent"] is False
|
|
|
|
|
|
def test_merged_policy_replaces_separate_probes_and_stays_within_ten():
|
|
desired = json.loads(
|
|
(ROOT / "docs/object-storage/railiance-platform-pg-backup-policy.json").read_text()
|
|
)
|
|
live = {
|
|
"Version": "2023-04-17",
|
|
"Id": "resource-control-scoped-backup",
|
|
"Statement": [
|
|
item for item in desired["Statement"]
|
|
if not str(item.get("Sid", "")).startswith("Mas")
|
|
and item.get("Sid") != "PublisherBucketProbe"
|
|
] + [
|
|
{
|
|
"Sid": "CoulombSocialBackupProbe",
|
|
"Principal": {"SCW": "application_id:681d00bf-b965-4323-b657-97bfde423511"},
|
|
"Action": list(gate.PROBE_ACTIONS),
|
|
},
|
|
{
|
|
"Sid": "EmailConnectBackupProbe",
|
|
"Principal": {"SCW": "application_id:0eb0f958-77a4-4c70-9aa7-14ba46411415"},
|
|
"Action": list(gate.PROBE_ACTIONS),
|
|
},
|
|
],
|
|
}
|
|
merged = gate.merged_policy(live, desired)
|
|
sids = [item.get("Sid") for item in merged["Statement"]]
|
|
assert len(sids) == 10
|
|
assert "CoulombSocialBackupProbe" not in sids
|
|
assert "EmailConnectBackupProbe" not in sids
|
|
assert "PublisherBucketProbe" in sids
|
|
assert sids.count("MasEvidenceBackupList") == 1
|
|
assert "BackupObjectsUnderPrefix" in sids
|
|
observed = gate.policy_observation(merged)
|
|
assert observed["probe_includes_publisher"] is True
|
|
assert observed["object_delete_absent"] is True
|
|
|
|
|
|
def test_denial_observation_passes_only_when_all_three_boundaries_deny():
|
|
results = {
|
|
"sibling_prefix": {"http_status": 403, "denied": True},
|
|
"retained_delete": {"http_status": 403, "denied": True},
|
|
"administration": {"http_status": 403, "denied": True},
|
|
"own_prefix": {"http_status": 200, "denied": False},
|
|
}
|
|
passed = gate.denial_observation(results)
|
|
assert passed["passed"] is True
|
|
assert passed["application_id"] == "25a4cbca-220e-40c0-9af5-6c7713000174"
|
|
results["retained_delete"]["denied"] = False
|
|
assert gate.denial_observation(results)["passed"] is False
|
|
results["retained_delete"]["denied"] = True
|
|
results["own_prefix"] = {"http_status": 403, "denied": True, "error_code": "AccessDenied"}
|
|
rejected = gate.denial_observation(results)
|
|
assert rejected["passed"] is False
|
|
assert rejected["own_prefix_allowed"] is False
|
|
assert rejected["error_code"]["own_prefix"] == "AccessDenied"
|
|
rendered = json.dumps(passed)
|
|
assert "access-one" not in rendered
|
|
assert "secret-two" not in rendered
|
|
|
|
|
|
def test_project_scope_appends_the_bootstrap_project_once():
|
|
pair = {
|
|
"AWS_ACCESS_KEY_ID": "SCWEXAMPLEKEY",
|
|
"AWS_SECRET_ACCESS_KEY": "secret-value-not-written",
|
|
}
|
|
project = "11111111-2222-3333-4444-555555555555"
|
|
scoped, applied = gate.scoped_supplier_pair(pair, project)
|
|
assert applied is True
|
|
assert scoped["AWS_ACCESS_KEY_ID"] == f"SCWEXAMPLEKEY@{project}"
|
|
assert scoped["AWS_SECRET_ACCESS_KEY"] == pair["AWS_SECRET_ACCESS_KEY"]
|
|
again, applied_again = gate.scoped_supplier_pair(scoped, project)
|
|
assert applied_again is False
|
|
assert again["AWS_ACCESS_KEY_ID"] == scoped["AWS_ACCESS_KEY_ID"]
|
|
untouched, unused = gate.scoped_supplier_pair(pair, "")
|
|
assert unused is False
|
|
assert untouched["AWS_ACCESS_KEY_ID"] == "SCWEXAMPLEKEY"
|
|
|
|
|
|
def test_raw_create_response_keeps_num_uses_out_of_the_public_detail():
|
|
auth = gate.writer_auth({
|
|
"auth": {
|
|
"client_token": "s.secret-token-value",
|
|
"accessor": "accessor-secret",
|
|
"token_policies": ["backup-pair-writer"],
|
|
"policies": ["backup-pair-writer"],
|
|
"lease_duration": 300,
|
|
"num_uses": 2,
|
|
}
|
|
})
|
|
identity = gate._writer_identity(auth)
|
|
assert identity["num_uses"] == 2
|
|
rendered = json.dumps(identity)
|
|
assert "s.secret-token-value" not in rendered
|
|
with pytest.raises(gate.GateError):
|
|
gate.writer_auth({"auth": {"num_uses": 2}})
|
|
|
|
|
|
def test_writer_identity_accepts_token_policies_and_rejects_default():
|
|
auth = {
|
|
"client_token": "s.secret-token-value",
|
|
"accessor": "accessor-secret",
|
|
"token_policies": ["backup-pair-writer"],
|
|
"lease_duration": "300",
|
|
"num_uses": "2",
|
|
}
|
|
identity = gate._writer_identity(auth)
|
|
assert identity == {
|
|
"policies": ["backup-pair-writer"],
|
|
"identity_policies": [],
|
|
"ttl": 300,
|
|
"num_uses": 2,
|
|
}
|
|
auth["policies"] = ["default", "backup-pair-writer"]
|
|
with pytest.raises(gate.GateError) as caught:
|
|
gate._writer_identity(auth)
|
|
assert caught.value.code == "writer_identity_rejected"
|
|
rendered = json.dumps(caught.value.detail)
|
|
assert "s.secret-token-value" not in rendered
|
|
assert "accessor-secret" not in rendered
|
|
assert "client_token" not in caught.value.detail["auth_fields"]
|
|
assert "accessor" not in caught.value.detail["auth_fields"]
|
|
assert caught.value.detail["legacy_policies"] == ["default", "backup-pair-writer"]
|
|
|
|
|
|
def test_put_parser_keeps_version_and_drops_any_nested_value():
|
|
parsed = gate.version_from_put(
|
|
{"request_id": "req-1", "data": {"version": 1, "data": {"AWS_SECRET_ACCESS_KEY": "not-a-live-value"}}}
|
|
)
|
|
assert parsed == {"version": 1, "request_id": "req-1"}
|
|
assert "not-a-live-value" not in json.dumps(parsed)
|
|
with pytest.raises(gate.GateError):
|
|
gate.version_from_put({"data": {"version": 2}})
|
|
|
|
|
|
def test_logical_path_strips_only_the_openbao_storage_marker():
|
|
assert gate.logical_path("platform/metadata/workloads/mas/backup-primary") == (
|
|
"platform/workloads/mas/backup-primary"
|
|
)
|
|
assert gate.logical_path("platform/data/workloads/mas/backup-primary") == (
|
|
"platform/workloads/mas/backup-primary"
|
|
)
|
|
|
|
|
|
def test_pair_custody_preview_includes_mas_and_the_writer_policy_names_it():
|
|
spec_custody = importlib.util.spec_from_file_location(
|
|
"pair_custody_for_gate", ROOT / "scripts/application_backup_pair_custody.py"
|
|
)
|
|
custody = importlib.util.module_from_spec(spec_custody)
|
|
spec_custody.loader.exec_module(custody)
|
|
preview = custody.preview("mas")
|
|
assert preview["kv_path"] == "platform/workloads/mas/backup-primary"
|
|
assert preview["openbao_write"] is False
|
|
text = (ROOT / "openbao/policies/backup-pair-writer.hcl").read_text(encoding="utf-8")
|
|
assert 'path "platform/data/workloads/mas/backup-primary"' in text
|
|
assert "read" not in text.split('path "platform/data/workloads/mas/backup-primary"')[1].split("path ")[0]
|
|
|
|
|
|
class _FakeConsole:
|
|
"""Scaleway IAM stand-in. Records calls; never returns the supplier secret."""
|
|
|
|
def __init__(self, keys=None, issued=None, name="mas-evidence-backup-primary"):
|
|
self.name = name
|
|
self.keys = keys if keys is not None else [
|
|
{"application_id": gate.APPLICATION_ID, "default_project_id": gate.PROJECT_ID,
|
|
"description": "supplier"}
|
|
]
|
|
self.issued = issued if issued is not None else {
|
|
"application_id": gate.APPLICATION_ID, "access_key": "SCWMINTED",
|
|
"secret_key": "minted-secret", "created_at": "2026-11-02T20:00:00Z",
|
|
}
|
|
self.calls = []
|
|
|
|
def __call__(self, method, path, data=None):
|
|
self.calls.append((method, path))
|
|
if method == "GET" and path.startswith("/iam/v1alpha1/applications/"):
|
|
return {"name": self.name, "organization_id": "org-1"}
|
|
if method == "GET" and path.startswith("/iam/v1alpha1/api-keys?"):
|
|
assert "organization_id=org-1" in path and "application_id=" + gate.APPLICATION_ID in path
|
|
return {"api_keys": list(self.keys), "total_count": len(self.keys)}
|
|
if method == "POST":
|
|
assert data["application_id"] == gate.APPLICATION_ID
|
|
assert data["default_project_id"] == gate.PROJECT_ID
|
|
self.keys.append({"application_id": gate.APPLICATION_ID,
|
|
"default_project_id": gate.PROJECT_ID,
|
|
"description": data["description"]})
|
|
return dict(self.issued)
|
|
if method == "DELETE":
|
|
return {}
|
|
raise AssertionError((method, path))
|
|
|
|
|
|
def test_mint_proves_then_writes_the_minted_key_not_the_supplier():
|
|
api = _FakeConsole()
|
|
seen = {}
|
|
|
|
def prove(pair, project):
|
|
seen["prove"] = (pair["AWS_ACCESS_KEY_ID"], project)
|
|
|
|
def write(pair):
|
|
seen["write"] = dict(pair)
|
|
return {"current_version": 1}
|
|
|
|
receipt = gate.mint_workload_pair(api, prove, write, sleep=lambda _: None)
|
|
assert seen["prove"] == ("SCWMINTED", gate.PROJECT_ID)
|
|
assert seen["write"] == {"AWS_ACCESS_KEY_ID": "SCWMINTED", "AWS_SECRET_ACCESS_KEY": "minted-secret"}
|
|
assert receipt["status"] == "passed" and receipt["keys_after"]["count"] == 2
|
|
assert "minted-secret" not in json.dumps(receipt) and "SCWMINTED" not in json.dumps(receipt)
|
|
assert not any(method == "DELETE" for method, _ in api.calls)
|
|
|
|
|
|
def test_mint_retries_propagation_then_deletes_the_key_when_proof_never_passes():
|
|
api = _FakeConsole()
|
|
sleeps = []
|
|
|
|
def prove(pair, project):
|
|
raise gate.GateError("publisher_denials_failed")
|
|
|
|
with pytest.raises(gate.GateError) as caught:
|
|
gate.mint_workload_pair(api, prove, lambda pair: None, sleep=sleeps.append)
|
|
assert caught.value.code == "publisher_denials_failed"
|
|
assert caught.value.detail == {"minted_key_deleted": True}
|
|
assert len(sleeps) == gate.PROOF_ATTEMPTS - 1
|
|
assert ("DELETE", "/iam/v1alpha1/api-keys/SCWMINTED") in api.calls
|
|
|
|
|
|
def test_mint_deletes_the_key_when_the_pair_write_fails():
|
|
api = _FakeConsole()
|
|
|
|
def write(pair):
|
|
raise gate.GateError("existing_custody_requires_rotation_review")
|
|
|
|
with pytest.raises(gate.GateError) as caught:
|
|
gate.mint_workload_pair(api, lambda pair, project: None, write)
|
|
assert caught.value.code == "existing_custody_requires_rotation_review"
|
|
assert caught.value.detail["minted_key_deleted"] is True
|
|
|
|
|
|
@pytest.mark.parametrize("console", [
|
|
_FakeConsole(name="email-connect-backup-primary"),
|
|
_FakeConsole(keys=[]),
|
|
_FakeConsole(keys=[{"application_id": gate.APPLICATION_ID, "default_project_id": gate.PROJECT_ID}] * 2),
|
|
_FakeConsole(keys=[{"application_id": gate.APPLICATION_ID, "default_project_id": "other"}]),
|
|
])
|
|
def test_mint_refuses_before_issuing_when_application_or_key_state_is_unexpected(console):
|
|
with pytest.raises(gate.GateError):
|
|
gate.mint_workload_pair(console, lambda *a: None, lambda pair: None)
|
|
assert not any(method in ("POST", "DELETE") for method, _ in console.calls)
|
|
|
|
|
|
def test_incomplete_issue_is_deleted_and_never_written():
|
|
api = _FakeConsole(issued={"application_id": gate.APPLICATION_ID, "access_key": "SCWMINTED"})
|
|
written = []
|
|
with pytest.raises(gate.GateError) as caught:
|
|
gate.mint_workload_pair(api, lambda *a: None, written.append)
|
|
assert caught.value.code == "issued_key_incomplete"
|
|
assert caught.value.detail["minted_key_deleted"] is True and written == []
|
|
|
|
|
|
def test_main_refuses_without_the_mint_flag(tmp_path, monkeypatch):
|
|
import sys
|
|
|
|
monkeypatch.setattr(gate, "RECEIPT_DIR", tmp_path)
|
|
monkeypatch.setattr(sys, "stdout", sys.stdout)
|
|
monkeypatch.setattr(sys, "stderr", sys.stderr)
|
|
assert gate.main([]) == 71
|
|
assert json.loads((tmp_path / "mas-evidence-publisher-gate.json").read_text())["code"] == "usage_refused"
|
|
|
|
|
|
def test_parent_token_prefers_env_then_the_private_helper(tmp_path):
|
|
helper = tmp_path / ".vault-token"
|
|
helper.write_text("s.helpertoken\n")
|
|
helper.chmod(0o600)
|
|
assert gate.parent_token({"BAO_TOKEN": "s.env", "HOME": str(tmp_path)}) == ("s.env", "BAO_TOKEN")
|
|
assert gate.parent_token({"HOME": str(tmp_path)}) == ("s.helpertoken", "token_helper")
|
|
helper.chmod(0o644)
|
|
assert gate.parent_token({"HOME": str(tmp_path)}) == ("", "helper_rejected")
|
|
assert gate.parent_token({"HOME": str(tmp_path / "missing")}) == ("", "absent")
|
|
assert gate.parent_token({}) == ("", "absent")
|
|
|
|
|
|
def test_writer_auth_failure_names_the_reason_without_values(monkeypatch, tmp_path):
|
|
monkeypatch.delenv("BAO_TOKEN", raising=False)
|
|
monkeypatch.delenv("VAULT_TOKEN", raising=False)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("BAO_ADDR", gate.BAO_ADDR)
|
|
with pytest.raises(gate.GateError) as caught:
|
|
gate._request_writer_auth()
|
|
assert caught.value.detail == {"reason": "parent_token_absent"}
|