diff --git a/sso-mfa/k8s/keycape/README.md b/sso-mfa/k8s/keycape/README.md index 8e17bec..2d35b2e 100644 --- a/sso-mfa/k8s/keycape/README.md +++ b/sso-mfa/k8s/keycape/README.md @@ -122,6 +122,21 @@ 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. +To add or refresh only the OpenBao client in a live cluster, do not decrypt the +bootstrap secret bundle and do not re-run the full secret generator. Patch the +existing live `keycape-config` Secret in place: + +```bash +cd sso-mfa/k8s/keycape +bash ./patch-openbao-client.sh +kubectl rollout restart deployment/keycape -n sso +kubectl rollout status deployment/keycape -n sso --timeout=60s +bash ./verify-openbao-client.sh +``` + +The patch script preserves existing secret values and does not print the +decoded `config.yaml` or signing key. + Example entry (public client, PKCE, for a SPA): ```yaml clients: diff --git a/sso-mfa/k8s/keycape/openbao-client-config.py b/sso-mfa/k8s/keycape/openbao-client-config.py new file mode 100644 index 0000000..b63d50e --- /dev/null +++ b/sso-mfa/k8s/keycape/openbao-client-config.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Patch or verify the KeyCape openbao-admin client in a live Secret. + +The script reads a Kubernetes Secret JSON object from stdin. It never prints the +decoded KeyCape config or private key; stdout is either a JSON merge patch for +kubectl, or a short non-secret verification message. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import sys +from typing import Any + +try: + import yaml +except ImportError as exc: # pragma: no cover - operator environment guard + raise SystemExit("PyYAML is required: install python3-yaml or run in the NetKingdom tool environment") from exc + + +OPENBAO_CLIENT = { + "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", +} + + +def load_config() -> dict[str, Any]: + secret = json.load(sys.stdin) + encoded_config = (secret.get("data") or {}).get("config.yaml") + if not encoded_config: + raise SystemExit("keycape-config Secret does not contain data.config.yaml") + try: + config_text = base64.b64decode(encoded_config).decode("utf-8") + except Exception as exc: # noqa: BLE001 - concise operator error + raise SystemExit(f"could not decode data.config.yaml: {exc}") from exc + config = yaml.safe_load(config_text) or {} + if not isinstance(config, dict): + raise SystemExit("KeyCape config.yaml must decode to a YAML mapping") + return config + + +def client_errors(config: dict[str, Any]) -> list[str]: + clients = config.get("clients") + if not isinstance(clients, list): + return ["clients must be a list"] + target = next( + (client for client in clients if isinstance(client, dict) and client.get("clientId") == OPENBAO_CLIENT["clientId"]), + None, + ) + if target is None: + return ["missing openbao-admin client"] + + errors: list[str] = [] + for key in ("displayName", "clientType"): + if target.get(key) != OPENBAO_CLIENT[key]: + errors.append(f"{key} should be {OPENBAO_CLIENT[key]!r}") + for key in ("redirectUris", "allowedScopes", "grantTypes"): + missing = sorted(set(OPENBAO_CLIENT[key]) - set(target.get(key) or [])) + if missing: + errors.append(f"{key} missing: {', '.join(missing)}") + return errors + + +def upsert_client(config: dict[str, Any]) -> dict[str, Any]: + clients = config.get("clients") + if not isinstance(clients, list): + clients = [] + config["clients"] = clients + for index, client in enumerate(clients): + if isinstance(client, dict) and client.get("clientId") == OPENBAO_CLIENT["clientId"]: + clients[index] = dict(OPENBAO_CLIENT) + return config + clients.append(dict(OPENBAO_CLIENT)) + return config + + +def render_patch(config: dict[str, Any]) -> None: + updated = upsert_client(config) + config_text = yaml.safe_dump(updated, sort_keys=False) + encoded = base64.b64encode(config_text.encode("utf-8")).decode("ascii") + json.dump({"data": {"config.yaml": encoded}}, sys.stdout, separators=(",", ":")) + sys.stdout.write("\n") + + +def verify(config: dict[str, Any]) -> None: + errors = client_errors(config) + if errors: + for error in errors: + print(f"[FAIL] {error}") + raise SystemExit(1) + print("[PASS] openbao-admin client has required CLI redirects, scopes, grant type, and public PKCE profile") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("patch", "verify")) + args = parser.parse_args() + config = load_config() + if args.mode == "patch": + render_patch(config) + else: + verify(config) + + +if __name__ == "__main__": + main() diff --git a/sso-mfa/k8s/keycape/patch-openbao-client.sh b/sso-mfa/k8s/keycape/patch-openbao-client.sh new file mode 100644 index 0000000..370ed70 --- /dev/null +++ b/sso-mfa/k8s/keycape/patch-openbao-client.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Patch the live KeyCape config Secret with the code-defined OpenBao CLI client. +# This does not require decrypted bootstrap secrets and does not print existing +# Secret values. + +set -euo pipefail + +NAMESPACE="${KEYCAPE_NAMESPACE:-sso}" +SECRET="${KEYCAPE_CONFIG_SECRET:-keycape-config}" +KUBECTL="${KUBECTL:-kubectl}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$KUBECTL" get secret "$SECRET" -n "$NAMESPACE" -o json \ + | python3 "$SCRIPT_DIR/openbao-client-config.py" patch \ + | "$KUBECTL" patch secret "$SECRET" -n "$NAMESPACE" --type merge --patch-file /dev/stdin + +echo "Patched $NAMESPACE/$SECRET with the openbao-admin client definition." diff --git a/sso-mfa/k8s/keycape/verify-openbao-client.sh b/sso-mfa/k8s/keycape/verify-openbao-client.sh new file mode 100644 index 0000000..0ab920c --- /dev/null +++ b/sso-mfa/k8s/keycape/verify-openbao-client.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Verify the live KeyCape config carries the OpenBao CLI client and KeyCape is +# serving OIDC discovery after rollout. + +set -euo pipefail + +NAMESPACE="${KEYCAPE_NAMESPACE:-sso}" +SECRET="${KEYCAPE_CONFIG_SECRET:-keycape-config}" +KUBECTL="${KUBECTL:-kubectl}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$KUBECTL" get secret "$SECRET" -n "$NAMESPACE" -o json \ + | python3 "$SCRIPT_DIR/openbao-client-config.py" verify + +KC_POD=$("$KUBECTL" get pod -n "$NAMESPACE" \ + -l app.kubernetes.io/name=keycape \ + --field-selector=status.phase=Running \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + +if [[ -z "$KC_POD" ]]; then + echo "[FAIL] no running KeyCape pod found in namespace $NAMESPACE" >&2 + exit 1 +fi + +"$KUBECTL" exec -n "$NAMESPACE" "$KC_POD" -- \ + wget -qO- "http://localhost:8080/.well-known/openid-configuration" >/dev/null + +echo "[PASS] KeyCape discovery endpoint responds from pod $KC_POD" diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index c317ac6..7ca3d67 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -1342,7 +1342,7 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str] 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." + deploy_reason = "Operator action: patch the live keycape-config Secret in place and restart KeyCape. No bootstrap secret bundle decryption is required." if deploy_state == "blocked": deploy_reason = "Blocked until OpenBao initial configuration exists and the KeyCape client definition is present in source." @@ -1361,16 +1361,14 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str] login_reason = "Configure OpenBao OIDC auth before testing the login path." keycape_dir = shlex.quote(str(KEYCAPE_OPENBAO_CLIENT_CONFIG.parent)) - k8s_dir = shlex.quote(str(REPO_ROOT / "sso-mfa/k8s")) deploy_command = ( "bash <<'NETKINGDOM_KEYCAPE_APPLY'\n" "set -euo pipefail\n" f"cd {keycape_dir}\n" - "bash ./create-secrets.sh\n" + "bash ./patch-openbao-client.sh\n" "kubectl rollout restart deployment/keycape -n sso\n" "kubectl rollout status deployment/keycape -n sso --timeout=60s\n" - f"cd {k8s_dir}\n" - "bash ./verify-t07.sh\n" + "bash ./verify-openbao-client.sh\n" "NETKINGDOM_KEYCAPE_APPLY\n" ) oidc_config_inner = """bao auth enable -path=keycape oidc >/tmp/keycape-auth-enable.out 2>/tmp/keycape-auth-enable.err || { @@ -1436,7 +1434,7 @@ rm -f /tmp/openbao-platform-admin-role.json /tmp/keycape-auth-enable.out /tmp/ke 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.", + "Deployment action for the non-secret openbao-admin client already present in source. Patches the live KeyCape Secret without decrypting the bootstrap secret bundle.", deploy_state, deploy_reason, deploy_command, 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 c9cac0d..6129e2d 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 @@ -327,6 +327,12 @@ script through `bash`, uses absolute repo paths, and wraps the sequence in a fail-fast heredoc so a failed config generation does not continue into a KeyCape restart or verification. +**2026-05-26:** Removed the KeyCape OpenBao client action's dependency on +decrypted bootstrap secrets after the operator correctly hit the absent +`sso-mfa/bootstrap/secrets/` directory. Added a focused live Secret patcher and +verifier for the `openbao-admin` client so this non-secret client addition can +be applied without decrypting the full bootstrap secret bundle. + **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