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
703 lines
24 KiB
Python
Executable file
703 lines
24 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Apply the MAS publisher boundary and write its one CAS=0 pair.
|
|
|
|
Stdout stays empty. Receipts record statement ids, HTTP statuses, versions,
|
|
and field names. Values are never written to a receipt or to stderr.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import pwd
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ROOT = Path("/home/worsch/railiance-platform")
|
|
DESIRED = ROOT / "docs/object-storage/railiance-platform-pg-backup-policy.json"
|
|
POLICY_FILE = ROOT / "openbao/policies/backup-pair-writer.hcl"
|
|
RECEIPT_DIR = Path("/home/worsch/.local/state/railiance/receipts")
|
|
BUCKET = "railiance-platform-pg-backup"
|
|
ENDPOINT = "https://s3.nl-ams.scw.cloud"
|
|
APPLICATION_ID = "25a4cbca-220e-40c0-9af5-6c7713000174"
|
|
PREFIX = "platform-pg/application-archives/mas/"
|
|
SIBLING = "platform-pg/application-archives/email-connect/"
|
|
MAS_SIDS = (
|
|
"MasEvidenceBackupList",
|
|
"MasEvidenceBackupObjects",
|
|
)
|
|
PROBE_SID = "PublisherBucketProbe"
|
|
PROBE_ACTIONS = (
|
|
"s3:GetBucketLocation",
|
|
"s3:GetBucketVersioning",
|
|
"s3:GetLifecycleConfiguration",
|
|
)
|
|
REPLACED_PROBES = (
|
|
"CoulombSocialBackupProbe",
|
|
"EmailConnectBackupProbe",
|
|
"MasEvidenceBackupProbe",
|
|
)
|
|
STATEMENT_LIMIT = 10
|
|
SUPPLIER = "platform/suppliers/scaleway/mas-evidence-backup-primary"
|
|
WORKLOAD = "platform/workloads/mas/backup-primary"
|
|
PREFERRED_PROJECT = "reef-storage"
|
|
BAO_ADDR = "http://127.0.0.1:18200"
|
|
_PROJECT_ID = re.compile(
|
|
r"\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\Z"
|
|
)
|
|
_SECRET_AUTH_FIELDS = frozenset({"client_token", "accessor"})
|
|
|
|
|
|
class GateError(RuntimeError):
|
|
def __init__(self, code, detail=None):
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.detail = detail
|
|
|
|
|
|
_ROLE = {
|
|
"awsaccesskeyid": "access",
|
|
"accesskey": "access",
|
|
"accesskeyid": "access",
|
|
"scwaccesskey": "access",
|
|
"awssecretaccesskey": "secret",
|
|
"secretkey": "secret",
|
|
"secretaccesskey": "secret",
|
|
"scwsecretkey": "secret",
|
|
}
|
|
|
|
|
|
def _role(name):
|
|
return _ROLE.get("".join(char.lower() for char in name if char.isalnum()))
|
|
|
|
|
|
def _mapping_text(value):
|
|
"""Read one credentials blob. Ignore anything that is not a mapping."""
|
|
text = value.strip()
|
|
if text.startswith("{") and text.endswith("}"):
|
|
try:
|
|
parsed = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return parsed if isinstance(parsed, dict) else None
|
|
rows = {}
|
|
for line in text.splitlines():
|
|
line = line.split("#", 1)[0].strip()
|
|
if not line or line.startswith("[") or "=" not in line:
|
|
continue
|
|
key, raw = line.split("=", 1)
|
|
rows[key.strip()] = raw.strip().strip('"').strip("'")
|
|
return rows or None
|
|
|
|
|
|
def _shape_detail(path, data, reason):
|
|
detail = {"path": path, "reason": reason}
|
|
if isinstance(data, dict):
|
|
detail["keys"] = sorted(str(key) for key in data)
|
|
else:
|
|
detail["shape"] = type(data).__name__
|
|
return detail
|
|
|
|
|
|
def supplier_pair(data, path="", unwrap=True):
|
|
"""Return the two workload fields. Refuse a partial or identical pair."""
|
|
if isinstance(data, str):
|
|
parsed = _mapping_text(data)
|
|
if isinstance(parsed, dict):
|
|
return supplier_pair(parsed, path, unwrap=False)
|
|
raise GateError("supplier_shape_rejected", _shape_detail(path, data, "not_a_mapping"))
|
|
if not isinstance(data, dict):
|
|
raise GateError("supplier_shape_rejected", _shape_detail(path, data, "not_a_mapping"))
|
|
nested = [value for value in data.values() if isinstance(value, dict)]
|
|
strings = {key: value for key, value in data.items() if isinstance(value, str)}
|
|
access = []
|
|
secret = []
|
|
for name, value in strings.items():
|
|
role = _role(str(name))
|
|
if role == "access":
|
|
access.append(value.strip())
|
|
elif role == "secret":
|
|
secret.append(value.strip())
|
|
if not access and not secret and len(nested) == 1 and not strings:
|
|
return supplier_pair(nested[0], path, unwrap=False)
|
|
if unwrap and not access and not secret and len(strings) == 1 and not nested:
|
|
parsed = _mapping_text(next(iter(strings.values())))
|
|
if isinstance(parsed, dict):
|
|
return supplier_pair(parsed, path, unwrap=False)
|
|
if len(access) != 1 or len(secret) != 1 or not access or not secret or not access[0] or not secret[0] or access[0] == secret[0]:
|
|
reason = "empty_or_equal" if access and secret else "pair_not_found"
|
|
raise GateError("supplier_shape_rejected", _shape_detail(path, data, reason))
|
|
return {"AWS_ACCESS_KEY_ID": access[0], "AWS_SECRET_ACCESS_KEY": secret[0]}
|
|
|
|
|
|
def mas_statements(document):
|
|
found = []
|
|
for item in document.get("Statement", []):
|
|
if item.get("Sid") in MAS_SIDS:
|
|
found.append(item)
|
|
return found
|
|
|
|
|
|
def _actions(statement):
|
|
value = statement.get("Action") or []
|
|
if isinstance(value, str):
|
|
return [value]
|
|
return list(value)
|
|
|
|
|
|
def _principals(statement):
|
|
principal = statement.get("Principal")
|
|
if not isinstance(principal, dict):
|
|
return []
|
|
value = principal.get("SCW")
|
|
if isinstance(value, str):
|
|
return [value]
|
|
if isinstance(value, list):
|
|
return [item for item in value if isinstance(item, str)]
|
|
return []
|
|
|
|
|
|
def policy_observation(document):
|
|
"""Non-secret view of the MAS statements and whether object delete is absent."""
|
|
rows = mas_statements(document)
|
|
actions = []
|
|
for item in rows:
|
|
actions.extend(_actions(item))
|
|
probe = next(
|
|
(item for item in document.get("Statement", []) if item.get("Sid") == PROBE_SID),
|
|
None,
|
|
)
|
|
probe_actions = _actions(probe) if probe else []
|
|
return {
|
|
"sids": [item.get("Sid") for item in rows],
|
|
"mas_sids_present": [sid for sid in MAS_SIDS if sid in {item.get("Sid") for item in rows}],
|
|
"object_delete_absent": not any(
|
|
action in {"s3:DeleteObject", "s3:DeleteObjectVersion"} for action in actions
|
|
),
|
|
"principal": APPLICATION_ID,
|
|
"statement_count": len(document.get("Statement", [])),
|
|
"probe_includes_publisher": bool(
|
|
probe
|
|
and f"application_id:{APPLICATION_ID}" in _principals(probe)
|
|
and all(action in probe_actions for action in PROBE_ACTIONS)
|
|
),
|
|
}
|
|
|
|
|
|
def merged_policy(live, desired):
|
|
"""Keep live statements and fit the publisher probe plus the MAS prefix into 10."""
|
|
desired_items = list(desired.get("Statement", []))
|
|
desired_by_sid = {item.get("Sid"): item for item in desired_items}
|
|
statements = []
|
|
seen = set()
|
|
for item in live.get("Statement", []):
|
|
sid = item.get("Sid")
|
|
if sid in REPLACED_PROBES:
|
|
continue
|
|
statements.append(desired_by_sid.get(sid, item))
|
|
seen.add(sid)
|
|
for item in desired_items:
|
|
sid = item.get("Sid")
|
|
if sid not in seen:
|
|
statements.append(item)
|
|
seen.add(sid)
|
|
sids = [item.get("Sid") for item in statements]
|
|
if len(statements) > STATEMENT_LIMIT:
|
|
raise GateError(
|
|
"policy_statement_limit",
|
|
{"count": len(statements), "limit": STATEMENT_LIMIT, "sids": sids},
|
|
)
|
|
return {
|
|
"Version": live.get("Version") or desired.get("Version"),
|
|
"Id": live.get("Id") or desired.get("Id"),
|
|
"Statement": statements,
|
|
}
|
|
|
|
|
|
def denial_observation(results):
|
|
required = ("sibling_prefix", "retained_delete", "administration")
|
|
names = (*required, "own_prefix")
|
|
denied = {name: bool(results.get(name, {}).get("denied")) for name in required}
|
|
own = results.get("own_prefix") or {}
|
|
own_allowed = own.get("http_status") == 200 and own.get("denied") is False
|
|
return {
|
|
"application_id": APPLICATION_ID,
|
|
"denied": denied,
|
|
"http_status": {name: results.get(name, {}).get("http_status") for name in names},
|
|
"error_code": {name: results.get(name, {}).get("error_code") for name in names},
|
|
"own_prefix_allowed": own_allowed,
|
|
"passed": all(denied.values()) and own_allowed,
|
|
}
|
|
|
|
|
|
def version_from_put(payload):
|
|
data = payload.get("data") or {}
|
|
version = data.get("version")
|
|
if version != 1:
|
|
raise GateError("unexpected_version")
|
|
return {"version": 1, "request_id": payload.get("request_id")}
|
|
|
|
|
|
def logical_path(path):
|
|
for marker in ("/metadata/", "/data/"):
|
|
if path.startswith("platform" + marker):
|
|
return "platform/" + path.split(marker, 1)[1]
|
|
return path
|
|
|
|
|
|
def _write_json(path, value):
|
|
path.parent.mkdir(mode=0o700, exist_ok=True)
|
|
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
path.chmod(0o600)
|
|
|
|
|
|
def _bao(args, token=None):
|
|
env = os.environ.copy()
|
|
env.setdefault("BAO_ADDR", "http://127.0.0.1:18200")
|
|
env.setdefault("VAULT_ADDR", env["BAO_ADDR"])
|
|
if token is None:
|
|
pass
|
|
else:
|
|
env["BAO_TOKEN"] = token
|
|
env["VAULT_TOKEN"] = token
|
|
return subprocess.run(["bao", *args], capture_output=True, env=env)
|
|
|
|
|
|
def _kv_data(path):
|
|
proc = _bao(["kv", "get", "-format=json", path])
|
|
if proc.returncode:
|
|
raise GateError("kv_read_failed")
|
|
body = json.loads(proc.stdout)
|
|
data = ((body.get("data") or {}).get("data")) or {}
|
|
if not isinstance(data, dict):
|
|
raise GateError("kv_read_failed")
|
|
return data
|
|
|
|
|
|
def scoped_supplier_pair(pair, project_id):
|
|
"""Scope only the proof client. The stored workload pair stays the raw key."""
|
|
key = pair["AWS_ACCESS_KEY_ID"]
|
|
project = project_id.strip() if isinstance(project_id, str) else ""
|
|
applied = bool(project and "@" not in key and _PROJECT_ID.fullmatch(project))
|
|
if not applied:
|
|
return dict(pair), False
|
|
scoped = dict(pair)
|
|
scoped["AWS_ACCESS_KEY_ID"] = f"{key}@{project}"
|
|
return scoped, True
|
|
|
|
|
|
def _client(pair):
|
|
"""Avoid the boto3 1.36+ default checksum on PutBucketPolicy.
|
|
|
|
Scaleway accepts the body from older clients. The default checksum makes
|
|
the same document come back as MalformedPolicy.
|
|
"""
|
|
import boto3
|
|
from botocore.config import Config
|
|
|
|
return boto3.client(
|
|
"s3",
|
|
region_name="nl-ams",
|
|
endpoint_url=ENDPOINT,
|
|
aws_access_key_id=pair["AWS_ACCESS_KEY_ID"],
|
|
aws_secret_access_key=pair["AWS_SECRET_ACCESS_KEY"],
|
|
config=Config(
|
|
signature_version="s3v4",
|
|
request_checksum_calculation="when_required",
|
|
response_checksum_validation="when_required",
|
|
),
|
|
)
|
|
|
|
|
|
def public_error(message):
|
|
import re
|
|
|
|
text = str(message)
|
|
text = re.sub(
|
|
r"(?i)(secret|token|password|signature)([^\s,;]{0,12}\s*[:=]\s*)\S+",
|
|
r"\1\2<redacted>",
|
|
text,
|
|
)
|
|
text = re.sub(r"SCW[A-Z0-9]{8,}", "SCW<redacted>", text)
|
|
text = re.sub(r"\b[A-Za-z0-9+/]{32,}={0,2}\b", "<redacted>", text)
|
|
return text[:300]
|
|
|
|
|
|
def s3_failure(exc):
|
|
response = exc.response if isinstance(getattr(exc, "response", None), dict) else {}
|
|
error = response.get("Error") if isinstance(response.get("Error"), dict) else {}
|
|
meta = response.get("ResponseMetadata") if isinstance(response.get("ResponseMetadata"), dict) else {}
|
|
detail = {
|
|
"error_code": error.get("Code"),
|
|
"http_status": meta.get("HTTPStatusCode"),
|
|
}
|
|
if isinstance(error.get("Message"), str) and error.get("Message"):
|
|
detail["message"] = public_error(error["Message"])
|
|
return detail
|
|
|
|
|
|
def _status(call):
|
|
from botocore.exceptions import ClientError
|
|
|
|
try:
|
|
result = call()
|
|
body = result.get("Body") if isinstance(result, dict) else None
|
|
if body is not None:
|
|
body.close()
|
|
code = result.get("ResponseMetadata", {}).get("HTTPStatusCode", 200)
|
|
return {"http_status": code, "denied": False, "error_code": None}
|
|
except ClientError as exc:
|
|
failure = s3_failure(exc)
|
|
code = failure["http_status"]
|
|
return {"http_status": code, "denied": code == 403, "error_code": failure["error_code"]}
|
|
|
|
|
|
def backup_python(account_home):
|
|
"""Pinned backup runtime on the account home.
|
|
|
|
Attended login replaces the process home with an empty session. A venv
|
|
under that session, or under /tmp, is not there on the next login.
|
|
"""
|
|
return (
|
|
Path(account_home)
|
|
/ ".local/share/railiance-platform/backup-venv/bin/python"
|
|
)
|
|
|
|
|
|
def selected_backup_python():
|
|
return backup_python(pwd.getpwuid(os.getuid()).pw_dir)
|
|
|
|
|
|
def same_interpreter(current, candidate):
|
|
"""Compare the invoked path. The venv binary is a symlink to system Python."""
|
|
return Path(current) == Path(candidate)
|
|
|
|
|
|
def _ensure_boto():
|
|
try:
|
|
import boto3 # noqa: F401
|
|
return
|
|
except ImportError:
|
|
pass
|
|
candidate = selected_backup_python()
|
|
if (
|
|
candidate.is_file()
|
|
and os.access(candidate, os.X_OK)
|
|
and not same_interpreter(sys.executable, candidate)
|
|
):
|
|
os.execv(candidate, [str(candidate), *sys.argv])
|
|
raise GateError("boto3_required")
|
|
|
|
|
|
def apply_live_policy(reader_pair, writer_pair):
|
|
from botocore.exceptions import ClientError
|
|
|
|
desired = json.loads(DESIRED.read_text(encoding="utf-8"))
|
|
reader = _client(reader_pair)
|
|
try:
|
|
live_policy = reader.get_bucket_policy(Bucket=BUCKET)["Policy"]
|
|
except ClientError as exc:
|
|
raise GateError("policy_read_failed", s3_failure(exc)) from None
|
|
try:
|
|
live = json.loads(live_policy)
|
|
except (TypeError, json.JSONDecodeError):
|
|
raise GateError("policy_read_failed", {"reason": "response_shape"}) from None
|
|
policy = merged_policy(live, desired)
|
|
statements = policy["Statement"]
|
|
receipt = {"before": [item.get("Sid") for item in live.get("Statement", [])]}
|
|
if receipt["before"] == [item.get("Sid") for item in statements]:
|
|
receipt["put"] = "unchanged"
|
|
else:
|
|
try:
|
|
_client(writer_pair).put_bucket_policy(Bucket=BUCKET, Policy=json.dumps(policy))
|
|
except ClientError as exc:
|
|
raise GateError("policy_write_failed", s3_failure(exc)) from None
|
|
receipt["put"] = "ok"
|
|
try:
|
|
after_policy = reader.get_bucket_policy(Bucket=BUCKET)["Policy"]
|
|
except ClientError as exc:
|
|
raise GateError("policy_read_failed", s3_failure(exc)) from None
|
|
try:
|
|
after = json.loads(after_policy)
|
|
except (TypeError, json.JSONDecodeError):
|
|
raise GateError("policy_read_failed", {"reason": "response_shape"}) from None
|
|
receipt["after"] = [item.get("Sid") for item in after.get("Statement", [])]
|
|
receipt["mas"] = policy_observation(after)
|
|
_write_json(RECEIPT_DIR / "backup-bucket-policy-apply.json", receipt)
|
|
if (
|
|
not receipt["mas"]["object_delete_absent"]
|
|
or receipt["mas"]["mas_sids_present"] != list(MAS_SIDS)
|
|
or not receipt["mas"]["probe_includes_publisher"]
|
|
or receipt["mas"]["statement_count"] > STATEMENT_LIMIT
|
|
):
|
|
raise GateError("mas_policy_not_installed")
|
|
return receipt
|
|
|
|
|
|
def prove_denials(pair, project_id=""):
|
|
scoped, applied = scoped_supplier_pair(pair, project_id)
|
|
client = _client(scoped)
|
|
results = {
|
|
"sibling_prefix": _status(
|
|
lambda: client.list_objects_v2(Bucket=BUCKET, Prefix=SIBLING, MaxKeys=1)
|
|
),
|
|
"retained_delete": _status(
|
|
lambda: client.delete_object(Bucket=BUCKET, Key=PREFIX + "retained-delete-probe")
|
|
),
|
|
"administration": _status(lambda: client.get_bucket_policy(Bucket=BUCKET)),
|
|
"own_prefix": _status(
|
|
lambda: client.list_objects_v2(Bucket=BUCKET, Prefix=PREFIX, MaxKeys=1)
|
|
),
|
|
}
|
|
observation = denial_observation(results)
|
|
observation["preferred_project"] = PREFERRED_PROJECT
|
|
observation["project_id_present"] = bool(isinstance(project_id, str) and project_id.strip())
|
|
observation["project_scope_applied"] = applied
|
|
_write_json(RECEIPT_DIR / "mas-evidence-publisher-denials.json", observation)
|
|
if not observation["passed"]:
|
|
raise GateError("publisher_denials_failed")
|
|
return observation
|
|
|
|
|
|
def _string_list(value):
|
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
return value
|
|
return None
|
|
|
|
|
|
def _bounded_int(value):
|
|
if type(value) is int:
|
|
return value
|
|
if isinstance(value, str) and value.isdigit():
|
|
return int(value)
|
|
return None
|
|
|
|
|
|
def _identity_detail(auth, policies, ttl, uses):
|
|
identity = _string_list(auth.get("identity_policies"))
|
|
legacy = _string_list(auth.get("policies"))
|
|
return {
|
|
"policies": policies,
|
|
"legacy_policies": legacy or [],
|
|
"token_policies_present": _string_list(auth.get("token_policies")) is not None,
|
|
"policies_present": legacy is not None,
|
|
"identity_policies": identity or [],
|
|
"ttl": ttl,
|
|
"ttl_type": type(auth.get("lease_duration")).__name__,
|
|
"num_uses": uses,
|
|
"num_uses_type": type(auth.get("num_uses")).__name__,
|
|
"auth_fields": sorted(key for key in auth if key not in _SECRET_AUTH_FIELDS),
|
|
}
|
|
|
|
|
|
def _writer_identity(auth):
|
|
"""Use the create response so the two token uses stay available for put and revoke.
|
|
|
|
OpenBao 2.5 returns token_policies. A non-empty legacy policies list must
|
|
name the same single policy. default and any parent policy stay rejected.
|
|
"""
|
|
token_policies = _string_list(auth.get("token_policies"))
|
|
legacy = _string_list(auth.get("policies"))
|
|
policies = token_policies if token_policies else (legacy or [])
|
|
identity = _string_list(auth.get("identity_policies"))
|
|
if identity is None and "identity_policies" not in auth:
|
|
identity = []
|
|
ttl = _bounded_int(auth.get("lease_duration"))
|
|
uses = _bounded_int(auth.get("num_uses"))
|
|
legacy_conflict = bool(legacy) and legacy != ["backup-pair-writer"]
|
|
accepted = (
|
|
not legacy_conflict
|
|
and policies == ["backup-pair-writer"]
|
|
and "default" not in policies
|
|
and identity == []
|
|
and type(ttl) is int
|
|
and 0 < ttl <= 300
|
|
and type(uses) is int
|
|
and 0 < uses <= 2
|
|
)
|
|
if not accepted:
|
|
raise GateError("writer_identity_rejected", _identity_detail(auth, policies, ttl, uses))
|
|
return {
|
|
"policies": policies,
|
|
"identity_policies": [],
|
|
"ttl": ttl,
|
|
"num_uses": uses,
|
|
}
|
|
|
|
|
|
class _Transport:
|
|
def __init__(self, token, absent):
|
|
self.token = token
|
|
self.absent = absent
|
|
|
|
def metadata(self, path):
|
|
if logical_path(path) != WORKLOAD or not self.absent:
|
|
raise GateError("pair_metadata_rejected")
|
|
return None
|
|
|
|
def put_cas0(self, path, body):
|
|
handle = tempfile.NamedTemporaryFile(
|
|
"w", prefix="mas-pair-", dir=RECEIPT_DIR, delete=False
|
|
)
|
|
name = Path(handle.name)
|
|
try:
|
|
json.dump(body["data"], handle)
|
|
handle.flush()
|
|
os.fchmod(handle.fileno(), 0o600)
|
|
handle.close()
|
|
proc = _bao(
|
|
["kv", "put", "-format=json", "-cas=0", logical_path(path), f"@{name}"],
|
|
token=self.token,
|
|
)
|
|
finally:
|
|
name.unlink(missing_ok=True)
|
|
if proc.returncode:
|
|
raise GateError("pair_write_failed")
|
|
return version_from_put(json.loads(proc.stdout))
|
|
|
|
def revoke(self):
|
|
_bao(["token", "revoke", "-self"], token=self.token)
|
|
|
|
|
|
def _custody():
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"application_backup_pair_custody", ROOT / "scripts/application_backup_pair_custody.py"
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, *args, **kwargs):
|
|
raise GateError("writer_token_failed")
|
|
|
|
|
|
def writer_auth(payload):
|
|
"""Keep the server's num_uses. The OpenBao 2.5 CLI drops that field."""
|
|
auth = payload.get("auth") if isinstance(payload, dict) else None
|
|
token = auth.get("client_token") if isinstance(auth, dict) else None
|
|
if not isinstance(token, str) or not token:
|
|
raise GateError("writer_token_failed")
|
|
return auth
|
|
|
|
|
|
def _request_writer_auth():
|
|
addr = os.environ.get("BAO_ADDR") or os.environ.get("VAULT_ADDR") or BAO_ADDR
|
|
parent = os.environ.get("BAO_TOKEN") or os.environ.get("VAULT_TOKEN")
|
|
if addr.rstrip("/") != BAO_ADDR or not isinstance(parent, str) or not parent:
|
|
raise GateError("writer_token_failed")
|
|
body = json.dumps({
|
|
"policies": ["backup-pair-writer"],
|
|
"ttl": "5m",
|
|
"num_uses": 2,
|
|
"no_default_policy": True,
|
|
}).encode()
|
|
request = urllib.request.Request(
|
|
BAO_ADDR + "/v1/auth/token/create-orphan",
|
|
data=body,
|
|
method="POST",
|
|
headers={"X-Vault-Token": parent, "Content-Type": "application/json"},
|
|
)
|
|
opener = urllib.request.build_opener(_NoRedirect)
|
|
try:
|
|
with opener.open(request, timeout=20) as response:
|
|
raw = response.read(65537)
|
|
except GateError:
|
|
raise
|
|
except Exception:
|
|
raise GateError("writer_token_failed") from None
|
|
if len(raw) > 65536:
|
|
raise GateError("writer_token_failed")
|
|
try:
|
|
return writer_auth(json.loads(raw))
|
|
except json.JSONDecodeError:
|
|
raise GateError("writer_token_failed") from None
|
|
|
|
|
|
def write_pair(pair):
|
|
custody = _custody()
|
|
|
|
existing = _bao(["kv", "metadata", "get", "-format=json", WORKLOAD])
|
|
if existing.returncode == 0:
|
|
raise GateError("existing_custody_requires_rotation_review")
|
|
written = _bao(["policy", "write", "backup-pair-writer", str(POLICY_FILE)])
|
|
if written.returncode:
|
|
raise GateError("writer_policy_install_failed")
|
|
auth = _request_writer_auth()
|
|
token = auth["client_token"]
|
|
try:
|
|
identity = _writer_identity(auth)
|
|
receipt = custody.provision("mas", pair, identity, _Transport(token, absent=True))
|
|
except Exception:
|
|
_bao(["token", "revoke", "-self"], token=token)
|
|
raise
|
|
meta = _bao(["kv", "metadata", "get", "-format=json", WORKLOAD])
|
|
if meta.returncode:
|
|
raise GateError("pair_metadata_unreadable")
|
|
data = json.loads(meta.stdout).get("data") or {}
|
|
names = sorted(_kv_data(WORKLOAD))
|
|
summary = {
|
|
"path": WORKLOAD,
|
|
"current_version": data.get("current_version"),
|
|
"field_names": names,
|
|
"cas": 0,
|
|
"kv_version": receipt["kv_version"],
|
|
"values_recorded": False,
|
|
}
|
|
if summary["current_version"] != 1 or names != ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]:
|
|
raise GateError("pair_metadata_rejected")
|
|
_write_json(RECEIPT_DIR / "mas-evidence-pair.json", summary)
|
|
return summary
|
|
|
|
|
|
def main():
|
|
sys.stdout = open(os.devnull, "w")
|
|
sys.stderr = open(os.devnull, "w")
|
|
try:
|
|
_ensure_boto()
|
|
RECEIPT_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
supplier = supplier_pair(_kv_data(SUPPLIER), SUPPLIER)
|
|
backup_path = "platform/workloads/railiance/backup/object-storage"
|
|
bootstrap_path = "platform/workloads/railiance/scaleway/bootstrap"
|
|
backup = _kv_data(backup_path)
|
|
bootstrap = _kv_data(bootstrap_path)
|
|
reader = supplier_pair(backup, backup_path)
|
|
writer = supplier_pair(bootstrap, bootstrap_path)
|
|
apply_live_policy(reader, writer)
|
|
prove_denials(supplier, bootstrap.get("DEFAULT_PROJECT_ID", ""))
|
|
write_pair(supplier)
|
|
except GateError as exc:
|
|
receipt = {"status": "failed", "code": exc.code, "values_recorded": False}
|
|
if exc.detail:
|
|
receipt["detail"] = exc.detail
|
|
_write_json(RECEIPT_DIR / "mas-evidence-publisher-gate.json", receipt)
|
|
return 71
|
|
except Exception as exc:
|
|
_write_json(
|
|
RECEIPT_DIR / "mas-evidence-publisher-gate.json",
|
|
{
|
|
"status": "failed",
|
|
"code": "contained_gate_failure",
|
|
"exception": type(exc).__name__,
|
|
"values_recorded": False,
|
|
},
|
|
)
|
|
return 72
|
|
_write_json(RECEIPT_DIR / "mas-evidence-publisher-gate.json", {"status": "passed", "values_recorded": False})
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except SystemExit:
|
|
raise
|
|
except Exception:
|
|
raise SystemExit(72) from None
|