From f3c8d70270bec0e87a75ecad9ad7181dc82e4917 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 26 May 2026 02:13:55 +0200 Subject: [PATCH] Split OpenBao admin identity tasks --- sso-mfa/k8s/keycape/README.md | 16 +- sso-mfa/k8s/keycape/create-secrets.sh | 8 + sso-mfa/k8s/verify-t07.sh | 35 ++- .../security_bootstrap_console.py | 250 ++++++++++++++++-- ...-custody-and-openbao-identity-bootstrap.md | 6 + 5 files changed, 292 insertions(+), 23 deletions(-) diff --git a/sso-mfa/k8s/keycape/README.md b/sso-mfa/k8s/keycape/README.md index 172406c..0448813 100644 --- a/sso-mfa/k8s/keycape/README.md +++ b/sso-mfa/k8s/keycape/README.md @@ -101,13 +101,27 @@ kubectl rollout restart deployment/keycape -n sso ## OIDC client registration Downstream applications are registered in the `clients:` block in -`keycape/create-secrets.sh`. After editing: +`keycape/create-secrets.sh`. The NetKingdom bootstrap console and Railiance +OpenBao admin CLI clients are code-defined there; operators should not create +those clients manually in a separate UI. After changing the block: ```bash ./create-secrets.sh # regenerates keycape-config Secret kubectl rollout restart deployment/keycape -n sso ``` +The `openbao-admin` client is intentionally a public PKCE client for the +current local operator CLI flow. It registers the OpenBao CLI callback URIs: + +```text +http://localhost:8250/oidc/callback +http://127.0.0.1:8250/oidc/callback +``` + +OpenBao browser UI callbacks are not registered yet because Railiance OpenBao +currently has public ingress disabled. Add exact UI callback URIs only after +the OpenBao UI exposure model is explicitly designed. + Example entry (public client, PKCE, for a SPA): ```yaml clients: diff --git a/sso-mfa/k8s/keycape/create-secrets.sh b/sso-mfa/k8s/keycape/create-secrets.sh index 26c1f89..5f330a8 100644 --- a/sso-mfa/k8s/keycape/create-secrets.sh +++ b/sso-mfa/k8s/keycape/create-secrets.sh @@ -121,6 +121,14 @@ clients: allowedScopes: ["openid", "profile", "email", "groups"] grantTypes: ["authorization_code"] clientType: "public" + - clientId: "openbao-admin" + displayName: "Railiance OpenBao Admin CLI" + redirectUris: + - "http://localhost:8250/oidc/callback" + - "http://127.0.0.1:8250/oidc/callback" + allowedScopes: ["openid", "profile", "email", "groups"] + grantTypes: ["authorization_code"] + clientType: "public" EOF ) diff --git a/sso-mfa/k8s/verify-t07.sh b/sso-mfa/k8s/verify-t07.sh index 5ed7259..509247f 100755 --- a/sso-mfa/k8s/verify-t07.sh +++ b/sso-mfa/k8s/verify-t07.sh @@ -9,7 +9,7 @@ # 3. At least one non-admin user exists in LLDAP # 4. Break-glass user exists and is in net-kingdom-admins # 5. privacyIDEA self-service portal reachable -# 6. KeyCape config has at least one OIDC client registered +# 6. KeyCape config has OIDC clients, including openbao-admin # # Usage: # chmod +x verify-t07.sh @@ -174,6 +174,39 @@ if [[ -n "$KC_POD" ]]; then else warn "No OIDC clients registered — add clients to keycape/create-secrets.sh and re-run it" fi + OPENBAO_CLIENT_CHECK=$(echo "$CONFIG" | python3 -c ' +import sys +import yaml + +cfg = yaml.safe_load(sys.stdin.read()) or {} +clients = cfg.get("clients") or [] +target = next((client for client in clients if client.get("clientId") == "openbao-admin"), None) +if not target: + print("missing openbao-admin client") + raise SystemExit(2) +required_redirects = { + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback", +} +required_scopes = {"openid", "profile", "email", "groups"} +missing_redirects = sorted(required_redirects - set(target.get("redirectUris") or [])) +missing_scopes = sorted(required_scopes - set(target.get("allowedScopes") or [])) +if target.get("clientType") != "public": + print("openbao-admin clientType must be public for the current PKCE-only KeyCape profile") + raise SystemExit(3) +if missing_redirects: + print("openbao-admin missing redirect URI(s): " + ", ".join(missing_redirects)) + raise SystemExit(4) +if missing_scopes: + print("openbao-admin missing scope(s): " + ", ".join(missing_scopes)) + raise SystemExit(5) +print("openbao-admin client has local CLI redirects and required scopes") +' 2>/dev/null || echo "missing or invalid openbao-admin client") + if [[ "$OPENBAO_CLIENT_CHECK" == openbao-admin* ]]; then + pass "$OPENBAO_CLIENT_CHECK" + else + fail "$OPENBAO_CLIENT_CHECK" + fi else warn "Skipping client check — KeyCape not reachable in-cluster" fi diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index bc90326..a7d955a 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -30,6 +30,7 @@ from typing import Any DEFAULT_STAGE = "S1 - Low-trust assembly" STAGE_ORDER = ("S1", "S2", "S3", "S4", "S5", "S6") DEFAULT_METADATA_PATH = Path("/tmp/net-kingdom-security-bootstrap.json") +REPO_ROOT = Path(__file__).resolve().parents[2] APPROVAL_PHRASE = "approve custody mode" VALID_STORAGE_CLASSES = {"password-safe", "offline-packet", "hardware-token"} VALID_MFA_CLASSES = {"totp", "webauthn", "hardware-token"} @@ -45,6 +46,12 @@ KEYCAPE_ISSUER = "https://kc.coulomb.social" OIDC_CLIENT_ID = "netkingdom-bootstrap-console" OIDC_SCOPE = "openid profile email groups" OIDC_CODE_VERIFIER = "netkingdom-bootstrap-local-oidc-verifier-2026-v1" +KEYCAPE_OPENBAO_CLIENT_ID = "openbao-admin" +KEYCAPE_OPENBAO_CLIENT_CONFIG = REPO_ROOT / "sso-mfa/k8s/keycape/create-secrets.sh" +KEYCAPE_OPENBAO_CLIENT_REDIRECTS = ( + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback", +) AGE_PUBLIC_PREFIX = "age1" AGE_PRIVATE_MARKER = "AGE-SECRET-KEY-1" @@ -94,6 +101,19 @@ def yes(data: dict[str, Any], key: str) -> bool: return data.get(key) is True +def keycape_openbao_client_source_ready() -> bool: + try: + text = KEYCAPE_OPENBAO_CLIENT_CONFIG.read_text() + except OSError: + return False + required = [ + f'clientId: "{KEYCAPE_OPENBAO_CLIENT_ID}"', + 'allowedScopes: ["openid", "profile", "email", "groups"]', + ] + required.extend(f'"{uri}"' for uri in KEYCAPE_OPENBAO_CLIENT_REDIRECTS) + return all(item in text for item in required) + + def second_factor_ready(data: dict[str, Any]) -> bool: return ( data.get("mfa_class") in VALID_MFA_CLASSES @@ -391,8 +411,8 @@ def stage_payloads(data: dict[str, Any]) -> list[dict[str, str]]: ( "S4", "Admin identity integration", - "Register OpenBao with KeyCape and map verified NetKingdom admin claims to the platform-admin policy.", - "Verify OIDC-backed OpenBao admin login.", + "Deploy the code-defined KeyCape client, map verified NetKingdom admin claims to OpenBao policy, and verify login.", + "Apply live KeyCape config, configure OpenBao OIDC auth, and verify admin login.", ), ( "S5", @@ -460,18 +480,41 @@ def build_gates(data: dict[str, Any]) -> list[Gate]: "Apply first auth, mount, and policy configuration; audit may be a declarative follow-up.", ), Gate( - "KeyCape OpenBao client", - "done" if yes(data, "openbao_oidc_client_registered") else "blocked", - "Dedicated OpenBao OIDC client is registered in KeyCape with exact redirect URIs.", + "KeyCape OpenBao client definition", + "done" if keycape_openbao_client_source_ready() else "blocked", + "The non-secret openbao-admin client is defined in KeyCape source/config generation.", + ), + Gate( + "KeyCape OpenBao client deployed", + ( + "done" + if yes(data, "openbao_oidc_client_registered") + else "human" + if keycape_openbao_client_source_ready() and yes(data, "openbao_initial_config_applied") + else "blocked" + ), + "Apply the code-defined client to the live KeyCape keycape-config Secret and restart KeyCape.", ), Gate( "OpenBao OIDC auth", - "done" if yes(data, "openbao_oidc_auth_configured") else "blocked", + ( + "done" + if yes(data, "openbao_oidc_auth_configured") + else "human" + if yes(data, "openbao_oidc_client_registered") + else "blocked" + ), "OpenBao OIDC/JWT auth is configured against KeyCape and maps claims to policy.", ), Gate( "OIDC admin login", - "done" if yes(data, "openbao_oidc_admin_login_verified") else "blocked", + ( + "done" + if yes(data, "openbao_oidc_admin_login_verified") + else "human" + if yes(data, "openbao_oidc_auth_configured") + else "blocked" + ), "platform-root can obtain OpenBao platform-admin access through KeyCape/MFA.", ), Gate( @@ -528,6 +571,12 @@ def next_action( if data and yes(data, "openbao_init_output_produced") and not yes(data, "openbao_initialized"): return "Run OpenBao unseal prompt" return "Run attended OpenBao init ceremony" + if gate.name == "KeyCape OpenBao client deployed": + return "Apply KeyCape OpenBao client config" + if gate.name == "OpenBao OIDC auth": + return "Run OpenBao OIDC auth setup" + if gate.name == "OIDC admin login": + return "Verify OpenBao OIDC admin login" return gate.name if gate.status == "blocked": if gate.name == "King credential kit": @@ -538,10 +587,12 @@ def next_action( return "Approve custody strategy" if gate.name == "OpenBao preflight": return "Run OpenBao preflight" - if gate.name == "KeyCape OpenBao client": - return "Register OpenBao OIDC client" + if gate.name == "KeyCape OpenBao client definition": + return "Ship KeyCape OpenBao client definition" + if gate.name == "KeyCape OpenBao client deployed": + return "Apply KeyCape OpenBao client config" if gate.name == "OpenBao OIDC auth": - return "Configure OpenBao OIDC auth" + return "Run OpenBao OIDC auth setup" if gate.name == "OIDC admin login": return "Verify OpenBao OIDC admin login" if gate.name == "Root-token disposition": @@ -1211,16 +1262,26 @@ def integration_payloads(data: dict[str, Any]) -> list[dict[str, str]]: def admin_identity_payloads(data: dict[str, Any]) -> list[dict[str, str]]: downstream_taint = openbao_trial_taint(data, "downstream") if yes(data, "openbao_initialized") else {} + source_ready = keycape_openbao_client_source_ready() return [ + { + "name": "openbao-admin client definition", + "description": "Code-defined KeyCape public PKCE client for Railiance OpenBao CLI login. This is development/configuration, not a manual registration step.", + "subsystem": "KeyCape source", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": "sso-mfa/k8s/keycape/create-secrets.sh", + "state": state_value(source_ready), + }, add_taint( { - "name": "OpenBao OIDC client", - "description": "Dedicated KeyCape client for OpenBao admin login with exact localhost callback URIs.", + "name": "openbao-admin client deployed", + "description": "The code-defined KeyCape client is applied to the live keycape-config Secret and KeyCape was restarted.", "subsystem": "KeyCape", "responsibility": "identity-admin", "email": role_email(data, "role_identity_admin_email"), - "location": "clientId=openbao-admin; scopes=openid profile email groups", - "state": state_value(yes(data, "openbao_oidc_client_registered"), yes(data, "openbao_initial_config_applied")), + "location": "clientId=openbao-admin; CLI redirects localhost/127.0.0.1:8250", + "state": state_value(yes(data, "openbao_oidc_client_registered"), source_ready and yes(data, "openbao_initial_config_applied")), }, downstream_taint, ), @@ -1251,6 +1312,150 @@ def admin_identity_payloads(data: dict[str, Any]) -> list[dict[str, str]]: ] +def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + source_ready = keycape_openbao_client_source_ready() + client_deployed = yes(data, "openbao_oidc_client_registered") + auth_configured = yes(data, "openbao_oidc_auth_configured") + login_verified = yes(data, "openbao_oidc_admin_login_verified") + initial_config_applied = yes(data, "openbao_initial_config_applied") + downstream_taint = openbao_trial_taint(data, "downstream") if yes(data, "openbao_initialized") else {} + + def action( + name: str, + description: str, + status: str, + status_reason: str, + command: str, + taint: dict[str, str] | None = None, + ) -> dict[str, str]: + return add_taint( + { + "name": name, + "description": description, + "status": status, + "status_reason": status_reason, + "command": command, + }, + taint or {}, + ) + + deploy_state = "done" if client_deployed else "todo" if source_ready and initial_config_applied else "blocked" + deploy_reason = "Live KeyCape is recorded as carrying the code-defined openbao-admin client." + if deploy_state == "todo": + deploy_reason = "Operator action: apply the already-shipped KeyCape config and restart KeyCape. No new client secret is created." + if deploy_state == "blocked": + deploy_reason = "Blocked until OpenBao initial configuration exists and the KeyCape client definition is present in source." + + auth_state = "done" if auth_configured else "todo" if client_deployed else "blocked" + auth_reason = "OpenBao OIDC/JWT auth is recorded as configured." + if auth_state == "todo": + auth_reason = "Operator action: requires a root/sudo-capable OpenBao token at the hidden prompt; the token value is not recorded." + if auth_state == "blocked": + auth_reason = "Apply and confirm the live KeyCape openbao-admin client before configuring OpenBao auth." + + login_state = "done" if login_verified else "todo" if auth_configured else "blocked" + login_reason = "OIDC-backed OpenBao platform-admin login is recorded as verified." + if login_state == "todo": + login_reason = "Human verification: complete the browser MFA flow and confirm the resulting token has platform-admin policy." + if login_state == "blocked": + login_reason = "Configure OpenBao OIDC auth before testing the login path." + + deploy_command = ( + "cd sso-mfa/k8s/keycape\n" + "./create-secrets.sh\n" + "kubectl rollout restart deployment/keycape -n sso\n" + "kubectl rollout status deployment/keycape -n sso --timeout=60s\n" + "cd ..\n" + "./verify-t07.sh" + ) + oidc_config_inner = """bao auth enable -path=keycape oidc >/tmp/keycape-auth-enable.out 2>/tmp/keycape-auth-enable.err || { + if grep -q "path is already in use" /tmp/keycape-auth-enable.err; then + printf "auth/keycape already exists\\n" >&2 + else + cat /tmp/keycape-auth-enable.err >&2 + exit 1 + fi +} +bao write auth/keycape/config \\ + oidc_discovery_url="https://kc.coulomb.social" \\ + oidc_client_id="openbao-admin" \\ + oidc_client_secret="" \\ + default_role="platform-admin" +cat >/tmp/openbao-platform-admin-role.json </dev/null || true; }\n" + " trap restore_tty EXIT INT TERM\n" + " printf \"OpenBao root/sudo token: \" >&2\n" + " stty -echo\n" + " read -r BAO_TOKEN\n" + " stty echo\n" + " printf \"\\n\" >&2\n" + " export BAO_TOKEN\n" + f"{oidc_config_inner}\n" + " unset BAO_TOKEN\n" + "'" + ) + login_command = ( + "# Terminal 1: keep a local OpenBao API port open while testing.\n" + "kubectl -n openbao port-forward svc/openbao-active 8200:8200\n\n" + "# Terminal 2: run the OIDC login and verify the policy on the returned token.\n" + "export BAO_ADDR=http://127.0.0.1:8200\n" + "bao login -method=oidc -path=keycape role=platform-admin\n" + "bao token lookup" + ) + + return [ + action( + "Apply code-defined KeyCape OpenBao client", + "Deployment action for the non-secret openbao-admin client already present in source. Run this only if live KeyCape has not yet loaded the updated config.", + deploy_state, + deploy_reason, + deploy_command, + downstream_taint if yes(data, "openbao_initialized") else {}, + ), + 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.", + auth_state, + auth_reason, + configure_command, + downstream_taint if yes(data, "openbao_initialized") else {}, + ), + action( + "Verify OIDC-backed OpenBao admin login", + "Start a local port-forward, complete the KeyCape MFA browser flow, and verify the returned OpenBao token before checking the confirmation box.", + login_state, + login_reason, + login_command, + downstream_taint if yes(data, "openbao_initialized") else {}, + ), + ] + + def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: public_key = extract_age_public_key(data.get("custodian_age_public_key")) state = bootstrap_secret_state() @@ -1808,7 +2013,7 @@ def section_gate_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "key": "admin-identity", "name": "Admin Identity Integration", "status": "ok" if all(row["state"] == "ok" for row in admin_rows) else "set", - "reason": "OpenBao admin access is bound to NetKingdom OIDC claims." if all(row["state"] == "ok" for row in admin_rows) else "Configure and verify the KeyCape-backed OpenBao admin path.", + "reason": "OpenBao admin access is bound to NetKingdom OIDC claims." if all(row["state"] == "ok" for row in admin_rows) else "Run only the remaining operator cards: live KeyCape deploy, protected OpenBao auth setup, or login verification.", }, { "key": "artifacts", @@ -1874,6 +2079,7 @@ def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]: "subsystems": subsystem_payloads(merged), "integrations": integration_payloads(merged), "admin_identity_integrations": admin_identity_payloads(merged), + "admin_identity_commands": admin_identity_command_payloads(merged), "runbooks": runbook_payloads(merged), "artifacts": artifact_payloads(merged), "commands": command_payloads(merged), @@ -2731,16 +2937,17 @@ def ui_html() -> str:
5. Admin Identity Integrationnil
Loading admin identity gate.
-

