From 186a92c676d4422b47e8c6429426c1d217c630b7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 7 Nov 2026 14:50:59 +0100 Subject: [PATCH] NK-WP-0054-T04: founder-run helper to disable the OpenBao auth/keycape alias Silent, attended (openbao-attended-exec), read-only unless --apply; refuses a session not from auth/netkingdom, writes a 0600 non-secret receipt. Bootstrap console now points at -path=netkingdom. 5 helper tests. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 149903@bnt-lap001 Assistant-Session: 4c20a536-1452-4a85-bc09-e1987d9817d6 --- .../security_bootstrap_console.py | 10 +- .../service-identity/keycape-alias-disable.sh | 4 + .../service-identity/keycape_alias_disable.py | 118 ++++++++++++++++++ .../tests/test_keycape_alias_disable.py | 100 +++++++++++++++ .../NK-WP-0054-service-identity-policy.md | 8 ++ 5 files changed, 235 insertions(+), 5 deletions(-) create mode 100755 tools/service-identity/keycape-alias-disable.sh create mode 100755 tools/service-identity/keycape_alias_disable.py create mode 100644 tools/service-identity/tests/test_keycape_alias_disable.py diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 4f7e8ca..f61f6f9 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -2411,7 +2411,7 @@ def admin_identity_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "subsystem": "Railiance OpenBao", "responsibility": "openbao-ceremony-operator", "email": role_email(data, "role_openbao_operator_email"), - "location": "auth/keycape or equivalent OpenBao OIDC/JWT mount", + "location": "auth/netkingdom (railiance-platform openbao/auth/netkingdom-*); the auth/keycape alias is retired (NK-WP-0054-T04)", "state": state_value(yes(data, "openbao_oidc_auth_configured"), yes(data, "openbao_oidc_client_registered")), }, downstream_taint, @@ -2423,7 +2423,7 @@ def admin_identity_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "subsystem": "KeyCape -> OpenBao", "responsibility": "platform-root-custodian", "email": role_email(data, "role_platform_custodian_email"), - "location": "bao login -method=oidc -path=keycape role=platform-admin", + "location": "bao login -method=oidc -path=netkingdom role=platform-admin", "state": state_value(yes(data, "openbao_oidc_admin_login_verified"), yes(data, "openbao_oidc_auth_configured")), }, downstream_taint, @@ -2527,7 +2527,7 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str] "# Terminal 1: start the pod-bundled bao CLI and wait until it prints the login URL.\n" f"{kubectl_bin} exec -it -n openbao openbao-0 -- sh -lc '\n" " export BAO_ADDR=http://127.0.0.1:8200\n" - " bao login -method=oidc -path=keycape role=platform-admin \\\n" + " bao login -method=oidc -path=netkingdom role=platform-admin \\\n" " skip_browser=true listenaddress=0.0.0.0 callbackhost=127.0.0.1 port=8250\n" " bao token lookup\n" "'\n\n" @@ -2549,7 +2549,7 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str] ), action( "Configure OpenBao OIDC auth", - "Create or update the auth/keycape mount and platform-admin role so KeyCape group claims map to OpenBao platform-admin policy. The helper uses a non-secret compatibility client-secret value because OpenBao requires the field while the current KeyCape client is public PKCE.", + "The auth/netkingdom mount and its platform-admin role (net-kingdom-admins, aal2) are railiance-platform-owned; prepare the change there and run it as a governed admin operation. The legacy helper is retired, and the auth/keycape alias mount is retired (NK-WP-0054-T04) because it granted platform-admin without aal2.", auth_state, auth_reason, configure_command, @@ -4165,7 +4165,7 @@ def ui_html() -> str:
diff --git a/tools/service-identity/keycape-alias-disable.sh b/tools/service-identity/keycape-alias-disable.sh new file mode 100755 index 0000000..31e12e1 --- /dev/null +++ b/tools/service-identity/keycape-alias-disable.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Silent child for the governed attended login (NK-WP-0054-T04); results go to the receipt only. +set -euo pipefail +exec python3 "$(dirname "$0")/keycape_alias_disable.py" "$@" >/dev/null 2>&1 diff --git a/tools/service-identity/keycape_alias_disable.py b/tools/service-identity/keycape_alias_disable.py new file mode 100755 index 0000000..83f26c1 --- /dev/null +++ b/tools/service-identity/keycape_alias_disable.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Disable the OpenBao `auth/keycape` alias mount (NK-WP-0054-T04 finding), founder-run and silent. + +The alias mount grants `platform-admin` through KeyCape client `openbao-admin` without binding aal2, while the +supported login, `auth/netkingdom/role/platform-admin`, binds aal2. Disabling the mount removes its role and +revokes every token it issued. The KeyCape client stays, because `auth/netkingdom` uses it too. + +Run only inside the governed attended login (railiance-platform `scripts/openbao-attended-exec.py`). The session +must come from `auth/netkingdom`; a session from the alias itself is refused, because disabling it would revoke +that session mid-run. Read-only unless `--apply`. Prints nothing; writes one non-secret receipt (0600, new file). + + keycape-alias-disable.sh --receipt /abs/path.json [--apply] +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import subprocess +import sys +from datetime import datetime, timezone + +MOUNT = "keycape/" +KEEP = "netkingdom/" +SCHEMA = "net-kingdom.openbao-keycape-alias-disable.v1" + + +class Refused(Exception): + pass + + +def bao_json(*args: str) -> dict: + out = subprocess.run(["bao", args[0], "-format=json", *args[1:]], capture_output=True, check=True, timeout=30) + return json.loads(out.stdout) + + +def require_attended() -> None: + if pathlib.Path.home().parent.name != ".warden-attended-login" or os.getenv("BAO_TOKEN") or os.getenv("VAULT_TOKEN"): + raise Refused("attended_envelope_required") + + +def mounts() -> dict: + return {path: {"type": m.get("type"), "accessor_present": bool(m.get("accessor"))} + for path, m in bao_json("read", "sys/auth")["data"].items()} + + +def session() -> dict: + data = bao_json("read", "auth/token/lookup-self")["data"] + return {"path": data.get("path"), "policies": sorted(data.get("policies") or [])} + + +def alias_view() -> dict: + view: dict = {} + try: + view["roles"] = sorted(bao_json("list", "auth/keycape/role")["data"].get("keys", [])) + except subprocess.CalledProcessError: + view["roles"] = None + try: + cfg = bao_json("read", "auth/keycape/config")["data"] + view["oidc_client_id"] = cfg.get("oidc_client_id") + view["default_role"] = cfg.get("default_role") + except subprocess.CalledProcessError: + view["oidc_client_id"] = None + return view + + +def run(apply: bool) -> dict: + require_attended() + me = session() + if not str(me["path"]).startswith("auth/" + KEEP): + raise Refused("session_not_from_auth_netkingdom") + before = mounts() + result = {"session": me, "mounts_before": sorted(before), "applied": False} + if MOUNT not in before: + return {**result, "status": "already_disabled"} + if before[MOUNT]["type"] != "oidc": + raise Refused("keycape_mount_not_oidc") + if KEEP not in before: + raise Refused("auth_netkingdom_missing") + result["alias"] = alias_view() + if not apply: + return {**result, "status": "dry_run_ok"} + subprocess.run(["bao", "auth", "disable", MOUNT.rstrip("/")], capture_output=True, check=True, timeout=60) + after = mounts() + still_me = session() # our own token, from auth/netkingdom, must survive + ok = MOUNT not in after and KEEP in after and still_me["path"] == me["path"] + return {**result, "applied": True, "mounts_after": sorted(after), "session_after_ok": still_me["path"] == me["path"], + "status": "disabled_verified" if ok else "readback_mismatch"} + + +def write_receipt(path: str, body: dict) -> None: + fd = os.open(pathlib.Path(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump({"schema": SCHEMA, "observed_at": datetime.now(timezone.utc).isoformat(), + "credential_values_emitted": False, **body}, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--receipt", required=True) + ap.add_argument("--apply", action="store_true") + args = ap.parse_args(argv) + if not os.path.isabs(args.receipt): + return 2 + try: + body = run(args.apply) + except Refused as error: + body = {"status": str(error), "applied": False} + except subprocess.CalledProcessError as error: + body = {"status": "bao_failed", "step": " ".join(error.cmd[:3]), "applied": None} + write_receipt(args.receipt, body) + return 0 if body["status"] in ("dry_run_ok", "disabled_verified", "already_disabled") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/service-identity/tests/test_keycape_alias_disable.py b/tools/service-identity/tests/test_keycape_alias_disable.py new file mode 100644 index 0000000..fd2dbf4 --- /dev/null +++ b/tools/service-identity/tests/test_keycape_alias_disable.py @@ -0,0 +1,100 @@ +"""keycape alias disable helper against a fake bao (NK-WP-0054-T04). Synthetic only.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys + +HERE = pathlib.Path(__file__).resolve().parent +SCRIPT = HERE.parent / "keycape-alias-disable.sh" + +FAKE_BAO = r'''#!/usr/bin/env python3 +import json, os, pathlib, sys +state_file = pathlib.Path(os.environ["FAKE_BAO_STATE"]) +state = json.loads(state_file.read_text()) +args = [a for a in sys.argv[1:] if a != "-format=json"] +state.setdefault("calls", []).append(args) +def out(data): print(json.dumps({"data": data})) +if args == ["read", "sys/auth"]: + out({m: {"type": t, "accessor": "auth_x"} for m, t in state["mounts"].items()}) +elif args == ["read", "auth/token/lookup-self"]: + if state["session"] not in [m.rstrip("/") for m in state["mounts"]]: + sys.exit(2) + out({"path": "auth/%s/oidc/callback" % state["session"], "policies": ["default", "platform-admin"]}) +elif args == ["list", "auth/keycape/role"]: + out({"keys": ["platform-admin"]}) +elif args == ["read", "auth/keycape/config"]: + out({"oidc_client_id": "openbao-admin", "default_role": "platform-admin", "oidc_client_secret": "never"}) +elif args[:2] == ["auth", "disable"]: + state["mounts"].pop(args[2] + "/") +else: + sys.exit(3) +state_file.write_text(json.dumps(state)) +''' + + +def prepare(tmp_path, session="netkingdom", mounts=None, envelope=True): + bindir = tmp_path / "bin" + bindir.mkdir() + (bindir / "bao").write_text(FAKE_BAO) + (bindir / "bao").chmod(0o755) + state = tmp_path / "state.json" + state.write_text(json.dumps({"session": session, "mounts": mounts or + {"keycape/": "oidc", "netkingdom/": "oidc", "token/": "token"}})) + home = tmp_path / (".warden-attended-login" if envelope else "plain") / "h" + home.mkdir(parents=True) + env = {k: v for k, v in os.environ.items() if k not in ("BAO_TOKEN", "VAULT_TOKEN")} + env.update(PATH=f"{bindir}:{env['PATH']}", HOME=str(home), FAKE_BAO_STATE=str(state)) + return env, state + + +def run(tmp_path, env, *extra): + receipt = tmp_path / f"receipt{len(list(tmp_path.glob('receipt*')))}.json" + done = subprocess.run([str(SCRIPT), "--receipt", str(receipt), *extra], env=env, capture_output=True, text=True) + assert done.stdout == "" and done.stderr == "" # silent + body = json.loads(receipt.read_text()) + assert oct(receipt.stat().st_mode & 0o777) == "0o600" + assert "never" not in receipt.read_text() + return done.returncode, body + + +def test_dry_run_reads_only(tmp_path): + env, state = prepare(tmp_path) + code, body = run(tmp_path, env) + assert code == 0 and body["status"] == "dry_run_ok" and body["applied"] is False + assert body["alias"] == {"roles": ["platform-admin"], "oidc_client_id": "openbao-admin", + "default_role": "platform-admin"} + assert not any(c[:2] == ["auth", "disable"] for c in json.loads(state.read_text())["calls"]) + + +def test_apply_disables_and_verifies(tmp_path): + env, state = prepare(tmp_path) + code, body = run(tmp_path, env, "--apply") + assert code == 0 and body["status"] == "disabled_verified" and body["session_after_ok"] + assert "keycape/" not in body["mounts_after"] and "netkingdom/" in body["mounts_after"] + code, body = run(tmp_path, env, "--apply") + assert code == 0 and body["status"] == "already_disabled" + + +def test_refuses_session_from_the_alias(tmp_path): + env, state = prepare(tmp_path, session="keycape") + code, body = run(tmp_path, env, "--apply") + assert code == 1 and body["status"] == "session_not_from_auth_netkingdom" + assert "keycape/" in json.loads(state.read_text())["mounts"] + + +def test_refuses_outside_the_attended_envelope(tmp_path): + env, state = prepare(tmp_path, envelope=False) + code, body = run(tmp_path, env, "--apply") + assert code == 1 and body["status"] == "attended_envelope_required" + + +def test_refuses_existing_receipt(tmp_path): + env, _ = prepare(tmp_path) + receipt = tmp_path / "exists.json" + receipt.write_text("{}") + done = subprocess.run([str(SCRIPT), "--receipt", str(receipt)], env=env) + assert done.returncode != 0 and receipt.read_text() == "{}" diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index e59d9ff..fa9d195 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -240,6 +240,14 @@ an aal2 binding and keeps the retired `bao.coulomb.social` callback. There are a aal2, one ESO role sharing a declared policy, and four PostgreSQL roles. All owners are railiance-platform; the findings are routed to them. KeyCape, LLDAP, Authelia and privacyIDEA are not covered yet. +**2026-11-07 founder decision: disable the `auth/keycape` alias mount.** Both mounts use KeyCape client +`openbao-admin`, so the client stays; `sso-mfa/k8s/keycape/openbao-client-config.py` already keeps the alias's +UI callback retired. The bootstrap console now names `-path=netkingdom` instead of `-path=keycape`. +Founder-run helper: `tools/service-identity/keycape-alias-disable.sh` (5 tests), run through railiance-platform's +`scripts/openbao-attended-exec.py`. It is read-only by default, `--apply` disables, and it refuses a session that +did not come from `auth/netkingdom`. railiance-platform removes the alias from its declarations +(`openbao/auth/keycape-oidc-config.json`, `docs/openbao.md`) so that a restore cannot bring it back. + ## Split the directory bind from the human admin ```task