Patch KeyCape OpenBao client without bootstrap secrets

This commit is contained in:
2026-05-26 02:36:04 +02:00
parent 1267df148a
commit 59c924bc18
6 changed files with 185 additions and 6 deletions

View File

@@ -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:

View File

@@ -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()

View File

@@ -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."

View File

@@ -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"

View File

@@ -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,

View File

@@ -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