This stage replaces manually minted OpenBao admin tokens as the normal path. KeyCape remains the identity issuer; OpenBao maps verified NetKingdom group claims to the existing platform-admin policy.

+

This stage replaces manually minted OpenBao admin tokens as the normal path. Development-owned client definitions are shipped in source; operator-owned cards below apply live config, use protected OpenBao prompts, or verify login.

    -
  • Register a dedicated KeyCape client such as openbao-admin with exact localhost OpenBao callback URIs and the groups scope.
  • -
  • Configure an OpenBao OIDC/JWT auth mount against https://kc.coulomb.social and bind net-kingdom-admins to platform-admin.
  • -
  • Verify platform-root can complete MFA-backed OpenBao login before root-token disposition and cleanup.
  • +
  • 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.
+
- - + +
@@ -3150,6 +3357,7 @@ def ui_html() -> str: renderRecords("subsystems-records", data.subsystems); renderRecords("integrations-records", data.integrations); renderRecords("admin-identity-records", data.admin_identity_integrations); + renderCommands("admin-identity-command-list", data.admin_identity_commands); renderRecords("runbooks-records", data.runbooks); renderRecords("artifacts-records", data.artifacts); renderCommands("command-list", data.commands); diff --git a/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md b/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md index c674d59..977921a 100644 --- a/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md +++ b/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md @@ -315,6 +315,12 @@ initial-root-token taint clears after the exposed OpenBao root token is revoked. Downstream work remains visibly tainted until derived access paths are reviewed and the compromise response is explicitly recorded complete. +**2026-05-26:** Split Admin Identity Integration into development-owned +configuration and operator-owned integration work. The `openbao-admin` KeyCape +client is now code-defined in `sso-mfa/k8s/keycape/create-secrets.sh`, while +the UI action cards only ask the operator to apply live KeyCape config, +configure OpenBao with a protected token prompt, and verify MFA-backed login. + **2026-05-24:** Stepped back from ad hoc secret rollout and added the custodian age-key bootstrap model to the control surface. The UI now records the custodian public age recipient, a derived fingerprint, and a non-secret