net-kingdom/tools/service-identity/keycape_alias_disable.py
tegwick 9db7f5fa1c NK-WP-0054-T04: keycape alias helper always leaves a receipt (failed step, error class, bao status lines)
The founder's first dry run exited non-zero without a receipt: an unexpected
error escaped the handler. Read-only; no live change resulted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 149903@bnt-lap001
Assistant-Session: 4c20a536-1452-4a85-bc09-e1987d9817d6
2026-11-07 15:00:06 +01:00

137 lines
5.5 KiB
Python
Executable file

#!/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 re
import subprocess
import sys
from datetime import datetime, timezone
MOUNT = "keycape/"
KEEP = "netkingdom/"
SCHEMA = "net-kingdom.openbao-keycape-alias-disable.v1"
TOKENISH = re.compile(r"\b[a-z]{1,4}\.[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{24,}")
STEP = {"name": "start"}
class Refused(Exception):
pass
def bao_json(*args: str) -> dict:
STEP["name"] = " ".join(args[:2])
out = subprocess.run(["bao", args[0], "-format=json", *args[1:]], capture_output=True, check=True, timeout=30)
return json.loads(out.stdout)
def error_summary(error: BaseException) -> dict:
"""Error class, failing step and bao's status lines only; token-shaped strings redacted."""
summary = {"error": type(error).__name__, "step": STEP["name"]}
if isinstance(error, subprocess.CalledProcessError):
text = (error.stderr or b"").decode("utf-8", "replace")
lines = [ln.strip() for ln in text.splitlines() if ln.strip().startswith(("Code:", "* ", "URL:", "Error"))]
summary["exit"] = error.returncode
summary["detail"] = [TOKENISH.sub("[redacted]", ln)[:200] for ln in lines[:4]]
return summary
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"}
STEP["name"] = "auth disable"
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 Exception as error: # noqa: BLE001 - every failure must still leave a receipt
body = {"status": "bao_failed" if isinstance(error, subprocess.CalledProcessError) else "helper_failed",
"applied": None if STEP["name"] == "auth disable" else False, **error_summary(error)}
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())