ops-mason/scripts/verify-reuse-surface-hub-writer.py
tegwick fca5591bc6 MASON-WP-0012: named-role login without session reuse, diagnosis receipt, capabilities-self proof (NK-WP-0051-T06)
verify-reuse-surface-hub-writer.py logs in with skip_browser=true and
shows the address (or opens a fresh temporary browser profile), so the
default Authelia session is never reused. The receipt records uid,
openbao_error and assurance per net-kingdom/docs/sign-in-diagnosis.md,
with tokens, codes and emails redacted. Capabilities come from
sys/capabilities-self on the lane token; no operator or root token is
read. Needs railiance-platform CCR-2026-0045 applied to run live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1041694@bnt-lap001
Assistant-Session: 7af0bb68-ac4a-4ce3-8639-24ddd84c0b52
2026-10-14 23:48:11 +02:00

283 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Attended proof of the reuse-surface hub-writer caller.
./scripts/verify-reuse-surface-hub-writer.py member [--uid UID] [--assurance aal1|aal2]
./scripts/verify-reuse-surface-hub-writer.py non-member [--uid UID] [--assurance aal1|aal2]
Logs in on auth/netkingdom role reuse-surface-hub-writer-workload-kv-read with
``bao login -token-only ... skip_browser=true``: bao prints the sign-in address
and waits, so the default browser's Authelia session is never reused. Open the
address in a private window, or set ``OPS_MASON_LOGIN_BROWSER`` to a Chromium
or Firefox binary and the script opens it in a fresh temporary profile. The
operator's token helper is left alone and the lane token stays in memory.
Capabilities come from ``sys/capabilities-self`` called with the lane token
itself (CCR-2026-0045). No operator or root token is read or used.
The receipt follows ``net-kingdom/docs/sign-in-diagnosis.md``: ``uid``,
``openbao_error`` (verbatim, redacted of tokens, codes and emails) and
``assurance`` when known, plus policies, entity id, path capabilities and
pass/fail. ``--uid`` and ``--assurance`` are what KeyCape's diagnosis page
showed for this request. It never records a token or any KV value.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from ops_mason.attended_login import (
TOKEN_RE,
classify_login_line,
fresh_profile_argv,
login_args,
openbao_error_text,
redact,
resolve_assurance,
resolve_uid,
)
from ops_mason.reuse_surface_hub_writer import (
DATA,
METADATA,
MOUNT,
MUST_DENY,
PARENT_DATA,
PARENT_METADATA,
POLICY,
ROLE,
ROLE_REQUIRES_AAL2,
SIBLING,
assess,
)
BAO_ADDR = os.environ.get("BAO_ADDR", "http://127.0.0.1:18200")
PATHS = [DATA, METADATA, PARENT_DATA, PARENT_METADATA, SIBLING]
def receipts_dir() -> Path:
home = Path(os.environ.get("HOME", str(Path.home())))
path = home / ".local/state/railiance/receipts"
path.mkdir(parents=True, mode=0o700, exist_ok=True)
return path
def run_bao(args: list[str], token: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["BAO_ADDR"] = BAO_ADDR
env["BAO_TOKEN"] = token
env.pop("VAULT_TOKEN", None)
return subprocess.run(
["bao", *args],
input=stdin,
text=True,
capture_output=True,
env=env,
check=False,
)
def open_fresh_profile(url: str, profile_dirs: list[str]) -> None:
browser = os.environ.get("OPS_MASON_LOGIN_BROWSER", "").strip()
if not browser:
return
resolved = shutil.which(browser) or browser
profile = tempfile.mkdtemp(prefix="ops-mason-login-profile-")
profile_dirs.append(profile)
try:
subprocess.Popen(
fresh_profile_argv(resolved, Path(profile), url),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
sys.stderr.write("Opened in a fresh browser profile.\n")
except OSError as exc:
sys.stderr.write(f"Could not start {browser}: {exc}. Open the address in a private window.\n")
def login() -> tuple[str, str]:
"""Return (lane token or "", OpenBao error text or "")."""
env = os.environ.copy()
env["BAO_ADDR"] = BAO_ADDR
env.pop("BAO_TOKEN", None)
env.pop("VAULT_TOKEN", None)
proc = subprocess.Popen(
["bao", *login_args(MOUNT, ROLE)],
text=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
kept: list[str] = []
profile_dirs: list[str] = []
def pump() -> None:
assert proc.stderr is not None
for line in proc.stderr:
kind = classify_login_line(line)
if kind == "url":
sys.stderr.write("\nOpen this address in a private browser window:\n\n")
sys.stderr.write(line.strip() + "\n\n")
open_fresh_profile(line.strip(), profile_dirs)
elif kind == "instruction":
if line.strip():
sys.stderr.write(line.strip() + "\n")
else:
kept.append(line)
sys.stderr.write(redact(line))
reader = threading.Thread(target=pump, daemon=True)
reader.start()
token = (proc.stdout.read() if proc.stdout else "").strip()
proc.wait()
reader.join(timeout=5)
for profile in profile_dirs:
shutil.rmtree(profile, ignore_errors=True)
if proc.returncode != 0 or TOKEN_RE.fullmatch(token) is None:
return "", openbao_error_text(kept) or "login output was not a token"
return token, ""
def lookup(token: str) -> dict:
proc = run_bao(["token", "lookup", "-format=json"], token)
if proc.returncode != 0:
raise RuntimeError(redact(proc.stderr or "token lookup failed"))
payload = json.loads(proc.stdout)
return payload.get("data") or payload
def capabilities(lane: str) -> dict[str, list[str]]:
"""The lane token's own capabilities, from sys/capabilities-self."""
proc = run_bao(
["write", "-format=json", "sys/capabilities-self", "paths=" + ",".join(PATHS)],
lane,
)
if proc.returncode != 0:
raise RuntimeError(redact(proc.stderr or "sys/capabilities-self failed").strip())
payload = json.loads(proc.stdout)
data = payload.get("data") or payload
found: dict[str, list[str]] = {}
for path in PATHS:
value = data.get(path)
if isinstance(value, list):
found[path] = value
return found
def revoke(token: str) -> bool:
proc = run_bao(["token", "revoke", "-self"], token)
return proc.returncode == 0
def write_receipt(name: str, lines: list[str]) -> Path:
path = receipts_dir() / name
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
path.chmod(0o600)
return path
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="verify-reuse-surface-hub-writer.py")
parser.add_argument("mode", choices=["member", "non-member"])
parser.add_argument("--uid", default="", help="uid KeyCape showed for this request")
parser.add_argument(
"--assurance", default="", choices=["", "aal1", "aal2"],
help="assurance KeyCape showed for this request",
)
return parser.parse_args(argv[1:])
def receipt_lines(report: dict, now: str, failures: list[str]) -> list[str]:
lines = [
f"mode={report['mode']}",
f"at={now}",
f"addr={BAO_ADDR}",
f"role={ROLE}",
f"policy={POLICY}",
f"uid={report['uid']}",
f"uid_source={report['uid_source']}",
f"assurance={report['assurance']}",
f"assurance_source={report['assurance_source']}",
f"login={report['login']}",
f"openbao_error={report['openbao_error']}",
f"entity_id={report['entity_id']}",
"policies=" + ",".join(report["policies"]),
"identity_policies=" + ",".join(report["identity_policies"]),
]
for path in PATHS:
caps = (report["capabilities"] or {}).get(path)
shown = ",".join(caps) if isinstance(caps, list) else "not-run"
lines.append(f"{path}: {shown}")
lines.append("revoked=" + ("yes" if report["revoked"] else "no" if report["revoked"] is False else "n/a"))
lines.append("result=" + ("pass" if not failures else "fail"))
for item in failures:
lines.append("failure=" + redact(item))
return [redact(line) for line in lines]
def main(argv: list[str]) -> int:
args = parse_args(argv)
mode = args.mode
try:
resolve_uid(args.uid, "", MOUNT)
except ValueError as exc:
sys.stderr.write(f"{exc}\n")
return 2
sys.stderr.write(
f"Sign in as the {mode} identity. bao prints the address and waits;\n"
"open it in a private window. A platform-admin session is the wrong principal for member.\n"
)
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lane, login_error = login()
report: dict = {
"mode": mode,
"login": "ok" if lane else "refused",
"openbao_error": login_error,
"policies": [],
"identity_policies": [],
"entity_id": "",
"display_name": "",
"capabilities": {},
"revoked": None,
}
try:
if lane:
info = lookup(lane)
report["policies"] = list(info.get("policies") or [])
report["identity_policies"] = list(info.get("identity_policies") or [])
report["entity_id"] = str(info.get("entity_id") or "")
report["display_name"] = str(info.get("display_name") or "")
if mode == "member":
report["capabilities"] = capabilities(lane)
except (RuntimeError, json.JSONDecodeError, OSError) as exc:
report["error"] = redact(str(exc))
report["openbao_error"] = report["openbao_error"] or report["error"]
finally:
if lane:
report["revoked"] = revoke(lane)
lane = ""
report["uid"], report["uid_source"] = resolve_uid(args.uid, report.pop("display_name"), MOUNT)
report["assurance"], report["assurance_source"] = resolve_assurance(
args.assurance, report["login"] == "ok", ROLE_REQUIRES_AAL2
)
failures = assess(report)
if report.get("error"):
failures = [report["error"], *failures]
receipt = write_receipt(f"reuse-surface-hub-writer-{mode}.txt", receipt_lines(report, now, failures))
sys.stderr.write(f"receipt: {receipt}\n")
return 0 if not failures else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))