#!/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 copy import re import subprocess import sys from datetime import datetime, timezone 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", } # Verified against an actual signed upstream token on 2026-09-08 (KEY-WP-0013). VERIFIED_UPSTREAM_ISSUER = "https://auth.coulomb.social" class IssuerPinError(Exception): """Only fixed, non-secret reason codes may leave the contained operation.""" class UniqueLoader(yaml.SafeLoader): pass def unique_mapping(loader: Any, node: Any) -> dict[str, Any]: result = {} for key_node, value_node in node.value: key = loader.construct_object(key_node, deep=True) if not isinstance(key, str) or key in result: raise IssuerPinError("ambiguous_yaml_mapping") result[key] = loader.construct_object(value_node, deep=True) return result UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping) def issuer_document(secret: dict[str, Any]) -> tuple[str, dict[str, Any], Any]: """Parse privately; never include a parser exception or document in output.""" raw = base64.b64decode(secret['data']['config.yaml'], validate=True).decode('utf-8') if any(isinstance(t, (yaml.tokens.AnchorToken, yaml.tokens.AliasToken)) for t in yaml.scan(raw)): raise IssuerPinError("yaml_alias_or_anchor") config = yaml.load(raw, Loader=UniqueLoader) if not isinstance(config, dict) or not isinstance(config.get('authelia'), dict): raise IssuerPinError("unexpected_configuration_shape") node = yaml.compose(raw) authelia = next(v for k, v in node.value if k.value == 'authelia') if authelia.flow_style or not authelia.value: raise IssuerPinError("unsupported_authelia_mapping") return raw, config, authelia def issuer_replacement(secret: dict[str, Any]) -> tuple[str, bool, str]: raw, config, authelia = issuer_document(secret) old = config['authelia'].get('issuer') prior = 'absent' if 'issuer' not in config['authelia'] else 'matching' if old == VERIFIED_UPSTREAM_ISSUER else 'different' if old == VERIFIED_UPSTREAM_ISSUER: return secret['data']['config.yaml'], False, prior field = next((v for k, v in authelia.value if k.value == 'issuer'), None) if field is not None: if not isinstance(field, yaml.nodes.ScalarNode): raise IssuerPinError("unsupported_issuer_value") updated = raw[:field.start_mark.index] + json.dumps(VERIFIED_UPSTREAM_ISSUER) + raw[field.end_mark.index:] else: index = authelia.start_mark.index - authelia.start_mark.column indent = ' ' * authelia.start_mark.column updated = raw[:index] + indent + 'issuer: ' + json.dumps(VERIFIED_UPSTREAM_ISSUER) + '\n' + raw[index:] expected = copy.deepcopy(config) expected['authelia']['issuer'] = VERIFIED_UPSTREAM_ISSUER if yaml.load(updated, Loader=UniqueLoader) != expected: raise IssuerPinError("unexpected_configuration_change") return base64.b64encode(updated.encode()).decode('ascii'), True, prior def contained_kube(args: list[str], payload: Any = None) -> Any: # Secret JSON and patch bodies stay in process memory and child stdin. # In particular, never use -p , a shell pipeline, or inherited stderr. result = subprocess.run( ['kubectl', '--request-timeout=20s', '-n', 'sso', *args], input=None if payload is None else json.dumps(payload), capture_output=True, text=True, timeout=30, check=False, ) if result.returncode: raise IssuerPinError("kubernetes_operation_failed") return json.loads(result.stdout) def safe_metadata(obj: dict[str, Any]) -> dict[str, str]: metadata = obj['metadata'] uid, revision = metadata['uid'], metadata['resourceVersion'] if not re.fullmatch(r'[0-9a-f-]{36}', uid) or not re.fullmatch(r'[0-9]+', revision): raise IssuerPinError("unexpected_metadata_shape") return {'uid': uid, 'resource_version': revision} def issuer_live(mode: str, expected_uid: str | None = None, expected_revision: str | None = None) -> dict[str, Any]: if mode == 'issuer-pin-live' and (not expected_uid or not expected_revision): raise IssuerPinError("pin_requires_observed_uid_and_revision") before = contained_kube(['get', 'secret', 'keycape-config', '-o', 'json']) if before.get('kind') != 'Secret' or before['metadata'].get('name') != 'keycape-config' or before['metadata'].get('namespace') != 'sso': raise IssuerPinError("unexpected_target") metadata = safe_metadata(before) if mode == 'issuer-pin-live' and metadata != {'uid': expected_uid, 'resource_version': expected_revision}: raise IssuerPinError("observed_revision_changed") replacement, changed, prior = issuer_replacement(before) receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'mode': mode, 'observed_at': datetime.now(timezone.utc).isoformat(), 'target': 'sso/keycape-config', 'verified_issuer': VERIFIED_UPSTREAM_ISSUER, 'before': metadata, 'prior_pin_state': prior, 'issuer_matches': not changed, 'config_changed': False, 'secret_values_emitted': False, 'deployment_changed': False} if mode == 'issuer-check-live': return receipt if changed: patch = [ {'op': 'test', 'path': '/metadata/uid', 'value': expected_uid}, {'op': 'test', 'path': '/metadata/resourceVersion', 'value': expected_revision}, {'op': 'replace', 'path': '/data/config.yaml', 'value': replacement}, ] contained_kube(['patch', 'secret', 'keycape-config', '--type=json', '--patch-file=/dev/stdin', '-o', 'json'], patch) after = contained_kube(['get', 'secret', 'keycape-config', '-o', 'json']) expected_data = dict(before['data'], **{'config.yaml': replacement}) if after['metadata']['uid'] != metadata['uid'] or after['data'] != expected_data: raise IssuerPinError("readback_mismatch_stop_without_replaying_stale_config") receipt.update({'after': safe_metadata(after), 'issuer_matches': True, 'config_changed': changed, 'other_secret_data_unchanged': True, 'unrelated_config_bytes_preserved': True}) return receipt def issuer_main(args: Any) -> int: try: receipt = issuer_live(args.mode, args.expected_uid, args.expected_resource_version) except IssuerPinError as exc: receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'status': 'failed', 'reason': str(exc)} except Exception: # Kubernetes/YAML exceptions may contain entire response/config fragments. receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'status': 'failed', 'reason': 'contained_operation_failed'} print(json.dumps(receipt, sort_keys=True)) return 1 if receipt.get('status') == 'failed' else 0 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", "issuer-check-live", "issuer-pin-live")) parser.add_argument("--expected-uid") parser.add_argument("--expected-resource-version") args = parser.parse_args() if args.mode.startswith('issuer-'): raise SystemExit(issuer_main(args)) config = load_config() if args.mode == "patch": render_patch(config) else: verify(config) if __name__ == "__main__": main()