2026-11-02 12:24:47 +01:00
|
|
|
#!/usr/bin/env python3
|
2026-11-02 23:41:20 +01:00
|
|
|
"""Mint the MAS workload key, prove its boundary, and write its one CAS=0 pair.
|
|
|
|
|
|
|
|
|
|
Run only as `--mint-workload` inside the attended OpenBao platform-admin login,
|
|
|
|
|
started by `mas_evidence_iam_session.mjs --mint-workload`. The founder-held
|
|
|
|
|
supplier key at SUPPLIER is not read or copied: the workload pair is a second
|
|
|
|
|
API key on the same IAM application, so it can be revoked on its own. A
|
|
|
|
|
temporary Scaleway Console session arrives on stdin after the gate signals
|
|
|
|
|
readiness. If anything fails after the mint, the minted key is deleted.
|
2026-11-02 12:24:47 +01:00
|
|
|
|
|
|
|
|
Stdout stays empty. Receipts record statement ids, HTTP statuses, versions,
|
2026-11-02 23:41:20 +01:00
|
|
|
and field names. Values are never written to a receipt, a file, or stderr.
|
2026-11-02 12:24:47 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
2026-11-02 19:28:28 +01:00
|
|
|
import pwd
|
|
|
|
|
import re
|
2026-11-02 12:24:47 +01:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-11-02 23:41:20 +01:00
|
|
|
import time
|
2026-11-03 05:41:25 +01:00
|
|
|
import urllib.error
|
2026-11-02 19:28:28 +01:00
|
|
|
import urllib.request
|
2026-11-02 12:24:47 +01:00
|
|
|
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",
|
|
|
|
|
)
|
2026-11-02 19:28:28 +01:00
|
|
|
PROBE_SID = "PublisherBucketProbe"
|
|
|
|
|
PROBE_ACTIONS = (
|
|
|
|
|
"s3:GetBucketLocation",
|
|
|
|
|
"s3:GetBucketVersioning",
|
|
|
|
|
"s3:GetLifecycleConfiguration",
|
|
|
|
|
)
|
|
|
|
|
REPLACED_PROBES = (
|
|
|
|
|
"CoulombSocialBackupProbe",
|
|
|
|
|
"EmailConnectBackupProbe",
|
|
|
|
|
"MasEvidenceBackupProbe",
|
|
|
|
|
)
|
|
|
|
|
STATEMENT_LIMIT = 10
|
2026-11-02 12:24:47 +01:00
|
|
|
SUPPLIER = "platform/suppliers/scaleway/mas-evidence-backup-primary"
|
|
|
|
|
WORKLOAD = "platform/workloads/mas/backup-primary"
|
2026-11-02 19:28:28 +01:00
|
|
|
PREFERRED_PROJECT = "reef-storage"
|
2026-11-02 23:41:20 +01:00
|
|
|
PROJECT_ID = "e1a0dd0e-04b8-4ea9-8b30-d53f8c35c688"
|
|
|
|
|
POLICY_NAME = "mas-evidence-backup-primary"
|
|
|
|
|
WORKLOAD_KEY_DESCRIPTION = "MAS evidence workload pair; RPF-WP-0070; node delivery"
|
|
|
|
|
READY_NAME = "mas-evidence-mint-ready.json"
|
|
|
|
|
MINT_NAME = "mas-evidence-workload-mint.json"
|
|
|
|
|
PROOF_ATTEMPTS = 7
|
|
|
|
|
PROOF_DELAY_SECONDS = 10
|
2026-11-02 19:28:28 +01:00
|
|
|
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"
|
2026-11-02 12:24:47 +01:00
|
|
|
)
|
2026-11-02 19:28:28 +01:00
|
|
|
_SECRET_AUTH_FIELDS = frozenset({"client_token", "accessor"})
|
2026-11-02 12:24:47 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class GateError(RuntimeError):
|
2026-11-02 19:28:28 +01:00
|
|
|
def __init__(self, code, detail=None):
|
2026-11-02 12:24:47 +01:00
|
|
|
super().__init__(code)
|
|
|
|
|
self.code = code
|
2026-11-02 19:28:28 +01:00
|
|
|
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
|
2026-11-02 12:24:47 +01:00
|
|
|
|
|
|
|
|
|
2026-11-02 19:28:28 +01:00
|
|
|
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):
|
2026-11-02 12:24:47 +01:00
|
|
|
"""Return the two workload fields. Refuse a partial or identical pair."""
|
2026-11-02 19:28:28 +01:00
|
|
|
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"))
|
2026-11-02 12:24:47 +01:00
|
|
|
if not isinstance(data, dict):
|
2026-11-02 19:28:28 +01:00
|
|
|
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]}
|
2026-11-02 12:24:47 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def mas_statements(document):
|
|
|
|
|
found = []
|
|
|
|
|
for item in document.get("Statement", []):
|
|
|
|
|
if item.get("Sid") in MAS_SIDS:
|
|
|
|
|
found.append(item)
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 19:28:28 +01:00
|
|
|
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 []
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 12:24:47 +01:00
|
|
|
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:
|
2026-11-02 19:28:28 +01:00
|
|
|
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 []
|
2026-11-02 12:24:47 +01:00
|
|
|
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,
|
2026-11-02 19:28:28 +01:00
|
|
|
"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,
|
2026-11-02 12:24:47 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def denial_observation(results):
|
|
|
|
|
required = ("sibling_prefix", "retained_delete", "administration")
|
2026-11-02 19:28:28 +01:00
|
|
|
names = (*required, "own_prefix")
|
2026-11-02 12:24:47 +01:00
|
|
|
denied = {name: bool(results.get(name, {}).get("denied")) for name in required}
|
2026-11-02 19:28:28 +01:00
|
|
|
own = results.get("own_prefix") or {}
|
|
|
|
|
own_allowed = own.get("http_status") == 200 and own.get("denied") is False
|
2026-11-02 12:24:47 +01:00
|
|
|
return {
|
|
|
|
|
"application_id": APPLICATION_ID,
|
|
|
|
|
"denied": denied,
|
2026-11-02 19:28:28 +01:00
|
|
|
"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,
|
2026-11-02 12:24:47 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 19:28:28 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 12:24:47 +01:00
|
|
|
def _client(pair):
|
2026-11-02 19:28:28 +01:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-11-02 12:24:47 +01:00
|
|
|
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",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 19:28:28 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 12:24:47 +01:00
|
|
|
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)
|
2026-11-02 19:28:28 +01:00
|
|
|
return {"http_status": code, "denied": False, "error_code": None}
|
2026-11-02 12:24:47 +01:00
|
|
|
except ClientError as exc:
|
2026-11-02 19:28:28 +01:00
|
|
|
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)
|
2026-11-02 12:24:47 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_boto():
|
|
|
|
|
try:
|
|
|
|
|
import boto3 # noqa: F401
|
2026-11-02 19:28:28 +01:00
|
|
|
return
|
2026-11-02 12:24:47 +01:00
|
|
|
except ImportError:
|
2026-11-02 19:28:28 +01:00
|
|
|
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")
|
2026-11-02 12:24:47 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_live_policy(reader_pair, writer_pair):
|
2026-11-02 19:28:28 +01:00
|
|
|
from botocore.exceptions import ClientError
|
|
|
|
|
|
2026-11-02 12:24:47 +01:00
|
|
|
desired = json.loads(DESIRED.read_text(encoding="utf-8"))
|
|
|
|
|
reader = _client(reader_pair)
|
2026-11-02 19:28:28 +01:00
|
|
|
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"]
|
2026-11-02 12:24:47 +01:00
|
|
|
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:
|
2026-11-02 19:28:28 +01:00
|
|
|
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
|
2026-11-02 12:24:47 +01:00
|
|
|
receipt["put"] = "ok"
|
2026-11-02 19:28:28 +01:00
|
|
|
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
|
2026-11-02 12:24:47 +01:00
|
|
|
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)
|
2026-11-02 19:28:28 +01:00
|
|
|
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
|
|
|
|
|
):
|
2026-11-02 12:24:47 +01:00
|
|
|
raise GateError("mas_policy_not_installed")
|
|
|
|
|
return receipt
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 19:28:28 +01:00
|
|
|
def prove_denials(pair, project_id=""):
|
|
|
|
|
scoped, applied = scoped_supplier_pair(pair, project_id)
|
|
|
|
|
client = _client(scoped)
|
2026-11-02 12:24:47 +01:00
|
|
|
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
|
2026-11-02 19:28:28 +01:00
|
|
|
observation["project_id_present"] = bool(isinstance(project_id, str) and project_id.strip())
|
|
|
|
|
observation["project_scope_applied"] = applied
|
2026-11-02 12:24:47 +01:00
|
|
|
_write_json(RECEIPT_DIR / "mas-evidence-publisher-denials.json", observation)
|
|
|
|
|
if not observation["passed"]:
|
|
|
|
|
raise GateError("publisher_denials_failed")
|
|
|
|
|
return observation
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 19:28:28 +01:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 12:24:47 +01:00
|
|
|
def _writer_identity(auth):
|
2026-11-02 19:28:28 +01:00
|
|
|
"""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))
|
2026-11-02 12:24:47 +01:00
|
|
|
return {
|
|
|
|
|
"policies": policies,
|
2026-11-02 19:28:28 +01:00
|
|
|
"identity_policies": [],
|
2026-11-02 12:24:47 +01:00
|
|
|
"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):
|
2026-11-02 23:41:20 +01:00
|
|
|
"""Write over the HTTP API. The value never touches a file."""
|
|
|
|
|
if logical_path(path) != WORKLOAD or body.get("options") != {"cas": 0}:
|
|
|
|
|
raise GateError("pair_write_failed")
|
|
|
|
|
request = urllib.request.Request(
|
|
|
|
|
BAO_ADDR + "/v1/" + path,
|
|
|
|
|
data=json.dumps(body).encode(),
|
|
|
|
|
method="POST",
|
|
|
|
|
headers={"X-Vault-Token": self.token, "Content-Type": "application/json"},
|
2026-11-02 12:24:47 +01:00
|
|
|
)
|
2026-11-02 23:41:20 +01:00
|
|
|
opener = urllib.request.build_opener(_NoRedirect)
|
2026-11-02 12:24:47 +01:00
|
|
|
try:
|
2026-11-02 23:41:20 +01:00
|
|
|
with opener.open(request, timeout=20) as response:
|
|
|
|
|
raw = response.read(65537)
|
|
|
|
|
except GateError:
|
|
|
|
|
raise
|
|
|
|
|
except Exception:
|
|
|
|
|
raise GateError("pair_write_failed") from None
|
|
|
|
|
try:
|
|
|
|
|
return version_from_put(json.loads(raw))
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
raise GateError("pair_write_failed") from None
|
2026-11-02 12:24:47 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 19:28:28 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-11-03 05:41:25 +01:00
|
|
|
def parent_token(environ=None):
|
|
|
|
|
"""Find the attended admin token the way the bao CLI does.
|
|
|
|
|
|
|
|
|
|
Warden's attended login removes BAO_TOKEN and VAULT_TOKEN from the child
|
|
|
|
|
and leaves the token only in the private helper at $HOME/.vault-token.
|
|
|
|
|
Returns (token, source); the token itself is never recorded.
|
|
|
|
|
"""
|
|
|
|
|
environ = os.environ if environ is None else environ
|
|
|
|
|
for name in ("BAO_TOKEN", "VAULT_TOKEN"):
|
|
|
|
|
value = (environ.get(name) or "").strip()
|
|
|
|
|
if value:
|
|
|
|
|
return value, name
|
|
|
|
|
home = environ.get("HOME") or ""
|
|
|
|
|
if not home:
|
|
|
|
|
return "", "absent"
|
|
|
|
|
helper = Path(home) / ".vault-token"
|
|
|
|
|
try:
|
|
|
|
|
info = helper.lstat()
|
|
|
|
|
except OSError:
|
|
|
|
|
return "", "absent"
|
|
|
|
|
import stat as _stat
|
|
|
|
|
|
|
|
|
|
if not _stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
|
|
|
|
return "", "helper_rejected"
|
|
|
|
|
value = helper.read_text(encoding="utf-8").strip()
|
|
|
|
|
return (value, "token_helper") if value else ("", "absent")
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 19:28:28 +01:00
|
|
|
def _request_writer_auth():
|
|
|
|
|
addr = os.environ.get("BAO_ADDR") or os.environ.get("VAULT_ADDR") or BAO_ADDR
|
2026-11-03 05:41:25 +01:00
|
|
|
if addr.rstrip("/") != BAO_ADDR:
|
|
|
|
|
raise GateError("writer_token_failed", {"reason": "bao_addr_mismatch"})
|
|
|
|
|
parent, source = parent_token()
|
|
|
|
|
if not parent:
|
|
|
|
|
raise GateError("writer_token_failed", {"reason": "parent_token_" + source})
|
2026-11-02 19:28:28 +01:00
|
|
|
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
|
2026-11-03 05:41:25 +01:00
|
|
|
except urllib.error.HTTPError as exc:
|
|
|
|
|
raise GateError(
|
|
|
|
|
"writer_token_failed", {"reason": "create_http_error", "http_status": exc.code,
|
|
|
|
|
"parent_source": source}
|
|
|
|
|
) from None
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise GateError(
|
|
|
|
|
"writer_token_failed", {"reason": "create_request_failed",
|
|
|
|
|
"error_type": type(exc).__name__, "parent_source": source}
|
|
|
|
|
) from None
|
2026-11-02 19:28:28 +01:00
|
|
|
if len(raw) > 65536:
|
2026-11-03 05:41:25 +01:00
|
|
|
raise GateError("writer_token_failed", {"reason": "create_response_too_large"})
|
2026-11-02 19:28:28 +01:00
|
|
|
try:
|
|
|
|
|
return writer_auth(json.loads(raw))
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
raise GateError("writer_token_failed") from None
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 12:24:47 +01:00
|
|
|
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")
|
2026-11-02 19:28:28 +01:00
|
|
|
auth = _request_writer_auth()
|
2026-11-02 12:24:47 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-11-03 05:36:45 +01:00
|
|
|
def _app_keys(api, organization):
|
|
|
|
|
"""List the application's keys. A Console session needs organization_id or gets HTTP 400."""
|
|
|
|
|
from urllib.parse import urlencode
|
|
|
|
|
|
|
|
|
|
query = urlencode({
|
|
|
|
|
"organization_id": organization,
|
|
|
|
|
"application_id": APPLICATION_ID,
|
|
|
|
|
"page_size": 100,
|
|
|
|
|
})
|
|
|
|
|
value = api("GET", "/iam/v1alpha1/api-keys?" + query)
|
2026-11-02 23:41:20 +01:00
|
|
|
keys = value.get("api_keys") if isinstance(value, dict) else None
|
|
|
|
|
if not isinstance(keys, list) or value.get("total_count") != len(keys):
|
|
|
|
|
raise GateError("key_inventory_rejected")
|
|
|
|
|
return [item for item in keys if item.get("application_id") == APPLICATION_ID]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _key_view(keys):
|
|
|
|
|
return {
|
|
|
|
|
"count": len(keys),
|
|
|
|
|
"descriptions": sorted(str(item.get("description") or "") for item in keys),
|
|
|
|
|
"default_project_ids": sorted({str(item.get("default_project_id")) for item in keys}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mint_workload_pair(api, prove, write, sleep=time.sleep):
|
|
|
|
|
"""Mint one more key on the MAS application, prove it, then store it CAS=0.
|
|
|
|
|
|
|
|
|
|
`api` is the console transport. `prove(pair, project)` and `write(pair)`
|
|
|
|
|
are the S3 proof and the OpenBao write. The minted key is deleted when a
|
|
|
|
|
later step fails, and the receipt says whether that deletion happened.
|
|
|
|
|
"""
|
|
|
|
|
receipt = {"application_id": APPLICATION_ID, "values_recorded": False}
|
|
|
|
|
app = api("GET", "/iam/v1alpha1/applications/" + APPLICATION_ID)
|
2026-11-03 05:36:45 +01:00
|
|
|
organization = app.get("organization_id")
|
|
|
|
|
if app.get("name") != POLICY_NAME or not isinstance(organization, str) or not organization:
|
2026-11-02 23:41:20 +01:00
|
|
|
raise GateError("application_binding_refused")
|
2026-11-03 05:36:45 +01:00
|
|
|
before = _app_keys(api, organization)
|
2026-11-02 23:41:20 +01:00
|
|
|
receipt["keys_before"] = _key_view(before)
|
|
|
|
|
if len(before) != 1 or before[0].get("default_project_id") != PROJECT_ID:
|
|
|
|
|
raise GateError("supplier_key_state_requires_review", receipt["keys_before"])
|
|
|
|
|
issued = api("POST", "/iam/v1alpha1/api-keys", {
|
|
|
|
|
"application_id": APPLICATION_ID,
|
|
|
|
|
"default_project_id": PROJECT_ID,
|
|
|
|
|
"description": WORKLOAD_KEY_DESCRIPTION,
|
|
|
|
|
})
|
|
|
|
|
access = issued.get("access_key") if isinstance(issued, dict) else None
|
|
|
|
|
try:
|
|
|
|
|
if issued.get("application_id") != APPLICATION_ID:
|
|
|
|
|
raise GateError("issued_key_binding_refused")
|
|
|
|
|
pair = {
|
|
|
|
|
"AWS_ACCESS_KEY_ID": access,
|
|
|
|
|
"AWS_SECRET_ACCESS_KEY": issued.get("secret_key"),
|
|
|
|
|
}
|
|
|
|
|
if any(not isinstance(value, str) or not value for value in pair.values()):
|
|
|
|
|
raise GateError("issued_key_incomplete")
|
|
|
|
|
receipt["minted_at"] = issued.get("created_at")
|
|
|
|
|
attempts = 0
|
|
|
|
|
while True:
|
|
|
|
|
attempts += 1
|
|
|
|
|
try:
|
|
|
|
|
prove(pair, PROJECT_ID)
|
|
|
|
|
break
|
|
|
|
|
except GateError as exc:
|
|
|
|
|
if exc.code != "publisher_denials_failed" or attempts >= PROOF_ATTEMPTS:
|
|
|
|
|
raise
|
|
|
|
|
sleep(PROOF_DELAY_SECONDS)
|
|
|
|
|
receipt["proof_attempts"] = attempts
|
|
|
|
|
receipt["pair"] = write(pair)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
deleted = False
|
|
|
|
|
if isinstance(access, str) and access:
|
|
|
|
|
try:
|
|
|
|
|
api("DELETE", "/iam/v1alpha1/api-keys/" + access)
|
|
|
|
|
deleted = True
|
|
|
|
|
except Exception:
|
|
|
|
|
deleted = False
|
|
|
|
|
code = exc.code if isinstance(exc, GateError) else "contained_gate_failure"
|
|
|
|
|
detail = dict(exc.detail or {}) if isinstance(exc, GateError) and isinstance(exc.detail, dict) else {}
|
|
|
|
|
detail["minted_key_deleted"] = deleted
|
|
|
|
|
raise GateError(code, detail) from None
|
2026-11-03 05:36:45 +01:00
|
|
|
receipt["keys_after"] = _key_view(_app_keys(api, organization))
|
2026-11-02 23:41:20 +01:00
|
|
|
receipt["status"] = "passed"
|
|
|
|
|
return receipt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _console_api(raw):
|
|
|
|
|
import importlib.util
|
|
|
|
|
|
|
|
|
|
if len(raw) > 65536:
|
|
|
|
|
raise GateError("console_auth_rejected")
|
|
|
|
|
try:
|
|
|
|
|
auth = json.loads(raw)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
raise GateError("console_auth_rejected") from None
|
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
|
|
|
"application_recovery_identities", ROOT / "scripts/application_recovery_identities.py"
|
|
|
|
|
)
|
|
|
|
|
module = importlib.util.module_from_spec(spec)
|
|
|
|
|
spec.loader.exec_module(module)
|
|
|
|
|
try:
|
|
|
|
|
transport = module.Transport(auth)
|
|
|
|
|
except module.Refused:
|
|
|
|
|
raise GateError("console_auth_rejected") from None
|
|
|
|
|
|
|
|
|
|
def api(method, path, data=None):
|
|
|
|
|
try:
|
|
|
|
|
return transport(method, path, data)
|
|
|
|
|
except module.ProviderError as exc:
|
|
|
|
|
raise GateError("provider_http_error", {"method": method, "http_status": exc.status}) from None
|
|
|
|
|
except module.Refused as exc:
|
|
|
|
|
raise GateError("provider_refused", {"reason": str(exc)}) from None
|
|
|
|
|
|
|
|
|
|
return api
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _preflight():
|
|
|
|
|
existing = _bao(["kv", "metadata", "get", "-format=json", WORKLOAD])
|
|
|
|
|
if existing.returncode == 0:
|
|
|
|
|
raise GateError("existing_custody_requires_rotation_review")
|
|
|
|
|
if _bao(["policy", "write", "backup-pair-writer", str(POLICY_FILE)]).returncode:
|
|
|
|
|
raise GateError("writer_policy_install_failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv=None):
|
|
|
|
|
args = list(sys.argv[1:] if argv is None else argv)
|
2026-11-02 12:24:47 +01:00
|
|
|
sys.stdout = open(os.devnull, "w")
|
|
|
|
|
sys.stderr = open(os.devnull, "w")
|
2026-11-02 23:41:20 +01:00
|
|
|
receipt_path = RECEIPT_DIR / "mas-evidence-publisher-gate.json"
|
2026-11-02 12:24:47 +01:00
|
|
|
try:
|
2026-11-02 23:41:20 +01:00
|
|
|
if args != ["--mint-workload"]:
|
|
|
|
|
raise GateError("usage_refused")
|
2026-11-02 12:24:47 +01:00
|
|
|
_ensure_boto()
|
|
|
|
|
RECEIPT_DIR.mkdir(mode=0o700, exist_ok=True)
|
2026-11-02 23:41:20 +01:00
|
|
|
(RECEIPT_DIR / MINT_NAME).unlink(missing_ok=True)
|
|
|
|
|
_preflight()
|
|
|
|
|
_write_json(RECEIPT_DIR / READY_NAME, {"status": "waiting_for_console", "values_recorded": False})
|
|
|
|
|
api = _console_api(sys.stdin.read(65537))
|
|
|
|
|
receipt = mint_workload_pair(api, prove_denials, write_pair)
|
|
|
|
|
_write_json(RECEIPT_DIR / MINT_NAME, receipt)
|
2026-11-02 12:24:47 +01:00
|
|
|
except GateError as exc:
|
2026-11-02 19:28:28 +01:00
|
|
|
receipt = {"status": "failed", "code": exc.code, "values_recorded": False}
|
|
|
|
|
if exc.detail:
|
|
|
|
|
receipt["detail"] = exc.detail
|
2026-11-02 23:41:20 +01:00
|
|
|
_write_json(receipt_path, receipt)
|
2026-11-02 12:24:47 +01:00
|
|
|
return 71
|
2026-11-02 19:28:28 +01:00
|
|
|
except Exception as exc:
|
2026-11-02 12:24:47 +01:00
|
|
|
_write_json(
|
2026-11-02 23:41:20 +01:00
|
|
|
receipt_path,
|
2026-11-02 19:28:28 +01:00
|
|
|
{
|
|
|
|
|
"status": "failed",
|
|
|
|
|
"code": "contained_gate_failure",
|
|
|
|
|
"exception": type(exc).__name__,
|
|
|
|
|
"values_recorded": False,
|
|
|
|
|
},
|
2026-11-02 12:24:47 +01:00
|
|
|
)
|
|
|
|
|
return 72
|
2026-11-02 23:41:20 +01:00
|
|
|
finally:
|
|
|
|
|
(RECEIPT_DIR / READY_NAME).unlink(missing_ok=True)
|
|
|
|
|
_write_json(receipt_path, {"status": "passed", "values_recorded": False})
|
2026-11-02 12:24:47 +01:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
try:
|
|
|
|
|
raise SystemExit(main())
|
|
|
|
|
except SystemExit:
|
|
|
|
|
raise
|
|
|
|
|
except Exception:
|
|
|
|
|
raise SystemExit(72) from None
|