Review findings on the passkey reconciler, fixed:
1. HIGH - the bind was automatic and rollback did not stick. A git-declared switch,
`bind-browser-flow` in passkey-desired-state.yaml, now decides whether pass 3 may
PUT browserFlow. Default "false": build + verify the candidate (or reuse a verified
one), log WOULD BIND <alias>, never touch the binding - including after an earlier
bind, so an operator's rollback sticks, and a hand-edited bound candidate is left
alone rather than shadowed by an -r2 build. "true" binds as before. The renderer
carries the one hand-edited line through and --check refuses anything but the
quoted strings "true"/"false". --no-bind can only narrow it.
2. HIGH - the protected-subtree guard read `alias` on sub-flow rows. Keycloak 26.0.6
AuthenticationManagementResource.recurseExecutions sets displayName =
flowRef.getAlias() and flowId on a sub-flow row and never sets alias (alias is the
CONFIG alias on authenticator rows), so the protected set was only the top-level
alias. Sub-flows are now named by displayName (getFlows() lists top-level flows
only, so flowId cannot be resolved through it); a sub-flow row without one leaves
the subtree undetermined and the flow pass fail-closed. The selftest stub now
returns 26.0.6-shaped rows, and a real run is asserted to refuse writes into
browser-mfa-forms and browser-mfa-conditional-otp.
3. MEDIUM - PUT /flows/{alias}/executions was screened on the path alias only, but
updateExecutions resolves the execution realm-wide by body id. body.id is now
screened against the protected ids AND required to be in the tree this run is
building (recorded from the build's own readback); the config POST is held to the
same build scope; and the body may carry only id/requirement/priority (the same
handler renames any flow named by flowId).
4. Stale "audit-only" docs rewritten (platform-config kustomization, chainsaw test,
desired-state header and its renderer template, README) to describe what ships.
5. backoffLimit 0 (a retried deterministic build failure burns the candidate-alias
budget); the stale "Renovate does not manage image: tags" comment corrected and the
image pinned by index digest.
6. The ~160-line header duplicated in the YAML and the script is now kept once, in the
script, with a short pointer above it.
Selftest: 259 assertions (was 216). mutations.py: 40 injected regressions (was 30),
including one per finding above, all caught.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
491 lines
25 KiB
Python
491 lines
25 KiB
Python
#!/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 <alias>`, 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())
|