Adds the one-session Scaleway IAM grant script, merges the publisher bucket probes into one statement to fit the 10-statement limit, keeps the Playwright cache under the account home during attended login, and records the publisher-gate runs through the num_uses rejection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 49552@bnt-lap001 Assistant-Session: 575802ee-c0c0-42a9-b2a1-9204c9f5b3f4
251 lines
10 KiB
Python
251 lines
10 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]
|