railiance-platform/scripts/backup_reader_boundary_probe.py
codex d41c5ace0a Add the backup reader boundary probe and record founder approval of MAS node execution.
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
2026-11-03 05:50:10 +01:00

307 lines
13 KiB
Python

#!/usr/bin/env python3
"""Prove one backup reader's OpenBao boundary on railiance01 before delivery admission.
Run as root on railiance01. Stdlib only and self-contained, so it can be piped:
ssh railiance01 'sudo python3 - --application mas --expected-kv-version 1' \
< scripts/backup_reader_boundary_probe.py
Positive: the dedicated ServiceAccount logs in with audience openbao, holds only
its one reader policy within the CCR bounds, and reads exactly the expected KV
version with exactly the two pair fields.
Negative: a wrong audience and a foreign role cannot log in; sibling data,
metadata, parent LIST, a KV write and an administration endpoint are denied.
The write probe sends CAS=0 against an existing path, so it can never replace
the pair even if a policy were wrong.
Prints one JSON receipt. Values, tokens and JWTs never leave process memory;
the receipt holds field names, HTTP statuses, versions and bounds only.
Exit 0 only when every check passes.
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import ipaddress
import json
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
CLUSTER_UID = "a553c742-0115-43d4-99a4-a5ca56fe0786"
SA = "backup-primary-delivery"
FIELDS = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
APPLICATIONS = ("coulomb-social", "email-connect", "mas")
KUBE_ENV = {
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"KUBECONFIG": "/etc/rancher/k3s/k3s.yaml",
}
class ProbeError(Exception):
"""Fixed diagnostic codes only."""
def guard(condition, code):
if not condition:
raise ProbeError(code)
def policy(application):
return f"workload-kv-read-{application}-backup-primary"
def role(application):
return f"{application}-backup-primary-workload-kv-read"
def data_path(application):
return f"platform/data/workloads/{application}/backup-primary"
class Native:
"""kubectl and OpenBao access on the node. HTTP errors return their status."""
def __init__(self):
self.address = None
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
def kube(self, args):
try:
result = subprocess.run(
["kubectl", "--request-timeout=20s", *args],
capture_output=True, check=False, timeout=30, env=KUBE_ENV,
)
except (OSError, subprocess.SubprocessError):
raise ProbeError("kubernetes_command_failed") from None
guard(result.returncode == 0 and len(result.stdout) <= 1048576, "kubernetes_command_failed")
return result.stdout
def prepare(self, application):
ns = json.loads(self.kube(["get", "namespace", "kube-system", "-o", "json"]))
guard(ns.get("metadata", {}).get("uid") == CLUSTER_UID, "cluster_identity_mismatch")
svc = json.loads(self.kube(["-n", "openbao", "get", "service", "openbao", "-o", "json"]))
addr = ipaddress.ip_address(svc.get("spec", {}).get("clusterIP", ""))
guard(
addr in ipaddress.ip_network("10.43.0.0/16")
and any(p.get("port") == 8200 for p in svc["spec"].get("ports", [])),
"openbao_service_mismatch",
)
self.address = f"http://{addr}:8200/v1/"
sa = json.loads(self.kube(["-n", application, "get", "serviceaccount", SA, "-o", "json"]))
guard(sa.get("automountServiceAccountToken") is False, "non_mounting_service_account_required")
return sa.get("metadata", {}).get("uid")
def jwt(self, application, audience):
token = self.kube([
"-n", application, "create", "token", SA, f"--audience={audience}", "--duration=10m",
]).decode().strip()
guard(len(token.split(".")) == 3 and len(token) < 16384, "bounded_jwt_required")
return token
def http(self, method, path, payload=None, token=None):
headers = {"Content-Type": "application/json"}
if token is not None:
headers["X-Vault-Token"] = token
body = None if payload is None else json.dumps(payload).encode()
request = urllib.request.Request(self.address + path, data=body, headers=headers, method=method)
try:
with self.opener.open(request, timeout=20) as response:
raw = response.read(1048577)
guard(len(raw) <= 1048576, "openbao_response_too_large")
return response.status, ({} if not raw else json.loads(raw))
except urllib.error.HTTPError as exc:
return exc.code, {}
except (urllib.error.URLError, OSError):
raise ProbeError("openbao_request_failed") from None
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def jwt_claims(token):
piece = token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(piece + "=" * (-len(piece) % 4)))
def _login(native, application, login_role, audience="openbao"):
status, body = native.http(
"POST", "auth/kubernetes/login", {"role": login_role, "jwt": native.jwt(application, audience)}
)
auth = body.get("auth") if isinstance(body, dict) else None
token = auth.get("client_token") if isinstance(auth, dict) else None
return status, auth or {}, token if isinstance(token, str) and token else None
def _revoke(native, token):
status, _ = native.http("POST", "auth/token/revoke-self", {}, token=token)
return status in (200, 204)
def _denials(native, token, probes):
"""Run up to three denied probes on one four-use token, then revoke it."""
statuses = {}
try:
for name, method, path, payload in probes:
statuses[name], _ = native.http(method, path, payload, token=token)
finally:
revoked = _revoke(native, token)
return statuses, revoked
def probe(native, application, expected_version, sa_uid=None):
guard(application in APPLICATIONS, "application_refused")
guard(type(expected_version) is int and expected_version > 0, "expected_version_required")
expected_policy = policy(application)
receipt = {
"schema": "platform.backup-reader-boundary.v1",
"application": application,
"cluster_uid": CLUSTER_UID,
"service_account": f"{application}/{SA}",
"role": role(application),
"policy": expected_policy,
"kv_path": data_path(application),
"expected_kv_version": expected_version,
"values_recorded": False,
}
if sa_uid is None:
sa_uid = native.prepare(application)
claims = jwt_claims(native.jwt(application, "openbao"))
bound = claims.get("kubernetes.io", {}).get("serviceaccount", {})
receipt["jwt_binding"] = (
claims.get("sub") == f"system:serviceaccount:{application}:{SA}"
and claims.get("aud") == ["openbao"]
and bool(bound.get("uid"))
and bound.get("uid") == sa_uid
and 0 < claims.get("exp", 0) - time.time() <= 660
)
# Positive: one token for lookup, the read, and revoke.
status, auth, token = _login(native, application, role(application))
positive = {"login_status": status}
if token:
try:
positive["login_policies"] = auth.get("policies")
positive["lease_duration"] = auth.get("lease_duration")
_, info = native.http("GET", "auth/token/lookup-self", token=token)
data = info.get("data", {}) if isinstance(info, dict) else {}
positive["token"] = {
"policies": data.get("policies"),
"identity_policies": data.get("identity_policies") or [],
"ttl": data.get("ttl"),
"explicit_max_ttl": data.get("explicit_max_ttl"),
"period": data.get("period") or 0,
"num_uses": data.get("num_uses"),
}
read_status, record = native.http("GET", data_path(application), token=token)
payload = record.get("data", {}) if isinstance(record, dict) else {}
metadata = payload.get("metadata", {}) if isinstance(payload, dict) else {}
values = payload.get("data", {}) if isinstance(payload, dict) else {}
values = values if isinstance(values, dict) else {}
positive["read_status"] = read_status
positive["kv_version"] = metadata.get("version")
positive["kv_destroyed"] = metadata.get("destroyed")
positive["kv_deleted"] = bool(metadata.get("deletion_time"))
positive["field_names"] = sorted(values)
positive["encoding_ok"] = all(
isinstance(values.get(name), str)
and re.fullmatch(r"[A-Za-z0-9/+=_.-]{8,256}", values[name]) is not None
for name in FIELDS
)
positive["fields_distinct"] = values.get(FIELDS[0]) != values.get(FIELDS[1])
del values, payload, record
finally:
positive["token_revoked"] = _revoke(native, token)
token_view = positive.get("token", {})
positive["passed"] = (
status == 200
and positive.get("login_policies") == [expected_policy]
and 0 < (positive.get("lease_duration") or 0) <= 300
and token_view.get("policies") == [expected_policy]
and token_view.get("identity_policies") == []
and 0 < (token_view.get("ttl") or 0) <= 300
and 0 < (token_view.get("explicit_max_ttl") or 0) <= 300
and token_view.get("period") == 0
and 0 < (token_view.get("num_uses") or 0) <= 4
and positive.get("read_status") == 200
and positive.get("kv_version") == expected_version
and positive.get("kv_destroyed") is False
and positive.get("kv_deleted") is False
and positive.get("field_names") == FIELDS
and positive.get("encoding_ok") is True
and positive.get("fields_distinct") is True
and positive.get("token_revoked") is True
)
receipt["positive"] = positive
negative = {}
# Login boundary: a wrong audience, and this account against a sibling role.
status, _, stray = _login(native, application, role(application), audience="not-openbao")
negative["wrong_audience_login"] = status
if stray:
_revoke(native, stray)
sibling = next(item for item in APPLICATIONS if item != application)
status, _, stray = _login(native, application, role(sibling))
negative["foreign_role_login"] = status
if stray:
_revoke(native, stray)
others = [item for item in APPLICATIONS if item != application]
status, _, token = _login(native, application, role(application))
guard(status == 200 and token, "negative_login_failed")
first, revoked_first = _denials(native, token, [
("sibling_data_read", "GET", data_path(others[0]), None),
("metadata_read", "GET", f"platform/metadata/workloads/{application}/backup-primary", None),
("parent_list", "LIST", f"platform/metadata/workloads/{application}", None),
])
status, _, token = _login(native, application, role(application))
guard(status == 200 and token, "negative_login_failed")
second, revoked_second = _denials(native, token, [
# CAS=0 on an existing path cannot replace the pair even if allowed.
("kv_write", "POST", data_path(application), {"options": {"cas": 0}, "data": {"probe": "x"}}),
("second_sibling_data_read", "GET", data_path(others[1]), None),
("admin_policy_list", "LIST", "sys/policies/acl", None),
])
negative.update(first)
negative.update(second)
negative["tokens_revoked"] = revoked_first and revoked_second
login_denied = all(negative[name] in (400, 403) for name in ("wrong_audience_login", "foreign_role_login"))
api_denied = all(
negative[name] == 403
for name in (
"sibling_data_read", "metadata_read", "parent_list",
"kv_write", "second_sibling_data_read", "admin_policy_list",
)
)
negative["passed"] = login_denied and api_denied and negative["tokens_revoked"]
receipt["negative"] = negative
receipt["passed"] = receipt["jwt_binding"] and positive["passed"] and negative["passed"]
receipt["checked_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
return receipt
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--application", choices=APPLICATIONS, required=True)
parser.add_argument("--expected-kv-version", type=int, required=True)
args = parser.parse_args(argv)
try:
receipt = probe(Native(), args.application, args.expected_kv_version)
except ProbeError as exc:
receipt = {"status": "failed", "code": str(exc), "values_recorded": False}
except Exception as exc: # noqa: BLE001 - never print backend bodies
receipt = {"status": "failed", "code": "contained_probe_failure",
"error_type": type(exc).__name__, "values_recorded": False}
print(json.dumps(receipt, indent=2, sort_keys=True))
return 0 if receipt.get("passed") is True else 1
if __name__ == "__main__":
sys.exit(main())