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:
parent
97d1fed949
commit
fca5591bc6
8 changed files with 616 additions and 136 deletions
139
src/ops_mason/attended_login.py
Normal file
139
src/ops_mason/attended_login.py
Normal 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"
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue