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
This commit is contained in:
tegwick 2026-10-14 23:48:11 +02:00
parent 97d1fed949
commit fca5591bc6
8 changed files with 616 additions and 136 deletions

View file

@ -1,58 +1,69 @@
#!/usr/bin/env python3
"""Attended proof of the reuse-surface hub-writer caller.
./scripts/verify-reuse-surface-hub-writer.py member
./scripts/verify-reuse-surface-hub-writer.py non-member
./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``, so the operator's token helper is left alone. The
lane token stays in memory. Capability queries use the first local token that
may call ``sys/capabilities`` (``BAO_TOKEN`` or ``VAULT_TOKEN``, then
``~/.local/openbao/platform-admin.token``, then ``~/.vault-token``), because
the workload policy has no sys/capabilities-self grant. The receipt records
policies, entity id, path capabilities, and pass/fail. It does not record the
token or any KV value.
``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.
Sign in with a private browser window. A reused platform-admin session is the
wrong principal for ``member``.
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 re
import stat
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")
# This OpenBao issues s. service tokens. hvs/hvb/hvr are the newer Vault shapes.
TOKEN_RE = re.compile(r"\b(?:hvs|hvb|hvr|s)\.[A-Za-z0-9_-]{8,}\b")
PATHS = [DATA, METADATA, PARENT_DATA, PARENT_METADATA, SIBLING]
def redact(text: str) -> str:
return TOKEN_RE.sub("[redacted-token]", text or "")
def receipts_dir() -> Path:
home = Path(os.environ.get("HOME", str(Path.home())))
path = home / ".local/state/railiance/receipts"
@ -60,48 +71,6 @@ def receipts_dir() -> Path:
return path
def token_candidates() -> list[tuple[str, str]]:
found: list[tuple[str, str]] = []
for name in ("BAO_TOKEN", "VAULT_TOKEN"):
value = os.environ.get(name, "").strip()
if value:
found.append((name, value))
home = Path(os.environ.get("HOME", str(Path.home())))
for label, path in (
("~/.local/openbao/platform-admin.token", home / ".local/openbao/platform-admin.token"),
("~/.vault-token", home / ".vault-token"),
):
if path.is_file():
value = path.read_text(encoding="utf-8").strip()
if value:
found.append((label, value))
return found
def write_capabilities(caller: str, subject: str, paths: list[str]) -> subprocess.CompletedProcess[str]:
body = json.dumps({"token": subject, "paths": paths})
tmp = tempfile.NamedTemporaryFile("w", prefix="rs-caps-", delete=False)
try:
os.fchmod(tmp.fileno(), stat.S_IRUSR | stat.S_IWUSR)
tmp.write(body)
tmp.close()
return run_bao(["write", "-format=json", "sys/capabilities", f"@{tmp.name}"], caller)
finally:
try:
os.remove(tmp.name)
except OSError:
pass
def operator_token() -> str:
"""Return a token allowed to call sys/capabilities, or an empty string."""
for _label, token in token_candidates():
probe = write_capabilities(token, token, ["auth/token/lookup-self"])
if probe.returncode == 0:
return token
return ""
def run_bao(args: list[str], token: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["BAO_ADDR"] = BAO_ADDR
@ -117,16 +86,67 @@ def run_bao(args: list[str], token: str, stdin: str | None = None) -> subprocess
)
def login() -> str:
proc = run_bao(
["login", "-token-only", "-method=oidc", "-path=netkingdom", f"role={ROLE}"],
token="",
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,
)
token = (proc.stdout or "").strip()
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:
sys.stderr.write(redact(proc.stderr or "login output was not a token\n"))
return ""
return token
return "", openbao_error_text(kept) or "login output was not a token"
return token, ""
def lookup(token: str) -> dict:
@ -137,15 +157,14 @@ def lookup(token: str) -> dict:
return payload.get("data") or payload
def capabilities(operator: str, lane: str) -> dict[str, list[str]]:
if not operator:
raise RuntimeError(
"no token can call sys/capabilities "
"(BAO_TOKEN, VAULT_TOKEN, ~/.local/openbao/platform-admin.token, or ~/.vault-token)"
)
proc = write_capabilities(operator, lane, PATHS)
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 failed"))
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]] = {}
@ -168,57 +187,30 @@ def write_receipt(name: str, lines: list[str]) -> Path:
return path
def main(argv: list[str]) -> int:
mode = argv[1] if len(argv) > 1 else ""
if mode not in {"member", "non-member"}:
sys.stderr.write("usage: verify-reuse-surface-hub-writer.py member|non-member\n")
return 2
operator = operator_token()
if not operator:
sys.stderr.write(
"no token can call sys/capabilities. Checked BAO_TOKEN, VAULT_TOKEN, "
"~/.local/openbao/platform-admin.token, and ~/.vault-token.\n"
)
return 1
sys.stderr.write(
f"Sign in as the {mode} identity in a private browser window.\n"
"A platform-admin session is the wrong principal for member.\n"
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",
)
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lane = login()
report: dict = {
"mode": mode,
"login": "ok" if lane else "refused",
"policies": [],
"identity_policies": [],
"entity_id": "",
"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 "")
if mode == "member":
report["capabilities"] = capabilities(operator, lane)
except (RuntimeError, json.JSONDecodeError, OSError) as exc:
report["error"] = redact(str(exc))
finally:
if lane:
report["revoked"] = revoke(lane)
lane = ""
failures = assess(report)
if report.get("error"):
failures = [report["error"], *failures]
return parser.parse_args(argv[1:])
def receipt_lines(report: dict, now: str, failures: list[str]) -> list[str]:
lines = [
f"mode={mode}",
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"]),
@ -230,8 +222,59 @@ def main(argv: list[str]) -> int:
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=" + item)
receipt = write_receipt(f"reuse-surface-hub-writer-{mode}.txt", lines)
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