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

@ -0,0 +1,139 @@
"""Attended ``bao login`` for a named OIDC role, and its diagnosis receipt.
NK-WP-0051-T06, contract ``net-kingdom/docs/sign-in-diagnosis.md``:
- A named-role login must not reuse the default Authelia session. The login
runs with ``skip_browser=true``: bao prints the address and waits. If
``OPS_MASON_LOGIN_BROWSER`` names a Chromium-family or Firefox binary, that
address opens in a fresh, temporary browser profile instead.
- The receipt records ``uid``, ``openbao_error`` (OpenBao's error text,
verbatim) and ``assurance`` when known. Never a token, an authorization
code, an email address, a one-time password, or a KV value.
Pure helpers live here so they can be tested without OpenBao.
"""
from __future__ import annotations
import re
from pathlib import Path
# 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")
CODE_RE = re.compile(r"(?i)\b(code|id_token|access_token|refresh_token)=[^&\s\"']+")
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
UID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
ASSURANCE_VALUES = ("aal1", "aal2")
# Lines bao prints around the browser step. They are shown to the operator and
# never kept as error text.
_INSTRUCTION_MARKERS = (
"complete the login via your oidc provider",
"launching browser to",
"open the following link",
"waiting for oidc authentication to complete",
)
def redact(text: str) -> str:
"""Remove tokens, authorization codes and email addresses from text."""
text = TOKEN_RE.sub("[redacted-token]", text or "")
text = CODE_RE.sub(lambda m: f"{m.group(1)}=[redacted]", text)
return EMAIL_RE.sub("[redacted-email]", text)
def login_args(path: str, role: str) -> list[str]:
"""bao arguments for a named-role login that prints the address and waits."""
return [
"login",
"-token-only",
"-method=oidc",
f"-path={path}",
f"role={role}",
"skip_browser=true",
]
def is_auth_url(line: str) -> bool:
stripped = line.strip()
return stripped.startswith(("https://", "http://")) and (
"client_id=" in stripped or "/authorize" in stripped
)
def classify_login_line(line: str) -> str:
"""Return ``url``, ``instruction`` or ``error`` for one bao stderr line."""
if is_auth_url(line):
return "url"
lowered = line.strip().lower()
if not lowered or any(marker in lowered for marker in _INSTRUCTION_MARKERS):
return "instruction"
return "error"
def openbao_error_text(lines: list[str]) -> str:
"""OpenBao's error text from the kept stderr lines, verbatim but redacted.
Lines are joined with `` | `` so the receipt stays one field per line.
"""
kept = [line.strip() for line in lines if line.strip()]
return redact(" | ".join(kept))
def fresh_profile_argv(browser: str, profile_dir: Path, url: str) -> list[str]:
"""Command that opens ``url`` in a new, empty browser profile."""
name = Path(browser).name.lower()
if "firefox" in name:
return [browser, "-no-remote", "-profile", str(profile_dir), "-new-instance", url]
return [
browser,
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--new-window",
url,
]
def uid_from_display_name(display_name: str, mount: str) -> str:
"""The alias name OpenBao put in the token display name, if it is a uid.
OpenBao sets ``display_name`` to ``<mount>-<alias name>``. An empty
string is returned when the value is missing or looks like an email.
"""
value = (display_name or "").strip()
prefix = f"{mount}-"
if value.startswith(prefix):
value = value[len(prefix):]
if not value or "@" in value or not UID_RE.match(value):
return ""
return value
def resolve_uid(operator_uid: str, display_name: str, mount: str) -> tuple[str, str]:
"""Return ``(uid, source)``. The operator's value is what KeyCape showed."""
given = (operator_uid or "").strip()
if given:
if "@" in given or not UID_RE.match(given):
raise ValueError("--uid must be a directory uid, not an email or free text")
return given, "operator"
derived = uid_from_display_name(display_name, mount)
if derived:
return derived, "token-display-name"
return "", "unknown"
def resolve_assurance(operator_value: str, login_ok: bool, role_requires_aal2: bool) -> tuple[str, str]:
"""Return ``(assurance, source)``; empty when not known.
A successful login on a role bound to ``/assurance/level=aal2`` proves
this request carried aal2. A refusal proves nothing about assurance.
"""
given = (operator_value or "").strip()
if given:
if given not in ASSURANCE_VALUES:
raise ValueError("--assurance must be aal1 or aal2")
return given, "operator"
if login_ok and role_requires_aal2:
return "aal2", "role-bound-claim"
return "", "unknown"

View file

@ -8,6 +8,10 @@ from __future__ import annotations
POLICY = "workload-kv-read-reuse-surface-runtime"
ROLE = "reuse-surface-hub-writer-workload-kv-read"
MOUNT = "netkingdom"
# The role binds /assurance/level=aal2 (CCR-2026-0044), so a login that
# succeeds on it carried aal2 for that request.
ROLE_REQUIRES_AAL2 = True
FORBIDDEN_ENTITY_ID = "4a1f663c-0621-0483-edd0-f852274f4b6b"
FORBIDDEN_POLICIES = frozenset(
{
@ -48,9 +52,11 @@ def assess(report: dict) -> list[str]:
return [f"unknown mode {mode!r}"]
login = report.get("login")
if mode == "non-member":
if login == "refused":
return []
return ["non-member login succeeded; the role accepted the wrong principal"]
if login != "refused":
return ["non-member login succeeded; the role accepted the wrong principal"]
if not str(report.get("openbao_error") or "").strip():
return ["non-member refusal recorded no openbao_error text"]
return []
failures: list[str] = []
if login != "ok":