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:
- Development/config:
openbao-admin is defined in sso-mfa/k8s/keycape/create-secrets.sh; no manual KeyCape registration is expected.
- Operator deployment: apply the updated KeyCape config to live
keycape-config and restart KeyCape if the live client is missing.
- - Protected OpenBao step: configure
auth/keycape with a hidden root/sudo token prompt, then verify platform-root can complete MFA-backed login.
+ - Protected OpenBao step: railiance-platform configures
auth/netkingdom (aal2-bound platform-admin role), then verify platform-root can complete MFA-backed login.
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