#!/usr/bin/env python3 # ============================================================================= # ci/render-keycloak-passkey-policy.py # # Render platform/keycloak/passkey-desired-state.yaml FROM # platform/keycloak/keycloak-realm-import.yaml. # # WHY THIS EXISTS # --------------- # The Keycloak realm import is a ONE-SHOT that fails OPEN: once it rests at # Done=True / Started=False it never re-runs. Measured on prod 2026-10-18, hours # after the passkey declaration merged: # # kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath=...status # Done=True Started=False HasErrors=False # # So the WebAuthn passwordless policy and the restructured `browser-mfa` flow that # keycloak-realm-import.yaml declares are INERT. They are in git, Flux applied # them, and they reach the realm never. platform/keycloak/passkey-reconciler.yaml # is the level-triggered loop that closes that; this renderer supplies the DESIRED # STATE it converges towards. # # THE ONE VALUE THAT IS NOT RENDERED: `bind-browser-flow`, git's switch for whether the # reconciler may BIND its verified candidate browser flow (one atomic PUT of the # realm's browserFlow). It is an operator DECISION -- "I have tested the candidate # flow in a browser; make it the login path" -- not a fact derivable from the realm # import, so it cannot be generated. It is instead CARRIED THROUGH from the committed # file: --write preserves it, --check accepts any value of it that is exactly the # string "true" or "false" and rejects everything else (an unquoted YAML true is a # bool, which a ConfigMap rejects at apply time -- failing the whole platform-config # Kustomization -- so it is refused HERE, at merge time). An absent file or key # renders as "false". Going live is therefore a one-line PR flipping that line. # # THE CONSTRAINT THAT SHAPES IT: the reconciler pod runs with # automountServiceAccountToken: false, exactly like its client-secret sibling. It # has NO Kubernetes API access at all, by design (R-SEC-02) — so it cannot read the # KeycloakRealmImport CR to learn what the realm is supposed to look like. Its input # must be MOUNTED. Hence a ConfigMap. # # THAT IS ALSO WHY THIS IS A RENDERER AND NOT A HAND-WRITTEN ConfigMap. A # hand-authored copy of the policy would be a SECOND SOURCE OF TRUTH for the RP ID, # the user-verification requirement and the whole flow tree — free to disagree with # the realm import. "Two declarations that can disagree" is the same shape as the # bug this whole effort is closing. So the ConfigMap is GENERATED from the realm # import and the generation is GATED: # conformance/tests/l0-static/keycloak-passkey-reconciler/check.sh runs this script # with --check on every merge, and the merge fails if the committed file is not what # this renderer produces. Same render==golden idiom as # ci/render-keycloak-client-urls.py, catalog/render.py and ci/render-golden.sh. # # STRINGS ARE COPIED VERBATIM, NEVER RE-DERIVED. webAuthnPolicyPasswordlessRpId is # written in the realm import as the Flux substitution token ${BASE_DOMAIN}, and it # is carried through into the ConfigMap CHARACTER FOR CHARACTER. Both files are # rendered by the SAME Flux Kustomization (./platform-config, whose # postBuild.substituteFrom reads cluster-vars), so both are substituted in the same # pass with the same value and cannot disagree about what the base domain is. This # renderer never parses, splits or reconstructs a hostname and never reads # cluster-vars — there is no second place the domain is decided. # # THE ONE THING THIS RENDERER DERIVES RATHER THAN COPIES # ----------------------------------------------------- # The enrolment required action, `webauthn-register-passwordless`. It is # deliberately NOT declared in the realm import, and the import's own header # explains at length why declaring it there would be actively DANGEROUS: Keycloak # 26.0.6 treats `requiredActions` in a realm representation as REPLACE, not merge, # so declaring one action imports exactly that action plus delete_account and skips # DefaultRequiredActions.addActions entirely — deleting CONFIGURE_TOTP, # UPDATE_PASSWORD, VERIFY_EMAIL and seven more. Losing CONFIGURE_TOTP is a hard # lock-out of every platform-admin, because auth-otp-form in the browser flow still # demands a TOTP they could then never enrol. # # So the required action cannot be copied from the import, because it is correctly # absent from it. It is instead DERIVED, as a consequence of what the import DOES # declare: # # IF the declared browser flow contains a `webauthn-authenticator-passwordless` # execution, THEN passkey logins are offered, THEREFORE the enrolment action must # be registered and enabled — and NOT a defaultAction, because the import's whole # stated posture is "additive and optional" and a defaultAction forces enrolment # on every user at next login. # # This is a derivation, not a second declaration: there is no value here that a # human could edit into disagreement with the import. If the import stops declaring # the passkey execution, this renderer stops emitting the required action (and # fails, because then the whole desired state is empty of passkey intent). # # WHAT IS DELIBERATELY NOT EXPORTED # * the TWO-FACTOR webAuthnPolicy* family. Nothing in this realm uses the # `webauthn-authenticator` two-factor authenticator; the passwordless # authenticator reads getWebAuthnPolicyPasswordless() only, so a policy written # into the two-factor family is a silent no-op. Exporting it would hand the # reconciler a write surface with no purpose. # * `clients`, `users`, `groups`, `roles`. Client secrets and client URLs are the # SIBLING reconciler's surface (platform/keycloak/client-secret-reconciler.yaml), # and users/groups/roles are nobody's — prod's real user and the media-users / # platform-admins / viewers groups must survive every run of everything. # * execution `priority`. It is consumed here to ORDER the exported executions # (Keycloak returns them in priority order), but the numbers themselves are not # exported: the reconciler compares the resulting SEQUENCE, which is what # actually determines behaviour. Exporting the integers would make a # priority renumbering that changes no ordering read as drift. # # USAGE # python3 ci/render-keycloak-passkey-policy.py # print the rendered file # python3 ci/render-keycloak-passkey-policy.py --write # write it (regenerate) # python3 ci/render-keycloak-passkey-policy.py --check # exit 1 if it has drifted # ============================================================================= from __future__ import annotations import argparse import difflib import json import sys from pathlib import Path try: import yaml except ImportError: # pragma: no cover print("FATAL: PyYAML is required (same dependency as catalog/render.py)", file=sys.stderr) raise SystemExit(1) ROOT = Path(__file__).resolve().parent.parent IMPORT_FILE = "platform/keycloak/keycloak-realm-import.yaml" OUT_FILE = "platform/keycloak/passkey-desired-state.yaml" DATA_KEY = "passkey-desired-state.json" CONFIGMAP_NAME = "keycloak-passkey-desired-state" NAMESPACE = "keycloak" # The realm keys this renderer exports, and therefore the ONLY realm keys the # reconciler may write. Every one is a webAuthnPolicyPasswordless* field. Kept in # lockstep with POLICY_FIELDS in the reconciler script; the L0 check asserts the two # tuples are identical, so they cannot drift apart silently. POLICY_STR_FIELDS = ( "webAuthnPolicyPasswordlessRpId", "webAuthnPolicyPasswordlessRpEntityName", "webAuthnPolicyPasswordlessRequireResidentKey", "webAuthnPolicyPasswordlessUserVerificationRequirement", "webAuthnPolicyPasswordlessAuthenticatorAttachment", "webAuthnPolicyPasswordlessAttestationConveyancePreference", ) POLICY_LIST_FIELDS = ( "webAuthnPolicyPasswordlessSignatureAlgorithms", "webAuthnPolicyPasswordlessAcceptableAaguids", ) POLICY_INT_FIELDS = ("webAuthnPolicyPasswordlessCreateTimeout",) POLICY_BOOL_FIELDS = ("webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister",) POLICY_FIELDS = (POLICY_STR_FIELDS + POLICY_LIST_FIELDS + POLICY_INT_FIELDS + POLICY_BOOL_FIELDS) # The field Keycloak 26.0.6 gates the WHOLE passwordless policy on # (DefaultExportImportManager.updateRealm: the policy is applied only when # RpEntityName is non-null and non-empty). A desired state without it would produce # an accepted PUT that changes nothing, so refuse to render one. RP_ENTITY_NAME = "webAuthnPolicyPasswordlessRpEntityName" # The execution whose presence is what makes passkey enrolment necessary. PASSKEY_AUTHENTICATOR = "webauthn-authenticator-passwordless" ENROL_ACTION = "webauthn-register-passwordless" BIND_KEY = "bind-browser-flow" BIND_VALUES = ("true", "false") def committed_bind(root: Path) -> str: """The operator's bind-browser-flow value from the COMMITTED file, or "false". This is the only input read from the output file, and it is validated, not trusted: anything other than the exact strings "true" / "false" is a hard failure.""" target = root / OUT_FILE if not target.is_file(): return "false" docs = [d for d in yaml.safe_load_all(target.read_text(encoding="utf-8")) if d] cm = next((d for d in docs if d.get("kind") == "ConfigMap"), None) or {} data = cm.get("data") or {} if BIND_KEY not in data: return "false" v = data[BIND_KEY] if not isinstance(v, str) or v not in BIND_VALUES: raise SystemExit( "FATAL: %s data.%s is %r. It must be exactly the QUOTED string \"true\" or " "\"false\": an unquoted true/false is a YAML boolean, which a ConfigMap rejects " "at apply time and which would fail the whole platform-config Kustomization" % (OUT_FILE, BIND_KEY, v)) return v def render(root: Path) -> str: """Return the full text of passkey-desired-state.yaml as it should be committed.""" docs = list(yaml.safe_load_all((root / IMPORT_FILE).read_text())) imp = next((d for d in docs if d and d.get("kind") == "KeycloakRealmImport"), None) if imp is None: raise SystemExit("FATAL: no KeycloakRealmImport document in %s" % IMPORT_FILE) realm = (imp.get("spec") or {}).get("realm") or {} realm_name = realm.get("realm") if not isinstance(realm_name, str) or not realm_name: raise SystemExit("FATAL: %s declares no spec.realm.realm name" % IMPORT_FILE) # ---- the passwordless policy, copied VERBATIM ---------------------------------- policy: dict[str, object] = {} for f in POLICY_STR_FIELDS: if f not in realm: continue # not declared => NOT MANAGED v = realm[f] # A bare ${BASE_DOMAIN} parses as a plain string; an int or bool here would be # a YAML quoting mistake that must not be silently stringified. if not isinstance(v, str): raise SystemExit("FATAL: %s realm field %s is not a string (got %r) — " "quote it in the realm import" % (IMPORT_FILE, f, v)) policy[f] = v for f in POLICY_LIST_FIELDS: if f not in realm: continue v = realm[f] if not isinstance(v, list) or not all(isinstance(x, str) for x in v): raise SystemExit("FATAL: %s realm field %s is not a list of strings" % (IMPORT_FILE, f)) policy[f] = list(v) # verbatim, order as authored for f in POLICY_INT_FIELDS: if f not in realm: continue v = realm[f] if not isinstance(v, int) or isinstance(v, bool): raise SystemExit("FATAL: %s realm field %s is not an integer" % (IMPORT_FILE, f)) policy[f] = v for f in POLICY_BOOL_FIELDS: if f not in realm: continue v = realm[f] if not isinstance(v, bool): raise SystemExit("FATAL: %s realm field %s is not a boolean" % (IMPORT_FILE, f)) policy[f] = v if not policy: # Fail closed. An empty policy would make the reconciler believe every field is # unmanaged and silently stop reconciling — the realm import's own fail-open. raise SystemExit("FATAL: %s declares no webAuthnPolicyPasswordless* field — " "refusing to render an empty desired state" % IMPORT_FILE) if not policy.get(RP_ENTITY_NAME): raise SystemExit( "FATAL: %s does not declare a non-empty %s. Keycloak 26.0.6 applies the " "passwordless policy ONLY when that field is set, so every realm PUT would " "be accepted and change nothing" % (IMPORT_FILE, RP_ENTITY_NAME)) # ---- the flow tree, flattened per flow and ORDERED BY PRIORITY ----------------- browser_flow = realm.get("browserFlow") if not isinstance(browser_flow, str) or not browser_flow: raise SystemExit("FATAL: %s declares no spec.realm.browserFlow — the flow could " "be perfect and still not be the one anyone logs in through" % IMPORT_FILE) raw_flows = realm.get("authenticationFlows") or [] if not isinstance(raw_flows, list) or not raw_flows: raise SystemExit("FATAL: %s declares no authenticationFlows" % IMPORT_FILE) flows: dict[str, object] = {} for fl in raw_flows: alias = fl.get("alias") if not isinstance(alias, str) or not alias: raise SystemExit("FATAL: %s has an authentication flow with no alias" % IMPORT_FILE) if alias in flows: raise SystemExit("FATAL: %s declares the flow %r twice" % (IMPORT_FILE, alias)) execs = fl.get("authenticationExecutions") or [] if not isinstance(execs, list) or not execs: raise SystemExit("FATAL: %s flow %r declares no authenticationExecutions" % (IMPORT_FILE, alias)) ordered = [] for ex in sorted(execs, key=lambda e: (e.get("priority") or 0)): is_flow = bool(ex.get("authenticatorFlow")) name = ex.get("flowAlias") if is_flow else ex.get("authenticator") if not isinstance(name, str) or not name: raise SystemExit( "FATAL: %s flow %r has an execution with neither an authenticator " "nor a flowAlias" % (IMPORT_FILE, alias)) requirement = ex.get("requirement") if not isinstance(requirement, str) or not requirement: raise SystemExit("FATAL: %s flow %r execution %r declares no requirement" % (IMPORT_FILE, alias, name)) entry: dict[str, object] = { "kind": "flow" if is_flow else "authenticator", "name": name, "requirement": requirement, } cfg = ex.get("authenticatorConfig") if cfg is not None: if not isinstance(cfg, str) or not cfg: raise SystemExit("FATAL: %s flow %r execution %r has a non-string " "authenticatorConfig" % (IMPORT_FILE, alias, name)) entry["configAlias"] = cfg ordered.append(entry) flows[alias] = {"topLevel": bool(fl.get("topLevel")), "executions": ordered} if browser_flow not in flows: raise SystemExit("FATAL: %s binds browserFlow to %r but declares no such flow" % (IMPORT_FILE, browser_flow)) # Every referenced subflow must be declared, or the reconciler's flattening walks # into a hole and the audit silently covers less than it claims. for alias, spec in flows.items(): for ex in spec["executions"]: if ex["kind"] == "flow" and ex["name"] not in flows: raise SystemExit("FATAL: %s flow %r references subflow %r, which is not " "declared" % (IMPORT_FILE, alias, ex["name"])) # ---- authenticator configs, copied verbatim ------------------------------------ configs: dict[str, dict] = {} for cfg in realm.get("authenticatorConfig") or []: alias = cfg.get("alias") if not isinstance(alias, str) or not alias: raise SystemExit("FATAL: %s has an authenticatorConfig with no alias" % IMPORT_FILE) body = cfg.get("config") or {} if not isinstance(body, dict) or not body: raise SystemExit("FATAL: %s authenticatorConfig %r has no config body" % (IMPORT_FILE, alias)) # Values are stringified because Keycloak stores authenticator config values as # strings. `negate: "false"` in the import and a stored "false" are the same # declaration, and a YAML-unquoted `false` must not become a Python bool that # then never matches. configs[alias] = {k: str(v) for k, v in sorted(body.items())} # Every configAlias a flow references must be defined, else the reconciler reports # "git declares config X but defines no key/value for it" on every run forever. for alias, spec in flows.items(): for ex in spec["executions"]: ca = ex.get("configAlias") if ca and ca not in configs: raise SystemExit( "FATAL: %s flow %r execution %r references authenticatorConfig %r, " "which is not declared" % (IMPORT_FILE, alias, ex["name"], ca)) # ---- the enrolment required action, DERIVED (see the header) ------------------- offers_passkey = any( ex["kind"] == "authenticator" and ex["name"] == PASSKEY_AUTHENTICATOR for spec in flows.values() for ex in spec["executions"]) if not offers_passkey: raise SystemExit( "FATAL: %s declares a webAuthnPolicyPasswordless* policy but no %r execution " "in any flow. The policy decides what a passkey must BE; without that " "execution nothing ever OFFERS one, so the policy is unreachable " "configuration and this desired state would be meaningless" % (IMPORT_FILE, PASSKEY_AUTHENTICATOR)) want = { "realm": realm_name, "webAuthnPolicyPasswordless": policy, # enabled: passkey enrolment must be possible, since the flow offers passkeys. # defaultAction FALSE: enrolment stays OPT-IN. True would force every user to # enrol a passkey at next login — the platform-wide lock-out the realm import's # "additive and optional" posture exists to avoid. Derived, not copied; see the # header for why the import cannot declare this itself. "requiredAction": {"alias": ENROL_ACTION, "enabled": True, "defaultAction": False}, "browserFlow": browser_flow, "flows": flows, "authenticatorConfig": configs, } body = json.dumps(want, indent=2, sort_keys=True, ensure_ascii=False) indented = "\n".join((" " + ln) if ln else "" for ln in body.splitlines()) return (HEADER + BIND_BLOCK % (BIND_KEY, committed_bind(root)) + " %s: |\n" % DATA_KEY + indented + "\n") HEADER = """--- # ============================================================================= # GENERATED FILE — DO NOT EDIT BY HAND, except the one bind-browser-flow line below. # # source: platform/keycloak/keycloak-realm-import.yaml (spec.realm) # regenerate: python3 ci/render-keycloak-passkey-policy.py --write # gated by: conformance/tests/l0-static/keycloak-passkey-reconciler # (runs this renderer with --check on every merge) # # WHAT THIS IS: the DESIRED STATE for the `homelab` realm's PASSKEY configuration — # the WebAuthn passwordless policy, the enrolment required action, and the declared # browser authentication flow tree. platform/keycloak/passkey-reconciler.yaml mounts # it and (a) converges the policy and the required action onto it, (b) builds the # declared flow tree BLUE/GREEN under a new alias and verifies it by readback, never # writing the flow users are logging in through, and (c) BINDS that verified flow as # the realm's browserFlow ONLY when `bind-browser-flow` below is "true". # # EVERYTHING HERE IS GENERATED EXCEPT ONE LINE: `bind-browser-flow`. That is an # operator decision, carried through by the renderer from this committed file (see # ci/render-keycloak-passkey-policy.py). It defaults to "false". # # WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs # once Done=True, so a realm-level field or a flow edited in git never reaches the # realm. Measured on prod 2026-10-18, hours after the passkey declaration merged: # Done=True / Started=False, with the whole passkey block inert. The reconciler is # the level-triggered loop that closes that; this file is what it reconciles # TOWARDS. # # WHY IT IS GENERATED AND NOT AUTHORED: a hand-written copy of the RP ID, the # user-verification requirement and the flow tree would be a second source of truth, # free to disagree with the realm import — the same "two declarations that can # disagree" shape as the bug being closed. It is rendered from the import and the # render is gated, so the two cannot drift. # # STRINGS ARE VERBATIM: webAuthnPolicyPasswordlessRpId is the ${BASE_DOMAIN} # substitution token exactly as the realm import writes it. Both files are rendered # by the SAME Flux Kustomization (./platform-config), so the token resolves to the # same value in both and the domain is decided in exactly one place. # # `requiredAction` is the ONE entry that is DERIVED rather than copied: the realm # import deliberately declares no `requiredActions` block, because Keycloak 26.0.6 # treats that as REPLACE and declaring one action would delete CONFIGURE_TOTP and # nine more (a hard lock-out of every platform-admin). It is derived instead from # the presence of a webauthn-authenticator-passwordless execution in the flow: # passkeys are offered, therefore enrolment must be enabled — and defaultAction is # false, so enrolment stays opt-in. # # A field NOT declared on the realm in the import is simply ABSENT here, and the # reconciler leaves it alone. Absence means UNMANAGED, never "force to a default". # ============================================================================= apiVersion: v1 kind: ConfigMap metadata: name: %s namespace: %s labels: app.kubernetes.io/name: %s app.kubernetes.io/part-of: keycloak data: """ % (CONFIGMAP_NAME, NAMESPACE, CONFIGMAP_NAME) BIND_BLOCK = """ # --------------------------------------------------------------------------- # THE BIND SWITCH -- the ONLY hand-edited line in this file. Exactly "true" or # "false", QUOTED. # # "false" the reconciler builds and verifies the candidate browser flow and # logs `WOULD BIND `, but NEVER changes the realm's browserFlow. # Whatever the realm is bound to -- including a rollback -- is left alone. # "true" it binds the verified candidate in one atomic PUT, and keeps the # realm bound to it: a binding changed by hand is changed back. # # GO LIVE: test the candidate in a browser first (platform/keycloak/README.md, # "Passkeys"), then a PR setting this to "true". # ROLL BACK: a PR setting this to "false" FIRST (or the next run rebinds), then # one write setting browserFlow back to the previous alias from the Job log. # --------------------------------------------------------------------------- %s: "%s" """ def main() -> int: ap = argparse.ArgumentParser(description=__doc__) g = ap.add_mutually_exclusive_group() g.add_argument("--write", action="store_true", help="write %s" % OUT_FILE) g.add_argument("--check", action="store_true", help="exit 1 if the committed file has drifted from the source") args = ap.parse_args() want = render(ROOT) target = ROOT / OUT_FILE if args.write: target.write_text(want, encoding="utf-8") print("wrote %s" % OUT_FILE) return 0 if args.check: if not target.is_file(): print("CHECK FAIL: %s does not exist. Run: python3 " "ci/render-keycloak-passkey-policy.py --write" % OUT_FILE, file=sys.stderr) return 1 have = target.read_text(encoding="utf-8") if have == want: print("passkey-desired-state.yaml is in sync with %s" % IMPORT_FILE) return 0 print("CHECK FAIL: %s has DRIFTED from %s — the reconciler would converge the " "realm on a passkey policy the realm import does not declare, and build " "the login flow from a tree nobody authored.\n" "Regenerate with: python3 ci/render-keycloak-passkey-policy.py --write\n" % (OUT_FILE, IMPORT_FILE), file=sys.stderr) for line in difflib.unified_diff(have.splitlines(True), want.splitlines(True), fromfile="committed " + OUT_FILE, tofile="rendered from " + IMPORT_FILE): sys.stderr.write(line) return 1 sys.stdout.write(want) return 0 if __name__ == "__main__": raise SystemExit(main())