#!/usr/bin/env python3 """Patch or verify non-secret KeyCape live config requirements. 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", "redirectUris": [ "http://localhost:8250/oidc/callback", "http://127.0.0.1:8250/oidc/callback", "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback", "https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback", "https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback", ], "allowedScopes": ["openid", "profile", "email", "groups"], "grantTypes": ["authorization_code"], "clientType": "public", } RAPP_QONTO_CLIENT = { "clientId": "rapp-qonto-client", "displayName": "rapp-qonto workload", "allowedScopes": ["qonto:read"], "grantTypes": ["client_credentials"], "clientType": "confidential", "secretRef": "env:KEYCAPE_RAPP_QONTO_CLIENT_SECRET", "serviceSubject": "rapp-qonto", "tenant": "tenant:friendly:binky", "roles": ["qonto-reader"], } LLDAP_REQUIRED = { "userOU": "ou=people", "groupOU": "ou=groups", } 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], required: 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") == required["clientId"]), None, ) if target is None: return [f"missing {required['clientId']} client"] errors: list[str] = [] for key, expected in required.items(): if key in ("redirectUris", "allowedScopes", "grantTypes", "roles"): missing = sorted(set(expected) - set(target.get(key) or [])) if missing: errors.append(f"{required['clientId']}.{key} missing: {', '.join(missing)}") elif target.get(key) != expected: errors.append(f"{required['clientId']}.{key} should be {expected!r}") return errors def upsert_client(config: dict[str, Any], required: 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") == required["clientId"]: clients[index] = dict(required) return config clients.append(dict(required)) return config def lldap_errors(config: dict[str, Any]) -> list[str]: lldap = config.get("lldap") if not isinstance(lldap, dict): return ["lldap must be a mapping"] return [ f"lldap.{key} should be {expected!r}" for key, expected in LLDAP_REQUIRED.items() if lldap.get(key) != expected ] def enforce_lldap_defaults(config: dict[str, Any]) -> dict[str, Any]: lldap = config.get("lldap") if not isinstance(lldap, dict): lldap = {} config["lldap"] = lldap lldap.update(LLDAP_REQUIRED) return config def render_patch(config: dict[str, Any]) -> None: updated = enforce_lldap_defaults(upsert_client(upsert_client(config, OPENBAO_CLIENT), RAPP_QONTO_CLIENT)) 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, OPENBAO_CLIENT) + client_errors(config, RAPP_QONTO_CLIENT) + lldap_errors(config) if errors: for error in errors: print(f"[FAIL] {error}") raise SystemExit(1) print("[PASS] openbao-admin and rapp-qonto clients and LLDAP OU lookup settings are present") 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()