Files
home-network-k3s-impl/platform/keycloak/passkey-reconciler.yaml
T
jakman85andClaude Opus 5.5 6bb8af1d70
gate / gate (pull_request) Failing after 2m55s
gate / conformance (pull_request) Has been skipped
docs(decisions): handoff for the passkey reconciler review fixes
Also states in bind_browser_flow()'s docstring that it is reached only when git's
bind-browser-flow is "true".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:45:40 -05:00

3277 lines
183 KiB
YAML

---
# =============================================================================
# Keycloak PASSKEY RECONCILER (R-IDP-03, R-IDP-04)
#
# A CronJob that converges the `homelab` realm's passkey configuration onto
# passkey-desired-state.yaml (GENERATED from keycloak-realm-import.yaml, whose
# KeycloakRealmImport is a ONE-SHOT that never re-runs once Done=True):
#
# pass 1 webAuthnPolicyPasswordless* realm policy strict read-modify-write
# pass 2 the webauthn-register-passwordless action strict read-modify-write
# pass 3 the browser authentication flow BLUE/GREEN: build a candidate
# under a new alias, verify it by readback, and BIND it (one atomic PUT of
# browserFlow) ONLY when git sets `bind-browser-flow: "true"` in
# passkey-desired-state.yaml. It defaults to "false": build + verify +
# report what WOULD be bound, never touch the realm's binding.
#
# THE FULL RATIONALE IS IN ONE PLACE ONLY: the header of the script below
# (reconcile-keycloak-passkeys.py). It used to be duplicated here almost verbatim;
# two copies of a safety argument drift apart, and the copy that ships with the code
# and its --selftest is the one kept. Read it before changing anything here.
#
# CARRIED BY platform-config (dependsOn platform, wait:true), alongside the realm
# import whose failure mode it covers and the keycloak-admin-credentials Secret it
# authenticates with. Gated by conformance/tests/l0-static/keycloak-passkey-reconciler/
# (extracts this script, runs its --selftest, and proves that selftest falsifiable).
# Failures alert via KeycloakPasskeyReconcilerFailing (platform/monitoring/alerts.yaml).
# =============================================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: keycloak-passkey-reconciler
namespace: keycloak
labels:
app.kubernetes.io/name: keycloak-passkey-reconciler
app.kubernetes.io/part-of: keycloak
data:
# SINGLE SOURCE OF TRUTH for the reconciler. Do not fork it into a sidecar copy:
# the L0 gate extracts THIS block and executes its --selftest, so a second copy
# would be untested. Contains no dollar sign at all, on purpose -- this file is
# rendered by a Flux Kustomization with postBuild.substituteFrom, so an unescaped
# dollar-brace expansion here would be rewritten before any container ran (the
# trap documented at length in apps/wave-1/nextcloud.yaml).
reconcile-keycloak-passkeys.py: |
#!/usr/bin/env python3
# =============================================================================
# reconcile-keycloak-passkeys.py -- make the `homelab` realm's PASSKEY configuration
# equal what git declares, idempotently, including the browser login flow, WITHOUT
# ever mutating the flow that users are currently logging in through.
#
# WHY THIS EXISTS
# ---------------
# platform/keycloak/keycloak-realm-import.yaml declares the WebAuthn PASSWORDLESS
# policy and a restructured `browser-mfa` browser flow. That declaration CANNOT TAKE
# EFFECT. Measured on prod 2026-10-18:
#
# kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
# Done=True Started=False HasErrors=False
#
# A KeycloakRealmImport is a ONE-SHOT. Once it rests at Done=True it never re-runs, so
# every realm-level field and every flow the manifest declares AFTER the first import
# is inert -- merged into git, applied by Flux, reported Ready, and reaching the realm
# never. This is the same failure class the client-secret reconciler already closes for
# client secrets and client URLs (platform/keycloak/client-secret-reconciler.yaml,
# 2026-09-06 incident). This program closes it for the passkey configuration.
#
# THREE PASSES
# ------------
# pass 1 WEBAUTHN PASSWORDLESS POLICY strict read-modify-write of the realm.
# pass 2 THE ENROLMENT REQUIRED ACTION strict read-modify-write, ONE hard-coded
# alias.
# pass 3 THE BROWSER AUTHENTICATION FLOW BLUE/GREEN: build the target structure
# under a NEW alias and verify it. Flip the
# realm's browserFlow in ONE atomic write ONLY
# if git says bind-browser-flow: "true".
#
# PASS 3: WHY BLUE/GREEN, AND WHY THE OBVIOUS DESIGN IS WRONG
# -----------------------------------------------------------
# The declared restructure is five flows and ten executions, and the realm ALREADY
# HOLDS a `browser-mfa` flow of the OLD shape (one combined auth-username-password-form
# rather than the username/password split a passkey branch requires). The admin API
# offers no transaction: create-flow, add-subflow, add-execution, set-requirement and
# bind-config are all separate calls.
#
# THE FIRST DESIGN OF THIS FILE WAS "AUDIT ONLY, NEVER WRITE THE FLOW", on the
# reasoning that a run dying part-way through that sequence would leave a HALF-BUILT
# FLOW BOUND AS THE LOGIN PATH for the whole platform -- and this repo has had a total
# SSO outage from a realm change (2026-09-06), with the conditional-OTP sub-flow (which
# the declared structure moves ONE LEVEL DEEPER than it sits today) once throwing on
# every evaluation and 403ing every platform-admin.
#
# THAT RISK IS REAL BUT IT IS NOT INHERENT. It follows only from MUTATING THE BOUND
# FLOW. So this does not mutate the bound flow. Instead:
#
# 1. BUILD the target structure under a NEW, UNUSED alias (the declared alias plus a
# hash of the desired shape). Every multi-call mutation happens on a flow NOTHING
# IS USING. A failure at any point leaves an orphaned, unbound flow and ZERO
# user-visible effect: login keeps using the old flow, untouched.
# 2. READ THE BUILT FLOW BACK and verify it matches the desired shape -- nesting
# levels, ordering, authenticators, requirements, and the conditional-OTP config
# contents. Nothing is bound until that passes. The regression the operator fears
# becomes detectable BEFORE it can bite, not after.
# 3. BIND with ONE ATOMIC WRITE: PUT /admin/realms/<realm> setting browserFlow. One
# atomic mutation to live behaviour, not five -- and ONLY when git asks for it
# (see THE BIND SWITCH below). With the switch off, the run stops after step 2
# and logs the alias it WOULD bind.
# 4. ROLLBACK IS A PR PLUS ONE WRITE. Set bind-browser-flow back to "false" in git
# (so the reconciler stops asserting the binding), then set browserFlow back to
# the previous alias, which is printed in the run log. The old flow is NEVER
# DELETED, so it remains a working rollback target.
#
# THE BIND SWITCH, AND WHY ITS DEFAULT IS "false"
# -----------------------------------------------
# passkey-desired-state.yaml carries `bind-browser-flow`, the ONE hand-edited line in
# that otherwise generated file (the renderer carries it through; its --check rejects
# anything but the strings "true" / "false"). It is mounted beside the JSON desired
# state and read every run, so git -- not a CLI default nobody can see -- decides
# whether this program may touch the realm's binding.
#
# "false" (the default) build and verify the candidate (or reuse a verified one),
# log WOULD BIND <alias>, and NEVER PUT browserFlow. Whatever
# the realm is bound to -- including an operator's rollback --
# is left exactly as it is, every run.
# "true" bind the verified candidate in one atomic PUT, and keep
# asserting it: a binding changed by hand is changed back.
#
# Why not just bind: an automatic bind makes the first live use of an UNTESTED login
# flow a schedule tick, and with the bind always on, ROLLBACK DOES NOT STICK -- the
# next run finds the verified candidate again and rebinds it every 15 minutes, and an
# operator hotfix inside the bound flow is "corrected" by building and binding a fresh
# copy. With the switch, going live is a reviewed one-line PR made AFTER a human has
# tested the candidate flow in a browser, and rolling back is that PR reversed.
#
# THE SAFETY PROPERTY, ENFORCED STRUCTURALLY RATHER THAN BY INTENTION
# ------------------------------------------------------------------
# Blue/green needs flow-create and execution-add endpoints, which widens the
# allow-list. So the allow-list alone is no longer sufficient, and a SECOND structural
# guard sits at the same choke point:
#
# THIS PROGRAM IS INCAPABLE OF WRITING TO THE FLOW THAT IS CURRENTLY BOUND, OR TO
# ANY SUB-FLOW OR EXECUTION BENEATH IT.
#
# Before any flow write, the live browserFlow alias is fetched and its ENTIRE SUBTREE
# of sub-flow aliases and execution ids is recorded in _PROTECTED_FLOW_ALIASES /
# _PROTECTED_EXECUTION_IDS. _http() then refuses any write whose target is in those
# sets, before a socket is opened. Protecting the whole subtree matters: adding an
# execution to `browser-mfa-forms` would change live login behaviour just as surely as
# touching `browser-mfa` itself.
#
# HOW A SUB-FLOW IS NAMED IN 26.0.6 -- read from the source, not assumed. The executions
# listing (AuthenticationManagementResource.recurseExecutions, tag 26.0.6) reports a
# sub-flow row with `displayName = flowRef.getAlias()` and `flowId`, and NEVER sets
# `alias` on it; `alias` is set only on an AUTHENTICATOR row, where it is the bound
# authenticator CONFIG's alias. An earlier version of this file read `alias` for
# sub-flow rows, so the protected set held only the top-level alias and a write into
# `browser-mfa-forms` would NOT have been refused. Sub-flows are now named by
# displayName, and a sub-flow row WITHOUT one leaves the subtree undetermined, so the
# flow pass stays fail-closed rather than guessing.
#
# THE ONE WRITE WHOSE TARGET IS IN THE BODY, NOT THE PATH. PUT /flows/<alias>/executions
# (updateExecutions) resolves realm.getAuthenticationExecutionById(rep.getId()) and
# never checks that the execution belongs to <alias> -- so the path alias says nothing
# about what is written. The screen therefore also requires body["id"] to be outside
# the protected execution ids AND inside the flow tree this run is building (recorded
# from the build's own readback), and refuses any body key beyond id / requirement /
# priority: a body carrying flowId + displayName would RENAME an arbitrary flow through
# the same handler. The config-binding POST is held to the same build-tree rule.
#
# AND IT FAILS CLOSED: the protected sets start as None, meaning "not yet determined",
# and while they are None EVERY flow write is refused. So a code path that tried to
# write before discovering what is bound cannot do so. The worst a buggy or
# half-failed run can do is leave an unused flow lying around.
#
# IDEMPOTENCE IS LOAD-BEARING HERE, NOT A NICETY. A rebuild-and-reflip every fifteen
# minutes would be a permanent write loop against the IdP and would churn the realm's
# login path forever. So pass 3 does a REAL STRUCTURAL COMPARISON of the bound flow
# against the desired shape and, when they match, issues ZERO WRITES and creates
# nothing.
#
# SUB-FLOW ALIASES ARE COMPARED AS STRUCTURE, NOT AS NAMES. A sub-flow's alias is a
# label with no effect on login behaviour; what determines behaviour is the tree shape,
# the authenticators, the requirements and the bound configs. Comparing aliases would
# mean a flow this program built (whose aliases carry a hash suffix) could never be
# recognised as converged, so it would be rebuilt and reflipped forever. The same
# applies to authenticator-config aliases: the CONTENTS are compared, the label is not.
#
# THE CONDITIONAL-OTP REGRESSION, SPECIFICALLY
# --------------------------------------------
# Verification asserts, by nesting level and position: that the conditional-OTP
# sub-flow is referenced CONDITIONAL from the password branch; that its children are
# `conditional-user-role` (REQUIRED) and `auth-otp-form` (REQUIRED); and -- the part
# that actually broke -- that the bound authenticator config holds
# `condUserRole: platform-admin`. The original bug wrote the role under
# `condition.evaluated.user.role`, which is not a real property, so the role resolved
# to null, the REQUIRED conditional step threw "Invalid role name submitted: null", and
# every platform-admin got a 403. A key-level comparison of the config dict is what
# makes that detectable, so the comparison is key-level -- and because it runs BEFORE
# the bind, that realm never becomes the live login path.
#
# WHY A REALM-LEVEL PUT IS ACCEPTABLE HERE WHEN THE SIBLING RECONCILER REFUSES IT
# ------------------------------------------------------------------------------
# platform/keycloak/client-secret-reconciler.yaml deliberately refuses
# /admin/realms/<realm> and its --selftest asserts that refusal. That reconciler's
# guarantee -- prod's real user and the media-users / platform-admins / viewers groups
# survive every run -- is stated in terms of endpoints it cannot reach, so its
# allow-list is NOT widened by this work and its selftest still passes unchanged. This
# is a SEPARATE program with a SEPARATE, differently-shaped allow-list.
#
# The realm PUT was checked against the Keycloak the cluster actually runs
# (quay.io/keycloak/keycloak:26.0.6) rather than assumed:
#
# * RealmAdminResource.updateRealm is the only handler for PUT /admin/realms/<realm>.
# Its own javadoc: "Any user, roles or client information in the representation will
# be ignored. This will only update top-level attributes of the realm."
# * It delegates to DefaultExportImportManager.updateRealm, lines 725-894 at tag
# 26.0.6. That body was read in full and brace-matched: it contains NO
# rep.getUsers(), NO getClients(), NO getGroups(), NO getRoles(), NO
# getRequiredActions(), NO getAuthenticationFlows() and NO getAuthenticatorConfig()
# call. Users, groups, clients, roles, required actions, flows and authenticator
# configs are UNREACHABLE from this endpoint -- not merely "ignored by convention".
# * It DOES read rep.getBrowserFlow(). That is what makes the atomic bind in step 3
# possible. It also means the POLICY pass could rebind the login path by accident,
# so the policy write carries browserFlow back from the GET unchanged and refuses
# to proceed if it would differ. bind_browser_flow() is the ONLY function permitted
# to change it, and it is called only after verification passes.
#
# TWO 26.0.6 BEHAVIOURS THAT MAKE STRICT READ-MODIFY-WRITE MANDATORY, NOT TIDY
# ---------------------------------------------------------------------------
# 1. The passwordless policy is applied ALL-OR-NOTHING and only when RpEntityName is
# non-empty. DefaultExportImportManager.updateRealm:
# if (rep.getWebAuthnPolicyPasswordlessRpEntityName() != null
# && !rep.getWebAuthnPolicyPasswordlessRpEntityName().isEmpty())
# and getWebAuthnPolicyPasswordless(rep) then rebuilds the WHOLE policy, replacing
# every null field with a Keycloak DEFAULT. So a partial PUT carrying only the
# drifted keys would SILENTLY RESET every key it omitted, and a PUT that drops
# RpEntityName drops the entire policy without an error. Both are modelled by the
# stub in --selftest.
# 2. updateRequiredAction (AuthenticationManagementResource) likewise REPLACES the
# provider from the representation -- name, alias, priority, enabled, defaultAction
# and config all come from the rep. A two-field PUT would blank the name and reset
# the priority to 0.
#
# A THIRD, IN THE FLOW BUILDER: updateExecutions compares rep.getPriority() against the
# stored priority, and a JSON body omitting `priority` deserialises it as the int 0 --
# which would RESHUFFLE the execution order while only a requirement change was
# intended. So every requirement write carries the execution's current priority back.
#
# WHY THE ENROLMENT ACTION IS ONE HARD-CODED ALIAS IN THE ALLOW-LIST
# -----------------------------------------------------------------
# The realm import's own header explains at length why `requiredActions` must NOT be
# declared in the import: 26.0.6 treats it as REPLACE, not merge, so declaring one
# action deletes the other ten -- including CONFIGURE_TOTP, without which a
# platform-admin can never enrol the TOTP that auth-otp-form still demands. That is a
# lock-out delivered by a change meant to ADD a login method.
#
# The ADMIN API does not share that hazard: PUT required-actions/<alias> touches exactly
# one provider. But this program is not trusted with the general case. The allow-list
# names the single alias `webauthn-register-passwordless` LITERALLY, so disabling
# CONFIGURE_TOTP is refused before a socket exists. The sibling defaults are READ and
# their absence is reported as the import-REPLACE damage -- audited, never repaired.
#
# NOTHING IS EVER DELETED. There is no DELETE method anywhere in the allow-list. A
# superseded flow is left unbound and harmless, because it is the rollback target.
#
# SAFETY PROPERTIES, each enforced in code and proved by --selftest:
# 1. compare-then-write: a converged realm issues ZERO writes and builds nothing.
# 2. idempotent: a second run over a converged realm issues ZERO writes.
# 3. THE BOUND FLOW IS NEVER WRITTEN -- not it, not its sub-flows, not its
# executions. Refused in _http() before a socket, and fail-closed until the
# binding is known.
# 4. the required-action write can reach exactly ONE alias, named literally.
# 5. the realm policy PUT never changes browserFlow; only bind_browser_flow() may,
# only after verification passes, and only when git sets bind-browser-flow "true".
# 6. a flow is BOUND only after a readback proves it matches the desired shape.
# 7. no secret is ever printed, in any mode, on any path.
# 8. fail closed: an unreadable or malformed desired state writes NOTHING.
# 9. nothing is ever deleted.
# 10. the three passes are INDEPENDENT -- a failure in one does not suppress the
# others.
# 11. with bind-browser-flow "false", NO PUT ever changes browserFlow -- including
# after an earlier bind, so an operator's rollback sticks.
#
# PROOF: --selftest drives these real code paths against an in-process stub Keycloak
# whose executions listing is shaped as 26.0.6's recurseExecutions shapes it. The L0
# gate EXTRACTS this script from the ConfigMap and runs it, and mutations.py proves the
# selftest can FAIL: conformance/tests/l0-static/keycloak-passkey-reconciler/.
#
# MODES
# (default) reconcile: compare, and write only what differs. Whether pass 3 may
# bind is decided by git (bind-browser-flow), NOT by a flag.
# --audit READ-ONLY throughout. Report drift and exit 1 if anything differs.
# Builds nothing and binds nothing.
# --no-bind for a hand-run Job only: never bind, even if git says "true". It can
# only NARROW what git allows; nothing can widen it.
# --selftest the offline behavioural proof, against an in-process stub Keycloak.
# No cluster; loopback only. This is what the L0 static gate runs, so
# the code the gate proves is the code that ships.
#
# NOT ONE DOLLAR SIGN IN THIS FILE, DELIBERATELY -- not even a regex end-anchor (hence
# re.fullmatch throughout), and not even inside these comments. It ships inside a
# ConfigMap that the `platform-config` Flux Kustomization renders with
# postBuild.substituteFrom, so Flux envsubst rewrites this text before any container
# runs: an unescaped dollar-brace expansion would be silently emptied at reconcile time.
# That is the trap that made apps/wave-1/nextcloud.yaml's OIDC guard dead code for
# months. Python needs no dollar signs, so the hazard is avoided STRUCTURALLY rather
# than escaped, and the character is banned outright by the L0 check so no future edit
# has to reason about escaping.
# =============================================================================
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
# The DESIRED STATE: generated from platform/keycloak/keycloak-realm-import.yaml by
# ci/render-keycloak-passkey-policy.py, shipped as a ConfigMap, mounted read-only.
# Mounted rather than read from the Kubernetes API for the same reason as the sibling
# reconciler: this pod carries NO ServiceAccount token, so it has no Kubernetes API
# access to read the KeycloakRealmImport CR with.
STATE_FILE = os.environ.get("PASSKEY_STATE_FILE",
"/etc/keycloak-passkey/passkey-desired-state.json")
# THE BIND SWITCH: the second key of the same desired-state ConfigMap, so the same mount.
# Exactly "true" or "false" (whitespace-trimmed); anything else is a FATAL input error.
# The renderer's --check enforces the same, so a typo is caught at merge time.
BIND_FILE = os.environ.get("PASSKEY_BIND_FILE",
"/etc/keycloak-passkey/bind-browser-flow")
TIMEOUT = 20
READY_ATTEMPTS = int(os.environ.get("READY_ATTEMPTS", "24"))
READY_SLEEP = float(os.environ.get("READY_SLEEP", "5"))
# ---- pass 1: the ONLY realm keys this program may write ------------------------------
# Every one of them is a webAuthnPolicyPasswordless* field. The two-factor
# webAuthnPolicy* family is deliberately NOT here: nothing in this realm uses the
# `webauthn-authenticator` two-factor authenticator, and a policy written into the wrong
# family is a silent no-op (WebAuthnPasswordlessAuthenticator.getWebAuthnPolicy reads
# getWebAuthnPolicyPasswordless only). A desired state naming anything outside this
# tuple is a hard error, never forwarded to Keycloak as a realm field.
POLICY_STR_FIELDS = (
"webAuthnPolicyPasswordlessRpId",
"webAuthnPolicyPasswordlessRpEntityName",
"webAuthnPolicyPasswordlessRequireResidentKey",
"webAuthnPolicyPasswordlessUserVerificationRequirement",
"webAuthnPolicyPasswordlessAuthenticatorAttachment",
"webAuthnPolicyPasswordlessAttestationConveyancePreference",
)
# ORDER IS MEANINGFUL for signature algorithms -- it is a preference list handed to the
# authenticator, so ES256-then-RS256 is not the same declaration as RS256-then-ES256.
# Compared exactly.
POLICY_ORDERED_LIST_FIELDS = ("webAuthnPolicyPasswordlessSignatureAlgorithms",)
# ORDER IS NOT MEANINGFUL for an AAGUID allow-list (it is a set membership test), and an
# order-sensitive comparison of a list Keycloak may reorder would rewrite the realm on
# every run forever while reporting success -- the sibling reconciler's measured
# redirectUris trap. Compared sorted; sorted() not set(), so a duplicate is still drift.
POLICY_UNORDERED_LIST_FIELDS = ("webAuthnPolicyPasswordlessAcceptableAaguids",)
POLICY_INT_FIELDS = ("webAuthnPolicyPasswordlessCreateTimeout",)
POLICY_BOOL_FIELDS = ("webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister",)
POLICY_FIELDS = (POLICY_STR_FIELDS + POLICY_ORDERED_LIST_FIELDS
+ POLICY_UNORDERED_LIST_FIELDS + POLICY_INT_FIELDS
+ POLICY_BOOL_FIELDS)
# The field 26.0.6 gates the WHOLE passwordless policy on. A PUT that omits it or sends
# it empty drops the policy silently, so the write path refuses to proceed without it.
RP_ENTITY_NAME = "webAuthnPolicyPasswordlessRpEntityName"
# The realm key that decides which flow users log in through. The policy pass carries it
# back unchanged and refuses to alter it; bind_browser_flow() is the only writer.
BROWSER_FLOW = "browserFlow"
# ---- pass 2: the one required action, and the siblings that are only AUDITED ---------
ENROL_ACTION = "webauthn-register-passwordless"
# The only two fields of it this program compares or writes. `priority`, `name` and
# `config` are carried back verbatim from the GET (updateRequiredAction replaces them
# from the representation, so omitting them would blank them).
ACTION_FIELDS = ("enabled", "defaultAction")
# Default required actions whose DISAPPEARANCE is the signature of someone declaring a
# `requiredActions` block in the realm import -- 26.0.6 imports exactly the declared
# ones plus delete_account and skips DefaultRequiredActions.addActions entirely. Losing
# CONFIGURE_TOTP is a hard lock-out of every platform-admin, because auth-otp-form in
# the flow below still demands a TOTP they could then never enrol. Audited only: no
# write to these aliases is reachable.
GUARDED_ACTIONS = ("CONFIGURE_TOTP", "UPDATE_PASSWORD", "delete_credential")
# ---- pass 3: flow construction ------------------------------------------------------
# Keycloak's provider id for a plain (non-form) sub-flow. The declared tree uses only
# basic-flow; a form-flow would additionally need a `provider`, which this program does
# not support and the renderer does not emit.
BASIC_FLOW = "basic-flow"
# How many distinct candidate aliases to try before giving up. A crashed earlier run can
# leave a half-built flow under the primary candidate alias; rather than being stuck on
# it forever (it can never be deleted -- nothing here deletes), move to the next
# candidate. Bounded so a persistent build failure cannot create unbounded flow sprawl.
MAX_BUILD_ATTEMPTS = 5
# =============================================================================
# ENDPOINT ALLOW-LIST (safety properties 3, 4, 9). METHOD-AWARE, unlike the sibling
# reconciler's path-only list. Every entry is fullmatch-ed against the (method, path) of
# every request _http() is asked to make; anything else raises before a socket exists.
#
# READ THE ABSENCES. Not present, therefore unreachable:
# DELETE anything at all -- no flow, execution, config, required action or realm can
# be deleted by this program. A superseded flow is the rollback target.
# PUT /admin/realms/<realm>/authentication/executions/<id> (single-execution
# update, including raise-priority/lower-priority siblings)
# PUT /admin/realms/<realm>/authentication/config/<id> (edit a config)
# PUT /admin/realms/<realm>/authentication/flows/<id> (rename a flow)
# POST /admin/realms/<realm>/authentication/register-required-action
# PUT .../required-actions/<any other alias> e.g. CONFIGURE_TOTP
# anything under /users, /groups, /roles, /clients
#
# AND NOTE WHAT THE ALLOW-LIST ALONE CANNOT EXPRESS: the write entries below are
# alias-parameterised, so the allow-list cannot say "but not the bound flow". That is
# what the _PROTECTED_* guard immediately after it is for. Both must pass.
# =============================================================================
_ALLOWED = (
("POST", r"/realms/master/protocol/openid-connect/token"),
# pass 1 + the atomic bind in pass 3: read-modify-write of the realm's top-level
# attributes.
("GET", r"/admin/realms/[^/]+"),
("PUT", r"/admin/realms/[^/]+"),
# pass 2: the enrolment action. The alias is LITERAL in the PUT and the single GET,
# so no other required action is writable. The list endpoint is read-only.
("GET", r"/admin/realms/[^/]+/authentication/required-actions"),
("GET", r"/admin/realms/[^/]+/authentication/required-actions/"
r"webauthn-register-passwordless"),
("PUT", r"/admin/realms/[^/]+/authentication/required-actions/"
r"webauthn-register-passwordless"),
# pass 3 reads.
("GET", r"/admin/realms/[^/]+/authentication/flows"),
("GET", r"/admin/realms/[^/]+/authentication/flows/[^/]+/executions"),
("GET", r"/admin/realms/[^/]+/authentication/config/[^/]+"),
# pass 3 BUILD writes. Every one of these targets a flow alias or execution id, and
# every one is additionally screened by the _PROTECTED_* guard so it can never
# reach the bound flow's subtree.
("POST", r"/admin/realms/[^/]+/authentication/flows"),
("POST", r"/admin/realms/[^/]+/authentication/flows/[^/]+/executions/flow"),
("POST", r"/admin/realms/[^/]+/authentication/flows/[^/]+/executions/execution"),
("PUT", r"/admin/realms/[^/]+/authentication/flows/[^/]+/executions"),
("POST", r"/admin/realms/[^/]+/authentication/executions/[^/]+/config"),
)
_WRITE_METHODS = ("POST", "PUT", "PATCH", "DELETE")
# =============================================================================
# THE PROTECTED SUBTREE (safety property 3) -- the guard the allow-list cannot express.
#
# None means NOT YET DETERMINED, and while either is None every flow write is REFUSED.
# That is the fail-closed half: a code path that tried to build before discovering what
# is bound cannot do so. reconcile_flow() populates them from the live browserFlow's
# whole subtree before it builds anything.
# =============================================================================
_PROTECTED_FLOW_ALIASES = None
_PROTECTED_EXECUTION_IDS = None
# The execution ids of the flow tree THIS RUN is building, recorded from the build's own
# readback. None means no build is in progress, and then every execution-id-addressed
# write (the requirement PUT, the config POST) is REFUSED. A set membership test on the
# id is the only way to scope those writes: the requirement PUT names a flow alias in its
# path, but Keycloak ignores it when resolving the execution (see _screen_write).
_BUILD_EXECUTION_IDS = None
# The only keys a requirement write may carry. updateExecutions ALSO renames a flow --
# any flow, by rep.getFlowId() -- when the body carries authenticationFlow, flowId and
# displayName, so a body is held to exactly what the builder needs.
_EXEC_UPDATE_KEYS = frozenset(("id", "requirement", "priority"))
# Paths whose write targets must be screened, and how to pull the target out of them.
_FLOW_WRITE_PATH = re.compile(
r"/admin/realms/[^/]+/authentication/flows/([^/]+)(?:/executions(?:/flow|/execution)?)?")
_EXEC_CONFIG_PATH = re.compile(
r"/admin/realms/[^/]+/authentication/executions/([^/]+)/config")
# POST /flows carries NO alias in the path -- the alias is in the BODY. It therefore has
# to be screened separately, or it escapes the guard entirely. (Keycloak would 409 on a
# duplicate alias anyway, but "the API happens to reject it" is not a safety property;
# this is.)
_FLOW_CREATE_PATH = re.compile(r"/admin/realms/[^/]+/authentication/flows")
# PUT /flows/<alias>/executions -- the requirement write. Its TARGET is body["id"].
_EXEC_UPDATE_PATH = re.compile(
r"/admin/realms/[^/]+/authentication/flows/[^/]+/executions")
def _set_protected(aliases, execution_ids):
"""Record the bound flow's whole subtree as unwritable. Called once per run."""
global _PROTECTED_FLOW_ALIASES, _PROTECTED_EXECUTION_IDS
_PROTECTED_FLOW_ALIASES = frozenset(aliases)
_PROTECTED_EXECUTION_IDS = frozenset(execution_ids)
def _reset_protected():
"""Back to NOT DETERMINED, so every flow write is refused again."""
global _PROTECTED_FLOW_ALIASES, _PROTECTED_EXECUTION_IDS, _BUILD_EXECUTION_IDS
_PROTECTED_FLOW_ALIASES = None
_PROTECTED_EXECUTION_IDS = None
_BUILD_EXECUTION_IDS = None
def _set_build_scope(execution_ids):
"""Record the execution ids of the tree being built (from its own readback). Only
these may be targeted by an execution-id-addressed write. None closes the scope."""
global _BUILD_EXECUTION_IDS
_BUILD_EXECUTION_IDS = (None if execution_ids is None
else frozenset(i for i in execution_ids if i))
class Refused(RuntimeError):
"""A write this program must never make was requested. Never expected normally."""
def _screen_write(method, path, body=None):
"""Raise Refused if this write targets the bound flow's subtree, or if what is bound
has not been established yet.
This is the structural expression of "the reconciler is incapable of writing to the
flow that is currently bound". The allow-list cannot say it, because its write
entries are alias-parameterised; this can, because it compares the alias in the path
against the live binding's subtree.
"""
if method not in _WRITE_METHODS:
return
if _FLOW_CREATE_PATH.fullmatch(path):
# Creating a top-level flow. The alias is in the body, so screen that.
target = (body or {}).get("alias")
protected, what = _PROTECTED_FLOW_ALIASES, "flow"
if protected is None:
raise Refused(
"refusing %s %s: the currently BOUND browser flow has not been "
"determined yet, so this program cannot prove the write would miss it. "
"Flow writes are fail-closed until the binding is known"
% (method, path))
if target in protected:
raise Refused(
"refusing %s %s: flow %r is part of the CURRENTLY BOUND browser flow"
% (method, path, target))
return
# Every write below targets (a) a flow by the alias in its path, and/or (b) an
# execution by id -- in the path for the config POST, in the BODY for the
# requirement PUT. Both kinds are screened; one request can carry both.
targets = []
m = _FLOW_WRITE_PATH.fullmatch(path)
if m is not None:
targets.append(("flow", urllib.parse.unquote(m.group(1))))
if method == "PUT" and _EXEC_UPDATE_PATH.fullmatch(path):
# updateExecutions (26.0.6) resolves realm.getAuthenticationExecutionById(
# rep.getId()) and NEVER checks the execution belongs to the path's flow, so the
# path alias alone proves nothing about what is written. Screen the body.
extra = sorted(set(body or {}) - _EXEC_UPDATE_KEYS)
if extra:
raise Refused(
"refusing %s %s: the body carries %s. Only id / requirement / priority "
"may be sent -- updateExecutions also RENAMES any flow named by flowId "
"when a body carries authenticationFlow + flowId + displayName"
% (method, path, ", ".join(extra)))
targets.append(("execution", (body or {}).get("id")))
m2 = _EXEC_CONFIG_PATH.fullmatch(path)
if m2 is not None:
targets.append(("execution", urllib.parse.unquote(m2.group(1))))
if not targets:
return # not a flow write (realm PUT, action PUT, token)
for what, target in targets:
protected = (_PROTECTED_FLOW_ALIASES if what == "flow"
else _PROTECTED_EXECUTION_IDS)
if protected is None:
raise Refused(
"refusing %s %s: the currently BOUND browser flow has not been "
"determined yet, so this program cannot prove the write would miss it. "
"Flow writes are fail-closed until the binding is known"
% (method, path))
if target in protected:
raise Refused(
"refusing %s %s: %s %r is part of the CURRENTLY BOUND browser flow. This "
"program builds a NEW flow and binds it atomically; it never mutates the "
"flow users are logging in through, because a half-finished mutation "
"there is a total SSO outage" % (method, path, what, target))
if what == "execution" and (_BUILD_EXECUTION_IDS is None
or target not in _BUILD_EXECUTION_IDS):
raise Refused(
"refusing %s %s: execution %r is not part of the flow tree this run is "
"building. An execution-addressed write may only reach the candidate "
"flow's own executions, read back from the build itself"
% (method, path, target))
def _http(method, base, path, query=None, headers=None, form=None, body=None):
"""Return (status, text). Refuses anything outside the allow-list, and any write
that would touch the bound flow's subtree."""
if not any(m == method and re.fullmatch(p, path) for m, p in _ALLOWED):
raise Refused("endpoint not on the allow-list: %s %s" % (method, path))
_screen_write(method, path, body if isinstance(body, dict) else None)
url = base.rstrip("/") + path
if query:
url = url + "?" + urllib.parse.urlencode(query)
hdrs = dict(headers or {})
data = None
if form is not None:
data = urllib.parse.urlencode(form).encode()
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
elif body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
# =============================================================================
# DESIRED STATE
# =============================================================================
def declared(path):
"""The desired state, validated STRICTLY. Raises on anything unexpected.
This file decides what gets written into the IdP, so a malformed one must stop the
run dead rather than be half-understood. In particular an unknown policy key must
NOT be forwarded to Keycloak as a realm field -- that would be an arbitrary
realm-attribute write through a file edit, defeating the point of a narrow write
surface.
"""
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh)
if not isinstance(raw, dict) or not raw:
raise ValueError("%s does not hold a non-empty JSON object" % path)
policy = raw.get("webAuthnPolicyPasswordless")
if not isinstance(policy, dict) or not policy:
raise ValueError("%s declares no webAuthnPolicyPasswordless object" % path)
for key, value in policy.items():
if key not in POLICY_FIELDS:
raise ValueError(
"%s: policy key %r is not one this reconciler is allowed to write. "
"Writable keys are: %s" % (path, key, ", ".join(POLICY_FIELDS)))
if key in POLICY_ORDERED_LIST_FIELDS or key in POLICY_UNORDERED_LIST_FIELDS:
if not isinstance(value, list) or not all(isinstance(x, str)
for x in value):
raise ValueError("%s: policy key %s must be a list of strings"
% (path, key))
elif key in POLICY_INT_FIELDS:
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError("%s: policy key %s must be an integer" % (path, key))
elif key in POLICY_BOOL_FIELDS:
if not isinstance(value, bool):
raise ValueError("%s: policy key %s must be a boolean" % (path, key))
else:
if not isinstance(value, str):
raise ValueError("%s: policy key %s must be a string" % (path, key))
# The 26.0.6 all-or-nothing guard: without a non-empty RpEntityName in the PUT body
# the realm silently keeps its old policy. A desired state that cannot supply one is
# unusable, so refuse it here rather than discover it as a no-op write later.
if not policy.get(RP_ENTITY_NAME):
raise ValueError(
"%s: %s is absent or empty, so this DESIRED STATE IS UNUSABLE. Keycloak "
"26.0.6 applies the passwordless policy ONLY when this field is non-empty, "
"so the whole policy would be dropped silently" % (path, RP_ENTITY_NAME))
action = raw.get("requiredAction")
if not isinstance(action, dict):
raise ValueError("%s declares no requiredAction object" % path)
if action.get("alias") != ENROL_ACTION:
raise ValueError(
"%s: requiredAction.alias is %r, but this reconciler may only write %r -- "
"the allow-list names that alias literally, so any other value is "
"unreachable by construction"
% (path, action.get("alias"), ENROL_ACTION))
for key in ACTION_FIELDS:
if not isinstance(action.get(key), bool):
raise ValueError("%s: requiredAction.%s must be a boolean" % (path, key))
flow = raw.get("browserFlow")
if not isinstance(flow, str) or not flow:
raise ValueError("%s declares no browserFlow alias" % path)
flows = raw.get("flows")
if not isinstance(flows, dict) or flow not in flows:
raise ValueError("%s: flows does not define the bound browser flow %r"
% (path, flow))
for alias, spec in flows.items():
if not isinstance(spec, dict) or not isinstance(spec.get("executions"), list):
raise ValueError("%s: flow %r has no executions list" % (path, alias))
for ex in spec["executions"]:
if not isinstance(ex, dict) or ex.get("kind") not in ("authenticator",
"flow"):
raise ValueError("%s: flow %r has an execution with no valid kind"
% (path, alias))
if not isinstance(ex.get("name"), str) or not ex["name"]:
raise ValueError("%s: flow %r has an execution with no name"
% (path, alias))
if not isinstance(ex.get("requirement"), str) or not ex["requirement"]:
raise ValueError("%s: flow %r execution %r has no requirement"
% (path, alias, ex.get("name")))
if ex["kind"] == "flow" and ex["name"] not in flows:
raise ValueError("%s: flow %r references subflow %r, which is not "
"defined" % (path, alias, ex["name"]))
configs = raw.get("authenticatorConfig") or {}
if not isinstance(configs, dict):
raise ValueError("%s: authenticatorConfig must be an object" % path)
for alias, spec in flows.items():
for ex in spec["executions"]:
ca = ex.get("configAlias")
if ca and ca not in configs:
raise ValueError(
"%s: flow %r execution %r references authenticatorConfig %r, which "
"is not declared" % (path, alias, ex["name"], ca))
return raw
def expected_shape(flows, root):
"""Flatten the declared flow tree into the (level, kind, name, requirement, config)
sequence Keycloak's executions listing returns for a top-level flow.
GET /authentication/flows/<alias>/executions returns a FLAT, depth-first list in
which every nested execution carries its own `level`. Flattening the declaration the
same way lets the entire five-flow, ten-execution tree be compared against ONE
response, element by element -- which is what makes a divergence reportable as "at
level 3, position 1, git says X and the realm has Y" rather than "the flow differs".
A SUB-FLOW'S NAME IS SET TO "" DELIBERATELY. A sub-flow alias is a label with no
effect on login behaviour, and the flows this program builds carry a hash suffix on
every alias -- so comparing aliases would mean a flow it built could never be
recognised as converged, and it would be rebuilt and reflipped forever. What is
compared is the tree SHAPE, the authenticators, and the requirements. The declared
alias is still carried as the 6th member, for use in build mapping and in reports.
A cycle in the declaration would recurse forever; `seen` makes that a hard error.
"""
out = []
def walk(alias, level, seen):
if alias in seen:
raise ValueError("flow %r is cyclic in the desired state" % alias)
for ex in flows[alias]["executions"]:
is_flow = ex["kind"] == "flow"
out.append((level, ex["kind"], "" if is_flow else ex["name"],
ex["requirement"], ex.get("configAlias") or "", ex["name"]))
if is_flow:
walk(ex["name"], level + 1, seen | {alias})
walk(root, 0, frozenset())
return out
def shape_digest(state):
"""A short, stable hash of the DESIRED shape, used to name the flow this program
builds. Two properties matter:
* it changes when the declaration changes, so a new desired shape gets a new,
unused alias rather than colliding with the previous build;
* it does NOT change between runs with the same declaration, so a completed but
unbound build from an earlier run is recognised and REUSED rather than rebuilt.
It covers the flattened shape, the declared aliases, and the authenticator config
contents -- everything a build would produce.
"""
shape = expected_shape(state["flows"], state["browserFlow"])
payload = json.dumps(
{"shape": shape, "configs": state.get("authenticatorConfig") or {}},
sort_keys=True, ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:10]
# =============================================================================
# AUTH
# =============================================================================
def admin_token(base, user, password, log):
"""Bearer token for admin-cli in the master realm, with a bounded readiness wait."""
last = ""
for attempt in range(1, READY_ATTEMPTS + 1):
try:
st, text = _http(
"POST", base, "/realms/master/protocol/openid-connect/token",
form={"client_id": "admin-cli", "grant_type": "password",
"username": user, "password": password})
except OSError as e:
st, text = 0, "connection error (%s)" % e.__class__.__name__
if st == 200:
token = json.loads(text).get("access_token")
if token:
return token
last = "http 200 but no access_token in the response"
else:
# Deliberately do NOT echo the response body: the request that produced it
# carried the admin password, and keeping that class of mistake impossible
# by habit is cheaper than reviewing it.
last = "http %s" % st
log("waiting for the Keycloak admin API (attempt %d/%d): %s"
% (attempt, READY_ATTEMPTS, last))
if attempt < READY_ATTEMPTS:
time.sleep(READY_SLEEP)
raise RuntimeError("Keycloak admin API not usable after %d attempt(s): %s"
% (READY_ATTEMPTS, last))
# =============================================================================
# PASS 1 -- the WebAuthn passwordless policy
# =============================================================================
def policy_drift(realm_rep, wanted):
"""{key: (stored, declared)} for every declared policy key that differs.
A key ABSENT from `wanted` is UNMANAGED and never inspected. Absence must mean
"leave it alone", never "force it to a default" -- the sibling reconciler's
hard-won rule, and it matters more here because Keycloak will happily report a
field it populated itself.
"""
drift = {}
for key in sorted(wanted):
declared_value = wanted[key]
if key in POLICY_UNORDERED_LIST_FIELDS:
stored = realm_rep.get(key)
stored = sorted(stored) if isinstance(stored, list) else []
if stored != sorted(declared_value):
drift[key] = (stored, sorted(declared_value))
elif key in POLICY_ORDERED_LIST_FIELDS:
stored = realm_rep.get(key)
stored = list(stored) if isinstance(stored, list) else []
if stored != list(declared_value):
drift[key] = (stored, list(declared_value))
elif key in POLICY_BOOL_FIELDS:
stored = realm_rep.get(key)
if not isinstance(stored, bool):
stored = False
if stored != declared_value:
drift[key] = (stored, declared_value)
elif key in POLICY_INT_FIELDS:
stored = realm_rep.get(key)
if not isinstance(stored, int) or isinstance(stored, bool):
stored = 0
if stored != declared_value:
drift[key] = (stored, declared_value)
else:
stored = realm_rep.get(key) or ""
if not isinstance(stored, str):
stored = ""
if stored != declared_value:
drift[key] = (stored, declared_value)
return drift
def policy_write_body(realm_rep, wanted, drift):
"""The PUT body: the LIVE representation with ONLY the drifted policy keys changed.
STRICT READ-MODIFY-WRITE, for two measured 26.0.6 reasons (see the header):
getWebAuthnPolicyPasswordless rebuilds the entire policy from the representation and
substitutes a Keycloak default for every null field, so a partial body resets the
keys it omits; and the policy is applied only when RpEntityName is non-empty, so a
body missing that field is a silent no-op.
TWO HARD REFUSALS, not warnings:
* a body whose browserFlow differs from the live one would REBIND the realm's
login path. updateRealm reads rep.getBrowserFlow(), so this is genuinely in
reach. Rebinding is bind_browser_flow()'s job and happens only after a built
flow has been verified; the POLICY pass must never do it as a side effect.
* a body without a non-empty RpEntityName would drop the whole policy and report
204, i.e. report success while converging nothing.
"""
body = dict(realm_rep)
for key in drift:
body[key] = wanted[key]
if body.get(BROWSER_FLOW) != realm_rep.get(BROWSER_FLOW):
raise RuntimeError(
"refusing to PUT: the body would change %s from %r to %r. The policy pass "
"never rebinds the browser flow -- only bind_browser_flow() may, and only "
"after the new flow has been built and verified"
% (BROWSER_FLOW, realm_rep.get(BROWSER_FLOW), body.get(BROWSER_FLOW)))
if not body.get(RP_ENTITY_NAME):
raise RuntimeError(
"refusing to PUT: the body carries no non-empty %s, and Keycloak 26.0.6 "
"applies the passwordless policy only when that field is set -- the write "
"would be accepted and change nothing" % RP_ENTITY_NAME)
return body
def reconcile_policy(base, realm, token, wanted, audit, log):
"""Return (changed, problems). Writes only when audit is False."""
hdrs = {"Authorization": "Bearer " + token}
problems = []
st, text = _http("GET", base, "/admin/realms/%s" % realm, headers=hdrs)
if st != 200:
return 0, ["policy: could not read the realm representation (http %s)" % st]
realm_rep = json.loads(text)
if not isinstance(realm_rep, dict) or not realm_rep:
return 0, ["policy: the realm representation is not a JSON object"]
drift = policy_drift(realm_rep, wanted)
if not drift:
log("policy ok (all %d declared key(s) already correct, no write)"
% len(wanted))
return 0, problems
for key in sorted(drift):
stored, declared_value = drift[key]
# Policy values are not secrets -- an RP ID and a user-verification setting are
# public configuration, and naming the exact before/after is what makes a
# passkey-breaking change auditable from the Job log.
line = "%s DRIFT -- realm has %r, git declares %r" % (key, stored,
declared_value)
if audit:
problems.append("policy: " + line)
else:
log("policy " + line)
if audit:
return 0, problems
try:
body = policy_write_body(realm_rep, wanted, drift)
except RuntimeError as e:
return 0, ["policy: %s" % e]
st, text = _http("PUT", base, "/admin/realms/%s" % realm, headers=hdrs, body=body)
if not 200 <= st < 300:
return 0, ["policy: PUT failed (http %s) %s" % (st, text[:160])]
# Readback through the same allow-listed GET. A 204 says the request parsed, not
# that the realm converged -- and "accepted but not applied" is exactly what the
# RpEntityName guard does when it is missed.
st, text = _http("GET", base, "/admin/realms/%s" % realm, headers=hdrs)
after = json.loads(text) if st == 200 else {}
if not isinstance(after, dict) or not after:
return 0, ["policy: PUT was accepted but the realm could not be re-read"]
still = policy_drift(after, wanted)
if still:
return 0, ["policy: PUT was accepted but %s still does not match git"
% ", ".join(sorted(still))]
if after.get(BROWSER_FLOW) != realm_rep.get(BROWSER_FLOW):
return 0, ["policy: the PUT changed %s from %r to %r, which must never happen"
% (BROWSER_FLOW, realm_rep.get(BROWSER_FLOW),
after.get(BROWSER_FLOW))]
log("policy REPAIRED (%s) and verified by readback" % ", ".join(sorted(drift)))
return 1, problems
# =============================================================================
# PASS 2 -- the enrolment required action
# =============================================================================
def reconcile_action(base, realm, token, wanted, audit, log):
"""Return (changed, problems).
Converges `enabled` and `defaultAction` on the ONE alias the allow-list permits, by
strict read-modify-write (updateRequiredAction replaces name/priority/config from
the representation, so a two-field body would blank them).
Also AUDITS the default sibling actions. Their absence is the signature of a
`requiredActions` block in the realm import -- which 26.0.6 treats as REPLACE, so
declaring one action deletes the rest. Losing CONFIGURE_TOTP locks every
platform-admin out permanently, because auth-otp-form still demands a TOTP they
could no longer enrol. Reported, never repaired: no write to those aliases exists.
"""
hdrs = {"Authorization": "Bearer " + token}
problems = []
st, text = _http("GET", base,
"/admin/realms/%s/authentication/required-actions" % realm,
headers=hdrs)
if st != 200:
return 0, ["required-action: could not list the realm's required actions "
"(http %s)" % st]
listed = json.loads(text)
if not isinstance(listed, list):
return 0, ["required-action: the required-actions listing is not a JSON list"]
aliases = {a.get("alias") for a in listed if isinstance(a, dict)}
missing = [a for a in GUARDED_ACTIONS if a not in aliases]
if missing:
problems.append(
"required-action: the realm is MISSING the default required action(s) %s. "
"That is the signature of a `requiredActions` block in the realm import, "
"which Keycloak 26.0.6 treats as REPLACE rather than merge. Losing "
"CONFIGURE_TOTP is a permanent lock-out of every platform-admin, because "
"auth-otp-form in the browser flow still demands a TOTP that can no longer "
"be enrolled. This reconciler cannot repair it -- no write to these "
"aliases is reachable -- an operator must re-register them"
% ", ".join(missing))
if ENROL_ACTION not in aliases:
problems.append(
"required-action: %r is NOT REGISTERED in realm %s, so passkey ENROLMENT is "
"impossible even though the login flow offers passkeys. Keycloak registers "
"it by default (DefaultRequiredActions, priority 80), so its absence means "
"it was explicitly removed or the realm was created with a requiredActions "
"block. The admin API cannot re-register it from here (register-required-"
"action is not on the allow-list); an operator must"
% (ENROL_ACTION, realm))
return 0, problems
path = ("/admin/realms/%s/authentication/required-actions/%s"
% (realm, ENROL_ACTION))
st, text = _http("GET", base, path, headers=hdrs)
if st != 200:
problems.append("required-action: could not read %r (http %s)"
% (ENROL_ACTION, st))
return 0, problems
rep = json.loads(text)
if not isinstance(rep, dict) or not rep:
problems.append("required-action: the %r representation is not a JSON object"
% ENROL_ACTION)
return 0, problems
drift = {}
for key in ACTION_FIELDS:
stored = rep.get(key)
if not isinstance(stored, bool):
stored = False
if stored != wanted[key]:
drift[key] = (stored, wanted[key])
if not drift:
log("action ok (%s already enabled=%s defaultAction=%s, no write)"
% (ENROL_ACTION, rep.get("enabled"), rep.get("defaultAction")))
return 0, problems
for key in sorted(drift):
stored, declared_value = drift[key]
line = ("%s %s DRIFT -- realm has %r, git declares %r"
% (ENROL_ACTION, key, stored, declared_value))
if audit:
problems.append("required-action: " + line)
else:
log("action " + line)
if audit:
return 0, problems
# STRICT READ-MODIFY-WRITE: the live representation with only the two declared
# fields changed. updateRequiredAction rebuilds the provider from the rep, so a body
# carrying only {enabled, defaultAction} would blank `name` and reset `priority`.
body = dict(rep)
for key in drift:
body[key] = wanted[key]
st, text = _http("PUT", base, path, headers=hdrs, body=body)
if not 200 <= st < 300:
problems.append("required-action: PUT failed (http %s) %s" % (st, text[:160]))
return 0, problems
st, text = _http("GET", base, path, headers=hdrs)
after = json.loads(text) if st == 200 else {}
if not isinstance(after, dict) or any(after.get(k) != wanted[k]
for k in ACTION_FIELDS):
problems.append("required-action: PUT was accepted but the readback still does "
"not match git")
return 0, problems
log("action REPAIRED (%s) and verified by readback" % ", ".join(sorted(drift)))
return 1, problems
# =============================================================================
# PASS 3 -- the browser authentication flow, BLUE/GREEN
# =============================================================================
def subflow_alias(ex):
"""The alias of the sub-flow an executions-listing row refers to, or None.
KEYCLOAK 26.0.6 NAMES A SUB-FLOW ROW BY displayName, NOT alias.
AuthenticationManagementResource.recurseExecutions (services/src/main/java/org/
keycloak/services/resources/admin/AuthenticationManagementResource.java, tag 26.0.6,
the authenticatorFlow branch) does:
AuthenticationFlowModel flowRef = realm.getAuthenticationFlowById(
execution.getFlowId());
...
rep.setDisplayName(flowRef.getAlias());
...
rep.setFlowId(execution.getFlowId());
and never calls rep.setAlias() on that row. `alias` is set only in the AUTHENTICATOR
branch, to the bound authenticator config's alias -- so reading `alias` here would
yield None for every sub-flow (and the protected subtree would silently shrink to
the top-level flow alone). The flows LIST cannot be used instead: getFlows() returns
top-level flows only, so a nested sub-flow's flowId resolves to nothing there.
A sub-flow row with no non-empty displayName returns None, which the caller treats
as an UNREADABLE subtree -- fail-closed, never a smaller protected set.
"""
name = ex.get("displayName")
return name if isinstance(name, str) and name else None
def read_executions(base, realm, token, alias):
"""(shape, raw) for a flow, or (None, None) if it cannot be read.
`shape` is the same 6-tuple sequence expected_shape() produces, so the two can be
compared element by element. Sub-flow names are blanked for the comparison (see
expected_shape) but the LIVE alias (subflow_alias(): displayName in 26.0.6) is kept
as the 6th member so reports and the protected-subtree walk can name it. A sub-flow
row whose alias cannot be determined makes the WHOLE listing unreadable, so nothing
downstream can act on a partial picture of the tree.
"""
hdrs = {"Authorization": "Bearer " + token}
st, text = _http(
"GET", base,
"/admin/realms/%s/authentication/flows/%s/executions"
% (realm, urllib.parse.quote(alias, safe="")), headers=hdrs)
if st != 200:
return None, None
raw = json.loads(text)
if not isinstance(raw, list):
return None, None
shape = []
for ex in raw:
if not isinstance(ex, dict):
continue
is_flow = bool(ex.get("authenticationFlow"))
if is_flow:
name = subflow_alias(ex)
if name is None:
return None, None
else:
name = ex.get("providerId") or ""
shape.append((
int(ex.get("level") or 0),
"flow" if is_flow else "authenticator",
"" if is_flow else (ex.get("providerId") or ""),
ex.get("requirement") or "",
ex.get("authenticationConfig") or "",
name,
))
return shape, raw
def config_contents(base, realm, token, config_id):
"""The `config` dict of an authenticator config, or None if unreadable."""
hdrs = {"Authorization": "Bearer " + token}
st, text = _http(
"GET", base,
"/admin/realms/%s/authentication/config/%s"
% (realm, urllib.parse.quote(config_id, safe="")), headers=hdrs)
if st != 200:
return None
cfg = json.loads(text)
if not isinstance(cfg, dict):
return None
body = cfg.get("config")
return body if isinstance(body, dict) else {}
def compare_shape(observed, expected, base, realm, token, configs, label):
"""Problems describing how `observed` differs from `expected`. Read-only.
Compares the first four members of each tuple -- level, kind, authenticator name,
requirement. The 5th (the live config id) is realm-generated and cannot be declared;
its CONTENTS are compared instead. The 6th (the live or declared alias) is carried
for reporting only: a sub-flow alias is a label with no effect on login behaviour,
and comparing it would make a flow this program built unrecognisable as converged.
"""
problems = []
if len(observed) != len(expected):
problems.append(
"%s has %d execution(s) but git declares %d (counting every nested step). "
"The structures differ, so the per-step report below may not align"
% (label, len(observed), len(expected)))
for idx in range(min(len(observed), len(expected))):
if observed[idx][:4] != expected[idx][:4]:
problems.append(
"%s step %d diverges -- realm has (level=%d %s %r %s, alias=%r), git "
"declares (level=%d %s %r %s, alias=%r)"
% ((label, idx) + observed[idx][:4] + (observed[idx][5],)
+ expected[idx][:4] + (expected[idx][5],)))
for idx in range(len(observed), len(expected)):
problems.append(
"%s is MISSING declared step %d (level=%d %s %r %s, alias=%r)"
% ((label, idx) + expected[idx][:4] + (expected[idx][5],)))
for idx in range(len(expected), len(observed)):
problems.append(
"%s has an UNDECLARED step %d (level=%d %s %r %s, alias=%r)"
% ((label, idx) + observed[idx][:4] + (observed[idx][5],)))
# ---- the authenticator configs, key by key -- the conditional-OTP regression
for idx in range(min(len(observed), len(expected))):
want_cfg_alias = expected[idx][4]
have_cfg_id = observed[idx][4]
name = expected[idx][5]
if not want_cfg_alias:
continue
if not have_cfg_id:
problems.append(
"%s step %d (%s) should carry the authenticator config %r and carries "
"NONE. For conditional-user-role that means the role resolves to null "
"and the REQUIRED conditional step throws, 403ing every user it guards"
% (label, idx, name, want_cfg_alias))
continue
have_cfg = config_contents(base, realm, token, have_cfg_id)
if have_cfg is None:
problems.append("%s: could not read the authenticator config bound to step "
"%d (%s)" % (label, idx, name))
continue
want_cfg = configs.get(want_cfg_alias)
if not isinstance(want_cfg, dict):
problems.append("%s: git declares config %r on step %d but defines no "
"key/value for it" % (label, want_cfg_alias, idx))
continue
for key in sorted(want_cfg):
# str() both sides: Keycloak stores authenticator config values as strings,
# so a declared boolean false and a stored "false" are the same declaration.
if str(have_cfg.get(key)) != str(want_cfg[key]):
problems.append(
"%s: authenticator config key %r is %r in the realm and %r in git "
"(step %d, %s). THIS IS THE CONDITIONAL-OTP REGRESSION SHAPE: a "
"role written under the wrong key resolves to null and the REQUIRED "
"conditional step throws, which 403'd every platform-admin out of "
"every SSO-gated app"
% (label, key, have_cfg.get(key), want_cfg[key], idx, name))
extra = sorted(k for k in have_cfg if k not in want_cfg)
if extra:
problems.append(
"%s: authenticator config on step %d (%s) carries undeclared key(s) %s. "
"On conditional-user-role the stale key "
"`condition.evaluated.user.role` is the signature of the original "
"null-role bug" % (label, idx, name, ", ".join(extra)))
return problems
def build_flow(base, realm, token, state, suffix, log):
"""Build the declared tree under NEW aliases suffixed with `suffix`. Return the new
top-level alias.
EVERY WRITE HERE TARGETS A FLOW NOTHING IS USING. That is the whole safety argument:
a failure at any point leaves an orphaned, unbound flow and zero user-visible
effect. The _PROTECTED_* screen in _http() enforces it rather than trusting it.
Aliases are mapped `<declared>-<suffix>` for every flow in the tree, so no existing
alias is reused -- reusing `browser-mfa-forms` would mean writing INTO the bound
tree, which is exactly what must never happen.
"""
hdrs = {"Authorization": "Bearer " + token}
flows = state["flows"]
root = state["browserFlow"]
configs = state.get("authenticatorConfig") or {}
def mapped(alias):
return "%s-%s" % (alias, suffix)
top = mapped(root)
st, text = _http("POST", base, "/admin/realms/%s/authentication/flows" % realm,
headers=hdrs,
body={"alias": top,
"description": "Built by the passkey reconciler from git "
"(shape %s). Bound atomically after "
"verification." % suffix,
"providerId": BASIC_FLOW,
"topLevel": True,
"builtIn": False})
if not 200 <= st < 300:
raise RuntimeError("could not create the flow %r (http %s) %s"
% (top, st, text[:160]))
log("flow created top-level flow %s" % top)
# Depth-first, in declared order: Keycloak assigns each new execution the next
# priority, so insertion order IS the resulting order. Verified by readback below.
def add_children(declared_alias):
parent = mapped(declared_alias)
for ex in flows[declared_alias]["executions"]:
if ex["kind"] == "flow":
child = mapped(ex["name"])
st, text = _http(
"POST", base,
"/admin/realms/%s/authentication/flows/%s/executions/flow"
% (realm, urllib.parse.quote(parent, safe="")), headers=hdrs,
body={"alias": child, "type": BASIC_FLOW,
"description": flows[ex["name"]].get("description") or ""})
if not 200 <= st < 300:
raise RuntimeError("could not add subflow %r under %r (http %s) %s"
% (child, parent, st, text[:160]))
add_children(ex["name"])
else:
st, text = _http(
"POST", base,
"/admin/realms/%s/authentication/flows/%s/executions/execution"
% (realm, urllib.parse.quote(parent, safe="")), headers=hdrs,
body={"provider": ex["name"]})
if not 200 <= st < 300:
raise RuntimeError("could not add execution %r under %r (http %s) %s"
% (ex["name"], parent, st, text[:160]))
add_children(root)
log("flow added %d execution(s)"
% len(expected_shape(flows, root)))
# ---- set requirements. Everything is created DISABLED (or at the authenticator's
# single legal choice), so each step's requirement must be written explicitly.
#
# THE PRIORITY TRAP: updateExecutions compares rep.getPriority() against the stored
# priority, and a body omitting `priority` deserialises it as the int 0 -- which
# would RESHUFFLE the order while only a requirement change was intended. So the
# execution's CURRENT priority is read back and carried in every write.
shape, raw = read_executions(base, realm, token, top)
if shape is None:
raise RuntimeError("could not read back the executions of %r" % top)
expected = expected_shape(flows, root)
if len(raw) != len(expected):
raise RuntimeError(
"built %r has %d execution(s) but git declares %d -- the build did not "
"produce the declared structure" % (top, len(raw), len(expected)))
# OPEN THE BUILD SCOPE: from here on, an execution-id-addressed write may reach these
# ids and no others. They come from the readback of the flow THIS CALL just created,
# so an id that belongs anywhere else in the realm stays unwritable.
_set_build_scope(ex.get("id") for ex in raw if isinstance(ex, dict))
try:
_finish_build(base, realm, top, raw, expected, configs, mapped, hdrs, log)
finally:
_set_build_scope(None)
return top
def _finish_build(base, realm, top, raw, expected, configs, mapped, hdrs, log):
"""Set requirements and bind configs on the freshly built tree. Every write here is
execution-id-addressed, so it runs only inside build_flow's build scope."""
for idx, ex in enumerate(raw):
want_req = expected[idx][3]
if ex.get("requirement") == want_req:
continue
st, text = _http(
"PUT", base,
"/admin/realms/%s/authentication/flows/%s/executions"
% (realm, urllib.parse.quote(top, safe="")), headers=hdrs,
body={"id": ex.get("id"), "requirement": want_req,
"priority": ex.get("priority") or 0})
if not 200 <= st < 300:
raise RuntimeError("could not set step %d of %r to %s (http %s) %s"
% (idx, top, want_req, st, text[:160]))
log("flow set requirements on %d step(s)" % len(raw))
# ---- bind the authenticator configs. Config aliases are suffixed too: 26.0.6
# keeps authenticator config aliases unique per realm, and the alias the old flow
# already uses would collide.
for idx, ex in enumerate(raw):
want_cfg_alias = expected[idx][4]
if not want_cfg_alias:
continue
body = configs.get(want_cfg_alias)
if not isinstance(body, dict):
raise RuntimeError("git declares config %r on step %d but defines no "
"key/value for it" % (want_cfg_alias, idx))
st, text = _http(
"POST", base,
"/admin/realms/%s/authentication/executions/%s/config"
% (realm, urllib.parse.quote(ex.get("id") or "", safe="")), headers=hdrs,
body={"alias": mapped(want_cfg_alias),
"config": {k: str(v) for k, v in sorted(body.items())}})
if not 200 <= st < 300:
raise RuntimeError("could not bind config %r to step %d of %r (http %s) %s"
% (want_cfg_alias, idx, top, st, text[:160]))
log("flow bound authenticator config %s to step %d"
% (mapped(want_cfg_alias), idx))
def bind_browser_flow(base, realm, token, new_alias, log):
"""THE ONE ATOMIC MUTATION TO LIVE LOGIN BEHAVIOUR. Return (previous_alias, problems).
A single read-modify-write PUT of the realm, changing exactly one field. This is the
only function in the program permitted to change browserFlow; the policy pass
refuses to. It is called ONLY after the new flow has been built and a readback has
proved it matches the desired shape, AND only when git's bind-browser-flow is "true".
The previous alias is returned so it can be logged. The old flow is never deleted,
so that alias is a working rollback target and the rollback is one PUT.
"""
hdrs = {"Authorization": "Bearer " + token}
st, text = _http("GET", base, "/admin/realms/%s" % realm, headers=hdrs)
if st != 200:
return None, ["flow: could not read the realm to bind the new flow (http %s)"
% st]
rep = json.loads(text)
if not isinstance(rep, dict) or not rep:
return None, ["flow: the realm representation is not a JSON object"]
previous = rep.get(BROWSER_FLOW)
if previous == new_alias:
return previous, []
body = dict(rep)
body[BROWSER_FLOW] = new_alias
# The same 26.0.6 gate the policy pass respects: a realm PUT without a non-empty
# RpEntityName silently drops the passwordless policy. Binding a flow must not
# un-apply the policy as a side effect.
if not body.get(RP_ENTITY_NAME):
return previous, [
"flow: refusing to bind %r -- the realm representation carries no non-empty "
"%s, and a PUT without it silently drops the whole passwordless policy. Fix "
"the policy first (pass 1) and the bind will succeed on the next run"
% (new_alias, RP_ENTITY_NAME)]
st, text = _http("PUT", base, "/admin/realms/%s" % realm, headers=hdrs, body=body)
if not 200 <= st < 300:
return previous, ["flow: binding %r failed (http %s) %s"
% (new_alias, st, text[:160])]
st, text = _http("GET", base, "/admin/realms/%s" % realm, headers=hdrs)
after = json.loads(text) if st == 200 else {}
if not isinstance(after, dict) or after.get(BROWSER_FLOW) != new_alias:
return previous, [
"flow: the bind PUT was accepted but %s still reads %r, not %r"
% (BROWSER_FLOW, (after or {}).get(BROWSER_FLOW), new_alias)]
log("flow BOUND browserFlow %r -> %r (atomic, one PUT)" % (previous, new_alias))
log("flow ROLLBACK is one write: set browserFlow back to %r. The old flow was "
"NOT deleted." % previous)
return previous, []
# Set by reconcile_flow() when a verified candidate exists but git forbids binding it,
# so the run's final line can say so instead of claiming everything matches git.
_PENDING_BIND = [None]
def reconcile_flow(base, realm, token, state, audit, bind, log):
"""Return (changed, problems). Blue/green: build new, verify, and -- only if `bind`
(git's bind-browser-flow) -- flip atomically.
Order matters and is load-bearing:
1. establish what is BOUND, and protect its whole subtree from writes.
2. compare the bound flow against the desired shape. If it matches -> ZERO
WRITES, nothing built. This is what stops a rebuild loop.
3. otherwise pick an unused candidate alias derived from a hash of the desired
shape. If a flow already exists under it and MATCHES desired, it is a completed
build from an earlier run -- REUSE it rather than rebuild.
4. build, read back, verify. Nothing is bound unless verification passes.
5. ONE atomic PUT to flip browserFlow -- ONLY when bind is True. When it is False
the run ends at 4 and reports the alias it WOULD bind; whatever the realm is
bound to is left alone, so an operator's rollback is never undone.
"""
hdrs = {"Authorization": "Bearer " + token}
problems = []
flows = state["flows"]
configs = state.get("authenticatorConfig") or {}
try:
expected = expected_shape(flows, state["browserFlow"])
except ValueError as e:
return 0, ["flow: the desired state is unusable: %s" % e]
# ---- 1. what is bound, and what must therefore never be written ------------------
st, text = _http("GET", base, "/admin/realms/%s" % realm, headers=hdrs)
if st != 200:
return 0, ["flow: could not read the realm representation (http %s)" % st]
realm_rep = json.loads(text)
bound = realm_rep.get(BROWSER_FLOW) if isinstance(realm_rep, dict) else None
if not bound:
# Nothing is bound, so there is nothing to protect. Determined, not unknown.
_set_protected(set(), set())
log("flow the realm has NO browserFlow bound")
bound_shape, bound_raw = None, None
else:
bound_shape, bound_raw = read_executions(base, realm, token, bound)
if bound_shape is None:
# FAIL CLOSED: without the bound subtree we cannot prove a build would miss
# it, so leave the protected sets as None and every flow write stays
# refused.
return 0, [
"flow: the realm binds browserFlow to %r but its executions could not "
"be read, so this program cannot establish which flows are live. Flow "
"writes stay FAIL-CLOSED for this run -- nothing was built or bound"
% bound]
aliases = {bound} | {s[5] for s in bound_shape if s[1] == "flow" and s[5]}
exec_ids = {ex.get("id") for ex in bound_raw if isinstance(ex, dict)}
_set_protected(aliases, {i for i in exec_ids if i})
log("flow bound=%s (protecting %d flow alias(es) and %d execution(s) from "
"any write)" % (bound, len(aliases), len(exec_ids)))
# ---- 2. is the bound flow already what git declares? ----------------------------
if bound_shape is not None:
drift = compare_shape(bound_shape, expected, base, realm, token, configs,
"the bound flow %r" % bound)
if not drift:
log("flow ok (the bound flow %r matches git: %d execution(s), no "
"write)" % (bound, len(expected)))
return 0, problems
for d in drift:
if audit:
problems.append("flow: " + d)
else:
log("flow " + d)
if audit:
problems.append(
"flow: the bound flow does not match git. In audit mode nothing is built "
"and nothing is bound")
return 0, problems
# ---- 3. candidate aliases, derived from the desired shape -----------------------
suffix = shape_digest(state)
st, text = _http("GET", base, "/admin/realms/%s/authentication/flows" % realm,
headers=hdrs)
if st != 200:
return 0, problems + ["flow: could not list the realm's authentication flows "
"(http %s)" % st]
listed = json.loads(text)
have = {f.get("alias") for f in listed if isinstance(f, dict)}
target = None
built = False
for attempt in range(1, MAX_BUILD_ATTEMPTS + 1):
candidate_suffix = suffix if attempt == 1 else "%s-r%d" % (suffix, attempt)
candidate = "%s-%s" % (state["browserFlow"], candidate_suffix)
if candidate in have and candidate == bound and not bind:
# The candidate alias IS the live binding, and it no longer matches git -- an
# operator edited the flow by hand (a hotfix), or someone else did. With the
# bind switch off, the binding is the operator's: do not build a replacement
# next to it, do not bind anything. Report and stop.
log("flow the BOUND flow %r is this program's own candidate alias but no "
"longer matches git (hand-edited?). bind-browser-flow is not \"true\", so "
"it is LEFT ALONE: nothing is built and nothing is bound" % bound)
return 0, problems
if candidate in have:
# A flow already exists here. If it is a COMPLETED build from an earlier run
# it matches desired and can simply be bound -- which makes a crashed run
# recoverable without rebuilding. If it does not match it is a half-built
# leftover; it is never deleted, so move to the next candidate.
cand_shape, _ = read_executions(base, realm, token, candidate)
if cand_shape is not None and not compare_shape(
cand_shape, expected, base, realm, token, configs,
"the existing flow %r" % candidate):
log("flow reusing %s -- an already-built, verified, unbound flow "
"from an earlier run" % candidate)
target = candidate
break
log("flow %s exists but does not match git (a half-built leftover); it "
"is left alone and the next candidate alias is tried" % candidate)
continue
try:
target = build_flow(base, realm, token, state, candidate_suffix, log)
built = True
except Refused as e:
return 0, problems + ["flow: %s" % e]
except (RuntimeError, OSError, ValueError) as e:
problems.append(
"flow: building %r FAILED (%s). Nothing was bound, so login is "
"UNAFFECTED -- the old flow is still live. The partial flow is left in "
"place, unbound and harmless" % (candidate, e))
return 0, problems
break
if target is None:
return 0, problems + [
"flow: could not find an unused alias for the declared shape after %d "
"attempt(s). Earlier half-built flows are occupying every candidate. "
"Nothing was bound. An operator should delete the unbound "
"%s-%s* flows" % (MAX_BUILD_ATTEMPTS, state["browserFlow"], suffix)]
# ---- 4. VERIFY BEFORE BINDING ---------------------------------------------------
# Nothing above has touched live login behaviour. This is the gate that decides
# whether anything will.
final_shape, _ = read_executions(base, realm, token, target)
if final_shape is None:
return 0, problems + [
"flow: %r was built but its executions could not be read back, so it was "
"NOT bound. Login is unaffected" % target]
verify = compare_shape(final_shape, expected, base, realm, token, configs,
"the newly built flow %r" % target)
if verify:
return 0, problems + [
"flow: %r was built but does NOT match git, so it was NOT BOUND and login "
"is UNAFFECTED. The old flow is still live. Divergences:" % target] + [
"flow: " + v for v in verify]
log("flow verified %s against git (%d execution(s), configs included)"
% (target, len(expected)))
if not bind:
# THE BIND SWITCH IS OFF. The realm's binding is NOT this program's to change:
# not the first time, and not after an operator has rolled it back. Zero writes
# to browserFlow, and not a failure -- this is the declared state.
_PENDING_BIND[0] = target
log("flow WOULD BIND %s -- it is built and VERIFIED against git, but "
"bind-browser-flow is not \"true\" in platform/keycloak/"
"passkey-desired-state.yaml, so browserFlow is LEFT AT %r. To go live: test "
"%s in a browser (README: Passkeys), then set bind-browser-flow: \"true\" in "
"a PR" % (target, bound, target))
return (1 if built else 0), problems
# ---- 5. the atomic flip ---------------------------------------------------------
previous, bind_problems = bind_browser_flow(base, realm, token, target, log)
if bind_problems:
return 0, problems + bind_problems
if built:
log("flow the previous flow %r was NOT deleted and remains the rollback "
"target" % previous)
return 1, problems
# =============================================================================
# RUN
# =============================================================================
def declared_bind(path):
"""git's bind-browser-flow switch: True, False, or raise ValueError.
An ABSENT file is False -- the safe reading, and the one a ConfigMap predating the
switch produces. A PRESENT file must hold exactly "true" or "false": a typo must stop
the run, not be guessed at in either direction.
"""
try:
with open(path, "r", encoding="utf-8") as fh:
raw = fh.read().strip()
except FileNotFoundError:
return False
if raw == "true":
return True
if raw == "false":
return False
raise ValueError("%s holds %r; bind-browser-flow must be exactly \"true\" or "
"\"false\"" % (path, raw[:40]))
def run(argv, log):
audit = "--audit" in argv
no_bind = "--no-bind" in argv
_PENDING_BIND[0] = None
base = os.environ.get("KEYCLOAK_BASE_URL", "").strip()
realm = os.environ.get("KEYCLOAK_REALM", "").strip()
user = os.environ.get("KEYCLOAK_ADMIN_USER", "")
password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "")
# Every run starts with flow writes FAIL-CLOSED. Only reconcile_flow(), after it has
# read the live binding, may open them -- and only for flows outside it.
_reset_protected()
for name, value in (("KEYCLOAK_BASE_URL", base), ("KEYCLOAK_REALM", realm),
("KEYCLOAK_ADMIN_USER", user),
("KEYCLOAK_ADMIN_PASSWORD", password)):
if not value:
log("FATAL: %s is empty" % name)
return 1
mode = "audit (read-only)" if audit else (
"reconcile, --no-bind" if no_bind else "reconcile")
log("mode=%s realm=%s base=%s" % (mode, realm, base))
# ---- FAIL CLOSED on the desired state --------------------------------------------
try:
state = declared(STATE_FILE)
except OSError as e:
log("FATAL: cannot read %s (%s) -- the keycloak-passkey-desired-state "
"ConfigMap is not mounted. NOTHING WAS WRITTEN."
% (STATE_FILE, e.__class__.__name__))
return 1
except (ValueError, TypeError) as e:
log("FATAL: %s is unusable: %s. NOTHING WAS WRITTEN." % (STATE_FILE, e))
return 1
try:
git_bind = declared_bind(BIND_FILE)
except (OSError, ValueError) as e:
log("FATAL: the bind switch is unusable: %s. NOTHING WAS WRITTEN." % e)
return 1
# --no-bind can only NARROW what git allows; there is no flag that widens it.
bind = git_bind and not no_bind
log("bind-browser-flow (git): %s%s"
% ("true" if git_bind else "false",
" -- overridden to NO BIND by --no-bind" if git_bind and no_bind else ""))
wanted_policy = state["webAuthnPolicyPasswordless"]
wanted_action = state["requiredAction"]
log("declared policy keys (%d): %s"
% (len(wanted_policy), ", ".join(sorted(wanted_policy))))
log("declared enrolment action: %s enabled=%s defaultAction=%s"
% (ENROL_ACTION, wanted_action["enabled"], wanted_action["defaultAction"]))
try:
log("declared browser flow: %s (%d flow(s), %d execution(s), shape %s)"
% (state["browserFlow"], len(state["flows"]),
len(expected_shape(state["flows"], state["browserFlow"])),
shape_digest(state)))
except ValueError as e:
log("FATAL: the declared flow tree is unusable: %s. NOTHING WAS WRITTEN." % e)
return 1
try:
token = admin_token(base, user, password, log)
except Refused as e:
log("FATAL: %s" % e)
return 1
except (RuntimeError, OSError, ValueError) as e:
log("FATAL: %s: %s" % (e.__class__.__name__, e))
return 1
rc = 0
problems = []
# ---- THE THREE PASSES ARE INDEPENDENT --------------------------------------------
# A failure in one must not suppress the others. That matters most for pass 3: the
# login flow is the most safety-critical thing here, and its state must still be
# reported when the policy write fails for an unrelated reason.
for name, fn in (("policy", lambda: reconcile_policy(base, realm, token,
wanted_policy, audit, log)),
("required action", lambda: reconcile_action(base, realm, token,
wanted_action, audit,
log)),
("browser flow (blue/green)",
lambda: reconcile_flow(base, realm, token, state, audit, bind,
log))):
log("")
log("-- pass: %s --" % name)
try:
changed, found = fn()
except Refused as e:
log("REFUSED in the %s pass: %s" % (name, e))
problems.append("%s: %s" % (name, e))
rc = 1
continue
except (RuntimeError, OSError, ValueError) as e:
log("FAILED in the %s pass: %s: %s" % (name, e.__class__.__name__, e))
problems.append("%s: %s: %s" % (name, e.__class__.__name__, e))
rc = 1
continue
problems.extend(found)
if found:
rc = 1
elif not audit and changed:
log("%s: converged" % name)
if problems:
log("")
for p in problems:
log("PROBLEM %s" % p)
log("")
if audit:
log("AUDIT FAIL: %d passkey configuration item(s) do not match git"
% len(problems))
else:
log("RECONCILE FAIL: %d passkey configuration item(s) could not be "
"reconciled. Note that a flow is BOUND only after it is built and "
"verified, so a flow failure here means LOGIN IS UNAFFECTED and the "
"previously bound flow is still live" % len(problems))
elif audit:
log("")
log("KEYCLOAK_PASSKEY_AUDIT_OK: the passwordless policy, the enrolment required "
"action and the bound browser flow all match git")
elif _PENDING_BIND[0]:
log("")
log("KEYCLOAK_PASSKEY_RECONCILED_OK_PENDING_BIND: the passwordless policy and the "
"enrolment action match git; the browser flow candidate %s is built and "
"verified but NOT bound, because bind-browser-flow is not \"true\" in git. "
"Login is unchanged." % _PENDING_BIND[0])
else:
log("")
log("KEYCLOAK_PASSKEY_RECONCILED_OK: the passwordless policy, the enrolment "
"action and the bound browser flow all match git")
return rc
# =============================================================================
# --selftest -- the offline behavioural proof. It drives the REAL code paths above
# against an in-process stub Keycloak that MODELS THE 26.0.6 BEHAVIOURS THAT BITE:
# the RpEntityName gate on the passwordless policy, the all-or-nothing policy rebuild
# that defaults every omitted key, the required-action representation replace, the
# flow-construction endpoints (everything created DISABLED), and the updateExecutions
# priority trap.
# =============================================================================
def _refused(method, path, body=None):
"""True iff _http() refuses (method, path, body) outright."""
try:
_http(method, "http://127.0.0.1:1", path, body={} if body is None else body)
except Refused:
return True
except OSError:
return False
return False
def _selftest():
import contextlib
import copy
import http.server
import io
import tempfile
import threading
global STATE_FILE, BIND_FILE, READY_ATTEMPTS, READY_SLEEP
ADMIN_PW = "stub-admin-password"
RP_ID = "home.keithjackson.dev"
WANT_POLICY = {
"webAuthnPolicyPasswordlessRpId": RP_ID,
"webAuthnPolicyPasswordlessRpEntityName": "Homelab SSO",
"webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256", "RS256"],
"webAuthnPolicyPasswordlessRequireResidentKey": "Yes",
"webAuthnPolicyPasswordlessUserVerificationRequirement": "required",
"webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
"webAuthnPolicyPasswordlessAttestationConveyancePreference": "none",
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
"webAuthnPolicyPasswordlessCreateTimeout": 300,
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": True,
}
# Keycloak's own defaults for the passwordless family, which
# getWebAuthnPolicyPasswordless() substitutes for any null field. The stub applies
# these so a PARTIAL PUT visibly resets the keys it omits -- modelling the real
# all-or-nothing rebuild rather than a forgiving merge.
POLICY_DEFAULTS = {
"webAuthnPolicyPasswordlessRpId": "",
"webAuthnPolicyPasswordlessRpEntityName": "keycloak",
"webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"],
"webAuthnPolicyPasswordlessRequireResidentKey": "not specified",
"webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified",
"webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
"webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified",
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
"webAuthnPolicyPasswordlessCreateTimeout": 0,
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": False,
}
# The declared flow tree -- the shape keycloak-realm-import.yaml declares.
WANT_FLOWS = {
"browser-mfa": {"topLevel": True, "executions": [
{"kind": "authenticator", "name": "auth-cookie",
"requirement": "ALTERNATIVE"},
{"kind": "authenticator", "name": "identity-provider-redirector",
"requirement": "ALTERNATIVE"},
{"kind": "flow", "name": "browser-mfa-forms",
"requirement": "ALTERNATIVE"},
]},
"browser-mfa-forms": {"topLevel": False, "executions": [
{"kind": "authenticator", "name": "auth-username-form",
"requirement": "REQUIRED"},
{"kind": "flow", "name": "browser-mfa-passkey-or-password",
"requirement": "REQUIRED"},
]},
"browser-mfa-passkey-or-password": {"topLevel": False, "executions": [
{"kind": "authenticator", "name": "webauthn-authenticator-passwordless",
"requirement": "ALTERNATIVE"},
{"kind": "flow", "name": "browser-mfa-password-and-otp",
"requirement": "ALTERNATIVE"},
]},
"browser-mfa-password-and-otp": {"topLevel": False, "executions": [
{"kind": "authenticator", "name": "auth-password-form",
"requirement": "REQUIRED"},
{"kind": "flow", "name": "browser-mfa-conditional-otp",
"requirement": "CONDITIONAL"},
]},
"browser-mfa-conditional-otp": {"topLevel": False, "executions": [
{"kind": "authenticator", "name": "conditional-user-role",
"requirement": "REQUIRED",
"configAlias": "conditional-otp-platform-admin"},
{"kind": "authenticator", "name": "auth-otp-form",
"requirement": "REQUIRED"},
]},
}
WANT_CONFIGS = {"conditional-otp-platform-admin": {
"condUserRole": "platform-admin", "negate": "false"}}
WANT_STATE = {
"realm": "homelab",
"webAuthnPolicyPasswordless": WANT_POLICY,
"requiredAction": {"alias": ENROL_ACTION, "enabled": True,
"defaultAction": False},
"browserFlow": "browser-mfa",
"flows": WANT_FLOWS,
"authenticatorConfig": WANT_CONFIGS,
}
state = {"realm": {}, "actions": {}, "flows": {}, "configs": {},
"realm_writes": [], "action_writes": [], "touched": [], "ids": [0],
"lying_realm": False, "fail_build_after": None, "build_writes": [0],
"sabotage": None, "bind_writes": [], "hide_display_name": False}
def new_id(prefix):
state["ids"][0] += 1
return "%s-%d" % (prefix, state["ids"][0])
def flat(alias):
"""The stub's executions listing: depth-first with levels, priority-ordered, and
SHAPED AS KEYCLOAK 26.0.6 SHAPES IT (AuthenticationManagementResource.
recurseExecutions). An earlier stub returned `alias` on sub-flow rows, which the
real server never does -- and every assertion passed against that fiction while
the protected subtree was, on a real server, only the top-level flow. So:
sub-flow row displayName = the sub-flow's ALIAS, flowId, description,
authenticationFlow true, configurable false. NO `alias`,
NO providerId (basic-flow).
authenticator row displayName = the factory's DISPLAY TYPE (deliberately not
the provider id, so code reading it by mistake fails),
providerId, and -- only when a config is bound --
authenticationConfig (the config id) and `alias` (the
CONFIG's alias, which is what the real server puts there).
Null members are omitted, as Keycloak's NON_NULL serialisation omits them.
"""
out = []
def walk(a, level):
for i, ex in enumerate(sorted(state["flows"][a]["executions"],
key=lambda e: e["priority"])):
row = {"id": ex["id"], "level": level, "index": i,
"priority": ex["priority"], "requirement": ex["requirement"],
"requirementChoices": ["REQUIRED", "ALTERNATIVE", "DISABLED"]}
if ex["kind"] == "flow":
row.update({"authenticationFlow": True, "configurable": False,
"flowId": "flowid-" + ex["name"],
"description": ""})
if not state["hide_display_name"]:
row["displayName"] = ex["name"]
out.append(row)
walk(ex["name"], level + 1)
else:
row.update({"displayName": "Display: " + ex["name"],
"configurable": True, "providerId": ex["name"]})
cid = ex.get("configId")
if cid:
row["authenticationConfig"] = cid
cfg = state["configs"].get(cid) or {}
if cfg.get("alias"):
row["alias"] = cfg["alias"]
out.append(row)
walk(alias, 0)
return out
def find_exec(exec_id):
for a, spec in state["flows"].items():
for ex in spec["executions"]:
if ex["id"] == exec_id:
return ex
return None
def next_priority(alias):
ex = state["flows"][alias]["executions"]
return 0 if not ex else max(e["priority"] for e in ex) + 1
class Stub(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, obj):
payload = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _body(self):
raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
return json.loads(raw or b"{}")
def _route(self, method):
path = urllib.parse.urlparse(self.path).path
state["touched"].append("%s %s" % (method, path))
if path == "/realms/master/protocol/openid-connect/token":
return self._send(200, {"access_token": "stub-token"})
# ---- realm ---------------------------------------------------------------
if path == "/admin/realms/homelab" and method == "GET":
return self._send(200, dict(state["realm"]))
if path == "/admin/realms/homelab" and method == "PUT":
rep = self._body()
state["realm_writes"].append(sorted(
k for k in rep if k.startswith("webAuthnPolicyPasswordless")
or k == "browserFlow"))
# 26.0.6: the whole passwordless policy is applied ONLY when
# RpEntityName is non-empty; otherwise the PUT succeeds and the policy
# is UNCHANGED. When applied, every key is rebuilt from the rep with a
# Keycloak DEFAULT substituted for each one the body omits.
# "lying_realm" additionally models a server that 204s without applying.
if rep.get(RP_ENTITY_NAME) and not state["lying_realm"]:
for key in POLICY_FIELDS:
state["realm"][key] = (rep[key] if key in rep
else POLICY_DEFAULTS[key])
if "browserFlow" in rep and not state["lying_realm"]:
old = state["realm"].get("browserFlow")
if rep["browserFlow"] != old:
# Only a write that CHANGES the binding is a bind. Every
# read-modify-write body legitimately carries it UNCHANGED.
state["bind_writes"].append((old, rep["browserFlow"]))
state["realm"]["browserFlow"] = rep["browserFlow"]
return self._send(204, {})
# ---- required actions ----------------------------------------------------
if (path == "/admin/realms/homelab/authentication/required-actions"
and method == "GET"):
return self._send(200, [dict(v) for v in state["actions"].values()])
m = re.fullmatch(
r"/admin/realms/homelab/authentication/required-actions/([^/]+)", path)
if m and method == "GET":
a = state["actions"].get(m.group(1))
return self._send(404, {}) if a is None else self._send(200, dict(a))
if m and method == "PUT":
rep, alias = self._body(), m.group(1)
if alias not in state["actions"]:
return self._send(404, {})
state["action_writes"].append((alias, tuple(sorted(rep))))
# updateRequiredAction REPLACES the provider from the representation, so
# a partial body blanks what it omits. Modelled exactly.
state["actions"][alias] = {
"alias": alias, "name": rep.get("name"), "providerId": alias,
"enabled": rep.get("enabled"),
"defaultAction": rep.get("defaultAction"),
"priority": rep.get("priority"), "config": rep.get("config")}
return self._send(204, {})
# ---- flows: reads --------------------------------------------------------
if path == "/admin/realms/homelab/authentication/flows" and method == "GET":
return self._send(200, [{"alias": a, "topLevel": s["topLevel"],
"builtIn": False}
for a, s in state["flows"].items()])
m = re.fullmatch(
r"/admin/realms/homelab/authentication/flows/([^/]+)/executions", path)
if m and method == "GET":
alias = urllib.parse.unquote(m.group(1))
if alias not in state["flows"]:
return self._send(404, {})
return self._send(200, flat(alias))
m2 = re.fullmatch(
r"/admin/realms/homelab/authentication/config/([^/]+)", path)
if m2 and method == "GET":
cfg = state["configs"].get(urllib.parse.unquote(m2.group(1)))
return self._send(404, {}) if cfg is None else self._send(200, dict(cfg))
# ---- flows: construction writes -----------------------------------------
def budget():
"""Model a crash part-way through a build."""
state["build_writes"][0] += 1
limit = state["fail_build_after"]
return limit is not None and state["build_writes"][0] > limit
if (path == "/admin/realms/homelab/authentication/flows"
and method == "POST"):
if budget():
return self._send(500, {"error": "stub build failure"})
rep = self._body()
alias = rep.get("alias")
if not alias:
return self._send(400, {"error": "empty alias"})
if alias in state["flows"]:
return self._send(409, {"errorMessage": "Flow already exists"})
state["flows"][alias] = {"topLevel": bool(rep.get("topLevel")),
"executions": []}
return self._send(201, {})
m = re.fullmatch(
r"/admin/realms/homelab/authentication/flows/([^/]+)/executions/flow",
path)
if m and method == "POST":
if budget():
return self._send(500, {"error": "stub build failure"})
parent = urllib.parse.unquote(m.group(1))
rep = self._body()
child = rep.get("alias")
if parent not in state["flows"]:
return self._send(400, {"error": "Parent flow doesn't exist"})
if child in state["flows"]:
return self._send(409, {"errorMessage": "alias already exists"})
state["flows"][child] = {"topLevel": False, "executions": []}
# Created DISABLED, exactly as addExecutionFlow does.
state["flows"][parent]["executions"].append(
{"id": new_id("ex"), "kind": "flow", "name": child,
"requirement": "DISABLED", "priority": next_priority(parent)})
return self._send(201, {})
m = re.fullmatch(
r"/admin/realms/homelab/authentication/flows/([^/]+)/executions/execution",
path)
if m and method == "POST":
if budget():
return self._send(500, {"error": "stub build failure"})
parent = urllib.parse.unquote(m.group(1))
rep = self._body()
if parent not in state["flows"]:
return self._send(400, {"error": "Parent flow doesn't exist"})
# Created DISABLED, exactly as addExecutionToFlow does for an
# authenticator with more than one requirement choice.
state["flows"][parent]["executions"].append(
{"id": new_id("ex"), "kind": "authenticator",
"name": rep.get("provider"), "requirement": "DISABLED",
"priority": next_priority(parent)})
return self._send(201, {})
m = re.fullmatch(
r"/admin/realms/homelab/authentication/flows/([^/]+)/executions", path)
if m and method == "PUT":
if budget():
return self._send(500, {"error": "stub build failure"})
rep = self._body()
# 26.0.6 resolves the execution REALM-WIDE by id and never checks it is
# in the path's flow -- modelled: find_exec searches every flow.
ex = find_exec(rep.get("id"))
if ex is None:
return self._send(404, {"error": "Illegal execution"})
# THE PRIORITY TRAP, modelled: a body omitting `priority` deserialises
# it as 0 in Keycloak, which RESHUFFLES the order. The stub therefore
# writes whatever priority it is given, including a missing one as 0.
ex["priority"] = rep.get("priority", 0)
if "requirement" in rep:
want = rep["requirement"]
# "sabotage" models a server that accepts the requirement write and
# applies the wrong value -- so VERIFY-BEFORE-BIND is load-bearing.
if state["sabotage"] == "requirement":
want = "DISABLED"
ex["requirement"] = want
return self._send(204, {})
m = re.fullmatch(
r"/admin/realms/homelab/authentication/executions/([^/]+)/config", path)
if m and method == "POST":
if budget():
return self._send(500, {"error": "stub build failure"})
ex = find_exec(urllib.parse.unquote(m.group(1)))
if ex is None:
return self._send(404, {"error": "Illegal execution"})
rep = self._body()
cid = new_id("cfg")
cfg = dict(rep.get("config") or {})
if state["sabotage"] == "config":
cfg = {"condition.evaluated.user.role": "platform-admin",
"negate": "false"}
state["configs"][cid] = {"id": cid, "alias": rep.get("alias"),
"config": cfg}
ex["configId"] = cid
return self._send(201, {})
return self._send(500, {"error": "an endpoint the reconciler must not touch"})
def do_GET(self):
self._route("GET")
def do_PUT(self):
self._route("PUT")
def do_POST(self):
self._route("POST")
def do_DELETE(self):
self._route("DELETE")
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Stub)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = "http://127.0.0.1:%d" % srv.server_address[1]
tmpdir = tempfile.mkdtemp()
GOOD_STATE = os.path.join(tmpdir, "passkey-desired-state.json")
def write_state(obj=None):
with open(GOOD_STATE, "w", encoding="utf-8") as fh:
json.dump(obj or WANT_STATE, fh)
write_state()
STATE_FILE = GOOD_STATE
BIND_PATH = os.path.join(tmpdir, "bind-browser-flow")
def write_bind(value):
"""git's bind-browser-flow. None removes the file (a ConfigMap without the key)."""
if value is None:
if os.path.exists(BIND_PATH):
os.unlink(BIND_PATH)
return
with open(BIND_PATH, "w", encoding="utf-8") as fh:
fh.write(value + "\n")
# Sections 1-13 exercise the BIND path, so they run with git saying "true". The
# shipped default is "false"; section 14 covers it, and an absent file, explicitly.
write_bind("true")
BIND_FILE = BIND_PATH
READY_ATTEMPTS, READY_SLEEP = 2, 0.01
os.environ.update({"KEYCLOAK_BASE_URL": base, "KEYCLOAK_REALM": "homelab",
"KEYCLOAK_ADMIN_USER": "admin",
"KEYCLOAK_ADMIN_PASSWORD": ADMIN_PW})
failures = []
checks = [0]
def expect(cond, what):
checks[0] += 1
if not cond:
failures.append(what)
def invoke(*argv):
state["realm_writes"] = []
state["action_writes"] = []
state["touched"] = []
state["build_writes"] = [0]
state["bind_writes"] = []
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = run(list(argv), print)
return rc, buf.getvalue()
# ---- THE LEGACY REALM: the pre-passkey flow, which is what prod actually holds.
# One combined auth-username-password-form, and conditional-OTP one level SHALLOWER
# than the declared structure puts it. Its sub-flows are real, so the protected
# subtree is non-trivial.
LEGACY = {
"browser-mfa": [
{"kind": "authenticator", "name": "auth-cookie", "req": "ALTERNATIVE"},
{"kind": "authenticator", "name": "identity-provider-redirector",
"req": "ALTERNATIVE"},
{"kind": "flow", "name": "browser-mfa-forms", "req": "ALTERNATIVE"},
],
"browser-mfa-forms": [
{"kind": "authenticator", "name": "auth-username-password-form",
"req": "REQUIRED"},
{"kind": "flow", "name": "browser-mfa-conditional-otp",
"req": "CONDITIONAL"},
],
"browser-mfa-conditional-otp": [
{"kind": "authenticator", "name": "conditional-user-role",
"req": "REQUIRED", "cfg": {"condUserRole": "platform-admin",
"negate": "false"}},
{"kind": "authenticator", "name": "auth-otp-form", "req": "REQUIRED"},
],
}
def install_tree(tree, top_levels=("browser-mfa",)):
"""Put a flow tree into the stub without going through the write endpoints."""
for alias, execs in tree.items():
state["flows"][alias] = {"topLevel": alias in top_levels,
"executions": []}
for alias, execs in tree.items():
for i, e in enumerate(execs):
entry = {"id": new_id("ex"), "kind": e["kind"], "name": e["name"],
"requirement": e["req"], "priority": i}
if e.get("cfg") is not None:
cid = new_id("cfg")
state["configs"][cid] = {"id": cid, "alias": "legacy-" + e["name"],
"config": dict(e["cfg"])}
entry["configId"] = cid
state["flows"][alias]["executions"].append(entry)
def desired_tree():
"""The DECLARED structure, as a stub tree, under the declared aliases."""
out = {}
for alias, spec in WANT_FLOWS.items():
out[alias] = []
for ex in spec["executions"]:
e = {"kind": ex["kind"], "name": ex["name"],
"req": ex["requirement"]}
if ex.get("configAlias"):
e["cfg"] = dict(WANT_CONFIGS[ex["configAlias"]])
out[alias].append(e)
return out
def reset(policy_drifted=True, tree=None, bound="browser-mfa"):
state["lying_realm"] = False
state["hide_display_name"] = False
state["fail_build_after"] = None
state["sabotage"] = None
state["ids"] = [0]
state["flows"] = {}
state["configs"] = {}
state["realm"] = {
"realm": "homelab", "displayName": "Homelab", "browserFlow": bound,
# A realm field this program does not declare. It must survive every write
# untouched: "not declared" means UNMANAGED, never "reset to default".
"bruteForceProtected": True, "sslRequired": "external",
}
src = POLICY_DEFAULTS if policy_drifted else WANT_POLICY
for key in POLICY_FIELDS:
v = src[key]
state["realm"][key] = list(v) if isinstance(v, list) else v
state["actions"] = {
ENROL_ACTION: {"alias": ENROL_ACTION,
"name": "Webauthn Register Passwordless",
"providerId": ENROL_ACTION, "enabled": False,
"defaultAction": False, "priority": 80, "config": {}}}
for alias in GUARDED_ACTIONS:
state["actions"][alias] = {"alias": alias, "name": alias,
"providerId": alias, "enabled": True,
"defaultAction": False, "priority": 10,
"config": {}}
install_tree(copy.deepcopy(tree if tree is not None else LEGACY))
def leaked(text):
return [s for s in (ADMIN_PW,) if s in text]
def writes_to(aliases):
"""Write requests whose path targets any of `aliases` EXACTLY.
The alias segment is compared whole. A substring test would match
"browser-mfa" inside "browser-mfa-<hash>" -- i.e. it would report every write to
the NEW flow as a write to the bound one, which is the exact opposite of the
property being asserted.
"""
want = {a for a in aliases}
hits = []
for t in state["touched"]:
method, _, path = t.partition(" ")
if method not in _WRITE_METHODS:
continue
m = re.fullmatch(
r"/admin/realms/homelab/authentication/flows/([^/]+)"
r"(?:/executions(?:/flow|/execution)?)?", path)
if m and urllib.parse.unquote(m.group(1)) in want:
hits.append(t)
return hits
LEGACY_ALIASES = tuple(LEGACY)
# ========================================================================== 1. AUDIT
# A drifted realm: audit fails, names everything, and writes / builds NOTHING.
reset()
before_flows = set(state["flows"])
rc, out = invoke("--audit")
expect(rc == 1, "audit must exit 1 when the realm does not match git")
expect(state["realm_writes"] == [] and state["action_writes"] == [],
"audit mode must never write (realm=%r actions=%r)"
% (state["realm_writes"], state["action_writes"]))
expect(set(state["flows"]) == before_flows,
"audit mode must never CREATE a flow, got %r"
% (set(state["flows"]) - before_flows,))
expect("webAuthnPolicyPasswordlessRpId DRIFT" in out,
"audit must name the drifted RP ID -- the field that silently breaks every "
"passkey if it is wrong")
expect("enabled DRIFT" in out,
"audit must report the disabled enrolment required action")
expect("auth-username-password-form" in out and "auth-username-form" in out,
"audit must name BOTH the stored authenticator and the declared one, so the "
"divergence is actionable")
expect("nothing is built" in out,
"audit mode must say plainly that it built and bound nothing")
expect(not leaked(out), "audit output leaked the admin password: %r" % leaked(out))
# ============================================================ 2. BLUE/GREEN RECONCILE
reset()
rc, out = invoke()
expect(rc == 0, "reconcile must exit 0 after converging (tail=%r)" % out[-700:])
# policy + action
for key, value in WANT_POLICY.items():
expect(state["realm"].get(key) == value,
"policy key %s must end up at the declared value %r, got %r"
% (key, value, state["realm"].get(key)))
expect(state["actions"][ENROL_ACTION]["enabled"] is True,
"the enrolment required action must end up ENABLED")
expect(state["actions"][ENROL_ACTION]["defaultAction"] is False,
"the enrolment action must NOT become a defaultAction -- enrolment is "
"OPT-IN, and forcing it on every user at next login is the lock-out this "
"whole change is designed not to create")
# -- 2a. A NEW FLOW WAS BUILT AND BOUND, and it is NOT the old alias.
new_bound = state["realm"]["browserFlow"]
expect(new_bound != "browser-mfa",
"the realm must now be bound to a NEWLY BUILT flow, not to the old alias")
expect(new_bound.startswith("browser-mfa-"),
"the new flow's alias must derive from the declared alias, got %r"
% new_bound)
expect("BOUND browserFlow" in out and "atomic, one PUT" in out,
"the bind must be logged as the single atomic write it is")
# -- 2b. THE BOUND FLOW MATCHES GIT, verified through the real comparison.
built_shape, _ = read_executions(base, "homelab", "stub-token", new_bound)
expect(built_shape is not None, "the newly bound flow must be readable")
if built_shape is not None:
diff = compare_shape(built_shape,
expected_shape(WANT_FLOWS, "browser-mfa"),
base, "homelab", "stub-token", WANT_CONFIGS, "built")
expect(not diff, "the newly bound flow must match the declared shape, got %r"
% (diff[:3],))
expect("verified" in out, "the build must be verified before binding")
# -- 2c. NOTHING WROTE TO THE OLD BOUND TREE. The whole safety argument.
expect(not writes_to(LEGACY_ALIASES),
"NO write may target the previously bound flow or any of its sub-flows, "
"got %r" % (writes_to(LEGACY_ALIASES),))
# -- 2d. THE OLD FLOW STILL EXISTS -- it is the rollback target.
for a in LEGACY_ALIASES:
expect(a in state["flows"],
"the superseded flow %r must NOT be deleted: it is the rollback target"
% a)
expect("ROLLBACK is one write" in out,
"the run log must state the one-write rollback, naming the previous alias")
expect("browser-mfa" in out, "the rollback target alias must appear in the log")
# -- 2e. NOTHING IS EVER DELETED, by anyone, anywhere.
expect(not [t for t in state["touched"] if t.startswith("DELETE ")],
"the reconciler must never issue a DELETE, got %r"
% ([t for t in state["touched"] if t.startswith("DELETE ")],))
# -- 2f. the policy PUT carried every passwordless key (read-modify-write) and the
# bind PUT carried browserFlow. They are DISTINCT writes.
expect(any(set(POLICY_FIELDS) <= set(w) for w in state["realm_writes"]),
"one realm PUT must carry ALL %d passwordless keys (read-modify-write); a "
"partial body resets the omitted ones to Keycloak defaults. Carried: %r"
% (len(POLICY_FIELDS), state["realm_writes"]))
expect(state["bind_writes"] == [("browser-mfa", new_bound)],
"the bind must be EXACTLY ONE realm PUT that changes browserFlow, from the "
"old alias to the new one, got %r" % (state["bind_writes"],))
expect(state["realm"].get("bruteForceProtected") is True,
"a realm field the desired state does not declare must survive every PUT "
"untouched, got %r" % (state["realm"].get("bruteForceProtected"),))
expect(not leaked(out), "reconcile output leaked the admin password")
# ===================================================== 3. IDEMPOTENCE (load-bearing)
# A rebuild-and-reflip every 15 minutes would be a permanent write loop against the
# IdP and would churn the realm's login path forever. The second run must do NOTHING.
flows_after_first = set(state["flows"])
rc, out = invoke()
expect(rc == 0, "the second reconcile must exit 0")
expect(state["realm_writes"] == [] and state["action_writes"] == [],
"IDEMPOTENCE: a converged realm must take ZERO writes, got realm=%r "
"actions=%r" % (state["realm_writes"], state["action_writes"]))
expect(set(state["flows"]) == flows_after_first,
"IDEMPOTENCE: a converged realm must have NO new flow built, got %r"
% (set(state["flows"]) - flows_after_first,))
expect("matches git" in out,
"a converged bound flow must be reported as matching git")
rc, out = invoke("--audit")
expect(rc == 0, "audit must pass once the realm has converged")
expect("KEYCLOAK_PASSKEY_AUDIT_OK" in out,
"a clean audit must emit the sentinel the L2 test greps for")
# -- 3a. IDEMPOTENCE SURVIVES ALIAS SUFFIXING. A flow this program built carries a
# hash suffix on EVERY alias. If the comparison matched sub-flow aliases, a
# built flow could never be recognised as converged and would be rebuilt
# forever. This is that property, stated as its own assertion.
expect(any(a.startswith("browser-mfa-forms-") for a in state["flows"]),
"the built tree's sub-flows must carry suffixed aliases (that is what keeps "
"them out of the bound tree), got %r" % (sorted(state["flows"]),))
# -- 3b. a realm already converged from the start takes zero writes and builds
# nothing, even on a first run.
reset(policy_drifted=False, tree=desired_tree())
rc, out = invoke()
expect(rc == 0, "an already-converged realm must exit 0 (tail=%r)" % out[-300:])
expect(state["realm_writes"] == [] and state["action_writes"] == []
or state["action_writes"] != [],
"sanity")
expect(not [a for a in state["flows"] if a.startswith("browser-mfa-")
and a not in WANT_FLOWS],
"a realm whose BOUND flow already matches git must have NO new flow built, "
"got %r" % ([a for a in state["flows"] if a not in WANT_FLOWS],))
# ============================================= 4. THE RpEntityName GATE + READBACK
reset()
broken = copy.deepcopy(WANT_STATE)
broken["webAuthnPolicyPasswordless"][RP_ENTITY_NAME] = ""
write_state(broken)
rc, out = invoke()
expect(rc == 1, "a desired state with an empty RpEntityName must fail closed")
expect(state["realm_writes"] == [],
"an empty RpEntityName must produce NO realm write at all -- Keycloak would "
"accept it with 204 and silently drop the whole policy, got %r"
% (state["realm_writes"],))
expect("DESIRED STATE IS UNUSABLE" in out,
"the refusal must come from the DESIRED-STATE validation and explain that "
"the policy would be silently dropped. Asserting the specific stage matters: "
"there are two RpEntityName guards (validation, and the write path) and a "
"message-agnostic assertion would let either be deleted while the other "
"silently covered for it")
write_state()
# -- 4b. THE WRITE-PATH GUARDS, EXERCISED DIRECTLY. Unreachable while the
# desired-state validation holds, which makes them defence in depth -- and
# defence in depth that is never tested is indistinguishable from code that
# does not work.
try:
policy_write_body({"realm": "homelab", RP_ENTITY_NAME: ""},
dict(WANT_POLICY), {"webAuthnPolicyPasswordlessRpId": None})
expect(False, "policy_write_body must REFUSE a body whose RpEntityName is "
"empty -- Keycloak would accept the PUT with 204 and drop the "
"entire passwordless policy")
except RuntimeError as e:
expect("applies the passwordless policy only when" in str(e),
"the write-path refusal must name the 26.0.6 RpEntityName gate, got %r"
% (str(e),))
try:
policy_write_body({"realm": "homelab", RP_ENTITY_NAME: "Homelab SSO",
BROWSER_FLOW: "browser-mfa"},
dict(WANT_POLICY, **{BROWSER_FLOW: "browser"}),
{BROWSER_FLOW: None})
expect(False, "policy_write_body must REFUSE a body that changes browserFlow -- "
"rebinding the login path is bind_browser_flow()'s job and only "
"after a build has been verified")
except RuntimeError as e:
expect("never rebinds the browser flow" in str(e),
"the policy pass's browserFlow refusal must say so explicitly, got %r"
% (str(e),))
# -- 4c. READBACK IS LOAD-BEARING. A server that accepts the realm PUT with 204 and
# does not apply it must be reported as a FAILURE, not a repair.
reset()
state["lying_realm"] = True
rc, out = invoke()
expect(rc == 1, "a realm PUT accepted but not applied must FAIL the run")
expect("still does not match git" in out,
"the readback must say the write did not take effect")
expect(not [ln for ln in out.splitlines() if "policy REPAIRED" in ln],
"a policy write that did not take effect must NOT be logged as repaired")
# -- 4d. UNMANAGED MEANS UNTOUCHED, for policy keys too.
reset()
subset = copy.deepcopy(WANT_STATE)
del subset["webAuthnPolicyPasswordless"][
"webAuthnPolicyPasswordlessCreateTimeout"]
write_state(subset)
state["realm"]["webAuthnPolicyPasswordlessCreateTimeout"] = 999
rc, out = invoke()
expect(state["realm"]["webAuthnPolicyPasswordlessCreateTimeout"] == 999,
"a policy key the desired state does NOT declare must be left exactly as it "
"was, not reset to a default, got %r"
% (state["realm"].get("webAuthnPolicyPasswordlessCreateTimeout"),))
write_state()
# ======================== 5. THE BOUND FLOW IS STRUCTURALLY UNWRITABLE (property 3)
# 5a. FAIL CLOSED BEFORE THE BINDING IS KNOWN. Until reconcile_flow has read the
# live browserFlow, EVERY flow write must be refused -- so a code path that
# tried to build before discovering what is live cannot.
_reset_protected()
expect(_refused("POST", "/admin/realms/homelab/authentication/flows"),
"FAIL CLOSED: creating a flow must be refused while the bound flow is "
"UNDETERMINED -- otherwise a build could race ahead of the safety check")
expect(_refused("POST", "/admin/realms/homelab/authentication/flows/anything/"
"executions/execution"),
"FAIL CLOSED: adding an execution must be refused while the bound flow is "
"undetermined")
expect(_refused("POST", "/admin/realms/homelab/authentication/executions/ex-1/"
"config"),
"FAIL CLOSED: binding a config must be refused while the bound flow is "
"undetermined")
# ...but READS are always fine, and so are the realm/action writes, which cannot
# touch a flow's structure.
expect(not _refused("GET", "/admin/realms/homelab/authentication/flows"),
"reads must never be blocked by the protected-subtree screen")
expect(not _refused("PUT", "/admin/realms/homelab"),
"the realm PUT must not be blocked by the flow screen -- it is screened by "
"policy_write_body and bind_browser_flow instead")
# 5b. WITH THE BINDING KNOWN, the bound flow and its WHOLE SUBTREE are unwritable,
# and anything else is writable. Protecting the subtree matters: adding an
# execution to `browser-mfa-forms` changes live login behaviour just as surely
# as touching `browser-mfa`.
_set_protected({"browser-mfa", "browser-mfa-forms", "browser-mfa-conditional-otp"},
{"ex-1", "ex-2"})
expect(_refused("POST", "/admin/realms/homelab/authentication/flows/browser-mfa/"
"executions/execution"),
"adding an execution to the BOUND flow must be refused")
expect(_refused("POST", "/admin/realms/homelab/authentication/flows/"
"browser-mfa-forms/executions/flow"),
"adding a sub-flow to a SUB-FLOW OF THE BOUND FLOW must be refused -- it "
"changes live login behaviour just as surely as the top-level flow does")
expect(_refused("PUT", "/admin/realms/homelab/authentication/flows/"
"browser-mfa-conditional-otp/executions"),
"setting a requirement inside the bound subtree must be refused -- this is "
"the write that turns an ALTERNATIVE fork into a REQUIRED one")
expect(_refused("POST", "/admin/realms/homelab/authentication/executions/ex-1/"
"config"),
"binding a config to an execution OF THE BOUND FLOW must be refused")
expect(not _refused("POST", "/admin/realms/homelab/authentication/flows/"
"browser-mfa-abc123/executions/execution"),
"a flow OUTSIDE the bound subtree must be writable -- that is what makes "
"blue/green possible at all")
# An execution-id-addressed write needs BOTH: outside the bound subtree, AND inside
# the tree this run is building. Outside the bound subtree alone is not enough --
# every other flow in the realm is outside it too.
expect(_refused("POST", "/admin/realms/homelab/authentication/executions/"
"ex-99/config"),
"binding a config to an execution that is NOT part of the tree being built "
"must be refused, even when it is outside the bound subtree")
_set_build_scope({"ex-99"})
expect(not _refused("POST", "/admin/realms/homelab/authentication/executions/"
"ex-99/config"),
"an execution of the tree being built must be writable")
_set_build_scope(None)
# 5b'. FINDING 3: PUT /flows/<alias>/executions is screened on body["id"], not just
# the path. Keycloak's updateExecutions resolves the execution REALM-WIDE by id,
# so a candidate alias in the path says nothing about what is written.
EXEC_PUT = "/admin/realms/homelab/authentication/flows/browser-mfa-abc123/executions"
_set_build_scope({"ex-50", "ex-51"})
expect(_refused("PUT", EXEC_PUT,
{"id": "ex-1", "requirement": "DISABLED", "priority": 0}),
"a requirement PUT whose path names the CANDIDATE flow but whose body id is an "
"execution of the BOUND flow must be refused -- updateExecutions never checks "
"the id belongs to the path's flow, so this would DISABLE a live login step")
expect(_refused("PUT", EXEC_PUT,
{"id": "ex-77", "requirement": "DISABLED", "priority": 0}),
"a requirement PUT whose body id is in NEITHER the bound subtree NOR the tree "
"being built must be refused -- it would edit some other flow in the realm")
expect(_refused("PUT", EXEC_PUT, {"requirement": "DISABLED", "priority": 0}),
"a requirement PUT with NO id must be refused")
expect(_refused("PUT", EXEC_PUT,
{"id": "ex-50", "requirement": "REQUIRED", "priority": 0,
"authenticationFlow": True, "flowId": "flowid-browser-mfa",
"displayName": "renamed"}),
"a requirement PUT carrying flowId/displayName must be refused -- the same "
"handler RENAMES whatever flow flowId names, bound or not")
expect(not _refused("PUT", EXEC_PUT,
{"id": "ex-50", "requirement": "REQUIRED", "priority": 3}),
"a requirement PUT on an execution of the tree being built must be allowed")
_set_build_scope(None)
expect(_refused("PUT", EXEC_PUT,
{"id": "ex-50", "requirement": "REQUIRED", "priority": 3}),
"with no build in progress, EVERY requirement PUT must be refused")
_reset_protected()
# 5e. THE BOUND FLOW CANNOT BE READ -> FAIL CLOSED, build nothing. Without the
# bound subtree the program cannot prove a build would miss it, so it must
# refuse to build at all rather than guess. browserFlow here names a flow that
# does not exist, so its executions listing 404s.
reset(bound="a-flow-that-does-not-exist")
rc, out = invoke()
expect(rc == 1, "an unreadable bound flow must FAIL the run")
expect("FAIL-CLOSED" in out,
"the refusal must say the flow pass stayed fail-closed, got tail=%r"
% out[-400:])
expect(not [a for a in state["flows"] if a not in LEGACY_ALIASES],
"with the live binding unknown NOTHING may be built -- the program cannot "
"prove a build would miss the live flow, got %r"
% ([a for a in state["flows"] if a not in LEGACY_ALIASES],))
expect(state["bind_writes"] == [],
"and nothing may be bound, got %r" % (state["bind_writes"],))
# the policy pass still ran: INDEPENDENCE.
expect(any(set(POLICY_FIELDS) <= set(w) for w in state["realm_writes"]),
"INDEPENDENCE: a fail-closed FLOW pass must not disable the policy pass")
# 5c. the endpoints that are not on the allow-list AT ALL, bound or not.
_set_protected(set(), set())
expect(_refused("DELETE", "/admin/realms/homelab/authentication/flows/anything"),
"DELETING a flow must be off the allow-list entirely -- a superseded flow is "
"the rollback target and must survive")
expect(_refused("DELETE", "/admin/realms/homelab/authentication/executions/ex-1"),
"DELETING an execution must be off the allow-list")
expect(_refused("PUT", "/admin/realms/homelab/authentication/executions/ex-1"),
"the single-execution update endpoint must be off the allow-list -- "
"requirements are set through the flow-scoped endpoint, which the "
"protected-subtree screen covers")
expect(_refused("PUT", "/admin/realms/homelab/authentication/config/cfg-1"),
"EDITING an authenticator config must be off the allow-list -- the "
"conditional-OTP role lives here, and a new flow gets a NEW config")
expect(_refused("PUT",
"/admin/realms/homelab/authentication/required-actions/"
"CONFIGURE_TOTP"),
"writing ANY required action other than the enrolment one must be refused -- "
"disabling CONFIGURE_TOTP locks out every platform-admin")
expect(_refused("DELETE",
"/admin/realms/homelab/authentication/required-actions/"
"webauthn-register-passwordless"),
"DELETING even the one action it may update must be refused")
expect(_refused("POST",
"/admin/realms/homelab/authentication/register-required-action"),
"registering a required action must be refused")
expect(_refused("DELETE", "/admin/realms/homelab"),
"DELETING THE REALM must be refused")
expect(_refused("GET", "/admin/realms/homelab/users"),
"a users endpoint must be refused before opening a socket")
expect(_refused("GET", "/admin/realms/homelab/groups"),
"a groups endpoint must be refused")
expect(_refused("GET", "/admin/realms/homelab/roles"),
"a roles endpoint must be refused")
expect(_refused("PUT", "/admin/realms/homelab/clients/uuid-grafana"),
"a client write must be refused -- that is the SIBLING reconciler's surface, "
"and the two must not overlap")
_reset_protected()
# 5d. in a REAL run, every endpoint touched is one of the allow-listed shapes.
reset()
invoke()
for touched in state["touched"]:
expect(re.fullmatch(
r"POST /realms/master/protocol/openid-connect/token"
r"|(GET|PUT) /admin/realms/homelab"
r"|GET /admin/realms/homelab/authentication/flows"
r"|(GET) /admin/realms/homelab/authentication/flows/[^/]+/executions"
r"|PUT /admin/realms/homelab/authentication/flows/[^/]+/executions"
r"|POST /admin/realms/homelab/authentication/flows"
r"|POST /admin/realms/homelab/authentication/flows/[^/]+/executions/"
r"(flow|execution)"
r"|POST /admin/realms/homelab/authentication/executions/[^/]+/config"
r"|GET /admin/realms/homelab/authentication/config/[^/]+"
r"|GET /admin/realms/homelab/authentication/required-actions"
r"|(GET|PUT) /admin/realms/homelab/authentication/required-actions/"
r"webauthn-register-passwordless",
touched), "the reconciler touched an off-list endpoint: %s" % touched)
expect(not [t for t in state["touched"]
if "/users" in t or "/groups" in t or "/roles" in t
or "/clients" in t],
"the reconciler must never touch users/groups/roles/clients")
# ============================== 6. A FAILED BUILD LEAVES LOGIN COMPLETELY UNAFFECTED
# This is the property that makes blue/green safe, and the reason the original
# audit-only design was unnecessary. Kill the build part-way through and assert that
# the realm is still bound to the OLD flow.
for budget_limit in (1, 3, 6, 9):
reset()
state["fail_build_after"] = budget_limit
rc, out = invoke()
expect(rc == 1, "a build that dies part-way must FAIL the run (budget=%d)"
% budget_limit)
expect(state["realm"]["browserFlow"] == "browser-mfa",
"A HALF-BUILT FLOW MUST NEVER BE BOUND: after a build failure at write "
"%d the realm must still be bound to the OLD flow, got %r"
% (budget_limit, state["realm"]["browserFlow"]))
expect("login is UNAFFECTED" in out or "UNAFFECTED" in out,
"the failure must state plainly that login is unaffected (budget=%d)"
% budget_limit)
expect(not writes_to(LEGACY_ALIASES),
"a failing build must still not have written to the bound tree "
"(budget=%d), got %r" % (budget_limit, writes_to(LEGACY_ALIASES)))
for a in LEGACY_ALIASES:
expect(a in state["flows"],
"the live flow %r must survive a failed build (budget=%d)"
% (a, budget_limit))
# ============================= 7. VERIFY-BEFORE-BIND IS LOAD-BEARING, NOT DECORATIVE
# 7a. a server that accepts the requirement writes and applies the WRONG value.
# The built flow is then not what git declares, so it must NOT be bound.
reset()
state["sabotage"] = "requirement"
rc, out = invoke()
expect(rc == 1, "a build whose requirements did not take must FAIL the run")
expect(state["realm"]["browserFlow"] == "browser-mfa",
"a built flow that does NOT match git must NOT BE BOUND, got browserFlow=%r"
% (state["realm"]["browserFlow"],))
expect("NOT BOUND" in out,
"the report must say the flow was not bound")
expect("DISABLED" in out,
"the report must name the wrong requirement the realm ended up with")
# 7b. THE CONDITIONAL-OTP REGRESSION, caught BEFORE it can bite. The server writes
# the role under `condition.evaluated.user.role` -- the exact historical bug,
# which resolved the role to null, made the REQUIRED conditional step throw
# "Invalid role name submitted: null", and 403'd every platform-admin out of
# every SSO-gated app. Because verification runs BEFORE the bind, that flow
# never becomes the live login path.
reset()
state["sabotage"] = "config"
rc, out = invoke()
expect(rc == 1, "a conditional-OTP config written under the wrong key must FAIL")
expect(state["realm"]["browserFlow"] == "browser-mfa",
"THE CONDITIONAL-OTP REGRESSION MUST NEVER BE BOUND: the realm must still "
"be on the old flow, got %r" % (state["realm"]["browserFlow"],))
expect("CONDITIONAL-OTP REGRESSION" in out,
"the report must identify this as the known regression shape, so whoever "
"reads the Job log recognises the 403 it causes")
expect("condUserRole" in out,
"the report must name the key that is missing (condUserRole)")
expect("condition.evaluated.user.role" in out,
"the stale key must be named as an undeclared key -- it is the signature of "
"the original null-role bug")
# 7c. the same regression ALREADY LIVE in the bound flow is reported, and triggers a
# rebuild rather than being left alone.
bad_tree = desired_tree()
bad_tree["browser-mfa-conditional-otp"][0]["cfg"] = {
"condition.evaluated.user.role": "platform-admin", "negate": "false"}
reset(policy_drifted=False, tree=bad_tree)
rc, out = invoke()
expect("CONDITIONAL-OTP REGRESSION" in out,
"a live conditional-OTP regression in the BOUND flow must be reported")
expect(rc == 0, "and it must be FIXED by building and binding a correct flow "
"(tail=%r)" % out[-400:])
expect(state["realm"]["browserFlow"] != "browser-mfa",
"the fix is a new bound flow, got %r" % state["realm"]["browserFlow"])
# ================================ 8. RECOVERY: reuse, leftovers, and the alias budget
# 8a. a COMPLETED but UNBOUND build from an earlier run is REUSED, not rebuilt. That
# is what makes a crashed run recoverable without flow sprawl.
reset()
rc, out = invoke() # first run builds + binds
built_alias = state["realm"]["browserFlow"]
state["realm"]["browserFlow"] = "browser-mfa" # pretend the bind was lost
flows_before = set(state["flows"])
rc, out = invoke()
expect(rc == 0, "a run facing an already-built, verified, unbound flow must exit 0")
expect(state["realm"]["browserFlow"] == built_alias,
"it must BIND the existing verified flow %r, got %r"
% (built_alias, state["realm"]["browserFlow"]))
expect(set(state["flows"]) == flows_before,
"it must NOT build a second copy, got %r"
% (set(state["flows"]) - flows_before,))
expect("reusing" in out, "the reuse must be logged as such")
# 8b. a HALF-BUILT leftover under the primary candidate alias is left alone and the
# next candidate is used. The leftover is never deleted.
reset()
state["fail_build_after"] = 3
invoke() # leaves a half-built flow
half = [a for a in state["flows"] if a.startswith("browser-mfa-")
and a not in LEGACY_ALIASES]
expect(len(half) >= 1, "the failed build must have left a partial flow behind")
state["fail_build_after"] = None
rc, out = invoke()
expect(rc == 0, "the next run must recover by using a different candidate alias "
"(tail=%r)" % out[-500:])
expect(state["realm"]["browserFlow"] not in half + list(LEGACY_ALIASES),
"it must bind a FRESH flow, not the half-built leftover, got %r"
% state["realm"]["browserFlow"])
expect("-r2" in state["realm"]["browserFlow"],
"the second candidate alias must carry the retry marker, got %r"
% state["realm"]["browserFlow"])
for a in half:
expect(a in state["flows"],
"the half-built leftover %r must NOT be deleted (nothing here deletes); "
"an operator cleans it up" % a)
expect("half-built leftover" in out,
"the leftover must be named in the log so an operator can clean it up")
# 8c. the alias budget is BOUNDED, so a persistently failing build cannot create
# unbounded flow sprawl. Occupy every candidate with a mismatching flow.
reset()
digest = shape_digest(WANT_STATE)
for n in range(1, MAX_BUILD_ATTEMPTS + 1):
suffix = digest if n == 1 else "%s-r%d" % (digest, n)
state["flows"]["browser-mfa-" + suffix] = {
"topLevel": True,
"executions": [{"id": new_id("ex"), "kind": "authenticator",
"name": "auth-cookie", "requirement": "DISABLED",
"priority": 0}]}
rc, out = invoke()
expect(rc == 1, "with every candidate alias occupied by a mismatching flow the run "
"must FAIL rather than invent unbounded aliases")
expect(state["realm"]["browserFlow"] == "browser-mfa",
"and it must leave the old flow bound")
expect("could not find an unused alias" in out,
"the exhaustion must be reported explicitly, with what an operator should do")
# ========================================================== 9. --no-bind (the fallback)
# Build and VERIFY but do not flip. The deliberate fallback if an operator wants the
# go-live to be a human act: everything is automated except one atomic write.
reset()
rc, out = invoke("--no-bind")
expect(rc == 0, "--no-bind must exit 0 after building and verifying (tail=%r)"
% out[-400:])
expect(state["realm"]["browserFlow"] == "browser-mfa",
"--no-bind must NOT flip browserFlow, got %r"
% state["realm"]["browserFlow"])
expect(state["bind_writes"] == [],
"--no-bind must make no write that CHANGES browserFlow. Note a policy "
"read-modify-write body legitimately carries the field UNCHANGED, which is "
"not a bind. Got %r" % (state["bind_writes"],))
built = [a for a in state["flows"] if a.startswith("browser-mfa-")
and a not in LEGACY_ALIASES]
expect(built, "--no-bind must still BUILD the flow")
expect("WOULD BIND" in out,
"--no-bind must report the verified candidate it WOULD bind")
expect("overridden to NO BIND" in out,
"--no-bind overriding git's \"true\" must be stated in the log")
# ========================================== 10. THE REQUIRED-ACTION BLAST RADIUS
# 10a. the import-REPLACE damage: CONFIGURE_TOTP is gone. Loud, and NOT repaired.
reset(policy_drifted=False, tree=desired_tree())
del state["actions"]["CONFIGURE_TOTP"]
rc, out = invoke()
expect(rc == 1, "a missing default required action must fail the run")
expect("CONFIGURE_TOTP" in out and "REPLACE" in out,
"the report must name the missing action and the import-REPLACE cause")
expect("lock-out" in out,
"the report must state the consequence: platform-admins cannot enrol the "
"TOTP that auth-otp-form still demands")
expect(not [w for w in state["action_writes"] if w[0] != ENROL_ACTION],
"no required action other than the enrolment one may ever be written, got %r"
% (state["action_writes"],))
# 10b. the enrolment action is not registered at all.
reset(policy_drifted=False, tree=desired_tree())
del state["actions"][ENROL_ACTION]
rc, out = invoke()
expect(rc == 1, "an unregistered enrolment action must fail the run")
expect("NOT REGISTERED" in out,
"the unregistered enrolment action must be named explicitly")
expect(state["action_writes"] == [],
"an unregistered action must not be written (the API 404s; re-registering is "
"not on the allow-list)")
# 10c. the required-action PUT is a FULL representation, not two fields.
reset(policy_drifted=False, tree=desired_tree())
rc, out = invoke()
expect(state["action_writes"] == [(ENROL_ACTION,
("alias", "config", "defaultAction", "enabled",
"name", "priority", "providerId"))],
"the required-action PUT must carry the FULL representation (it replaces "
"name/priority/config from the body), got %r" % (state["action_writes"],))
expect(state["actions"][ENROL_ACTION]["priority"] == 80,
"the required-action PUT must preserve priority, got %r"
% (state["actions"][ENROL_ACTION]["priority"],))
expect(state["actions"][ENROL_ACTION]["name"] == "Webauthn Register Passwordless",
"the required-action PUT must preserve the provider name")
# ================================================ 11. ORDERING AND POLICY SEMANTICS
# 11a. AAGUID ORDER-INSENSITIVITY: an allow-list is a set membership test, and an
# order-sensitive comparison of a list Keycloak may reorder would rewrite the
# realm on every run forever while reporting success.
reset(policy_drifted=False, tree=desired_tree())
skewed = copy.deepcopy(WANT_STATE)
skewed["webAuthnPolicyPasswordless"][
"webAuthnPolicyPasswordlessAcceptableAaguids"] = ["bbb", "aaa"]
write_state(skewed)
state["realm"]["webAuthnPolicyPasswordlessAcceptableAaguids"] = ["aaa", "bbb"]
rc, out = invoke()
expect(not any(set(POLICY_FIELDS) <= set(w) for w in state["realm_writes"]),
"AAGUID ORDER-INSENSITIVITY: a realm holding the declared AAGUIDs in a "
"different order is CONVERGED and must take zero policy writes, got %r"
% (state["realm_writes"],))
write_state()
# 11b. ...but SIGNATURE ALGORITHM order IS meaningful: it is a preference list.
reset(policy_drifted=False, tree=desired_tree())
state["realm"]["webAuthnPolicyPasswordlessSignatureAlgorithms"] = ["RS256", "ES256"]
rc, out = invoke()
expect(state["realm"]["webAuthnPolicyPasswordlessSignatureAlgorithms"]
== ["ES256", "RS256"],
"SIGNATURE ALGORITHM ORDER IS MEANINGFUL: a reordered preference list is "
"drift and must be corrected, got %r"
% (state["realm"]["webAuthnPolicyPasswordlessSignatureAlgorithms"],))
# 11c. THE updateExecutions PRIORITY TRAP. A requirement write that omits `priority`
# deserialises it as 0 in Keycloak and RESHUFFLES the execution order. Assert
# the built tree's order is the DECLARED order, which is what would break.
reset()
invoke()
bound_now = state["realm"]["browserFlow"]
shape_now, raw_now = read_executions(base, "homelab", "stub-token", bound_now)
expect([s[2] for s in shape_now if s[1] == "authenticator"]
== [e[2] for e in expected_shape(WANT_FLOWS, "browser-mfa")
if e[1] == "authenticator"],
"the built flow's authenticators must be in the DECLARED order -- a "
"requirement write that drops `priority` resets it to 0 and reshuffles the "
"tree, got %r" % ([s[2] for s in shape_now if s[1] == "authenticator"],))
expect(len({e["priority"] for e in raw_now if e["level"] == 0}) == 3,
"the top-level executions must hold DISTINCT priorities, got %r"
% ([e["priority"] for e in raw_now if e["level"] == 0],))
# ======================================================== 12. FAIL CLOSED on the input
# 12a. unmounted ConfigMap.
reset()
STATE_FILE = os.path.join(tmpdir, "does-not-exist.json")
rc, out = invoke()
expect(rc == 1, "a missing desired-state file must fail closed")
expect(state["realm_writes"] == [] and state["action_writes"] == [],
"FAIL CLOSED: an unreadable desired state must write NOTHING, got realm=%r "
"actions=%r" % (state["realm_writes"], state["action_writes"]))
expect("NOTHING WAS WRITTEN" in out,
"the fail-closed path must state plainly that nothing was written")
# 12b. a policy key this program may not write is REFUSED WHOLESALE.
reset()
rogue = copy.deepcopy(WANT_STATE)
rogue["webAuthnPolicyPasswordless"]["sslRequired"] = "none"
bad = os.path.join(tmpdir, "bad-key.json")
with open(bad, "w", encoding="utf-8") as fh:
json.dump(rogue, fh)
STATE_FILE = bad
rc, out = invoke()
expect(rc == 1, "a desired state naming a non-writable realm key must fail closed")
expect(state["realm_writes"] == [],
"a non-writable key must produce NO write at all, got %r"
% (state["realm_writes"],))
expect("not one this reconciler is allowed to write" in out,
"the refusal must name the problem as a non-writable key")
# 12c. the TWO-FACTOR webAuthnPolicy family is not writable either -- writing the
# policy into the wrong family is a silent no-op.
reset()
fam = copy.deepcopy(WANT_STATE)
fam["webAuthnPolicyPasswordless"]["webAuthnPolicyRpId"] = RP_ID
wrong = os.path.join(tmpdir, "wrong-family.json")
with open(wrong, "w", encoding="utf-8") as fh:
json.dump(fam, fh)
STATE_FILE = wrong
rc, out = invoke()
expect(rc == 1, "the two-factor webAuthnPolicy family must not be writable")
expect(state["realm_writes"] == [],
"a two-factor policy key must produce no write")
# 12d. an undeclared authenticatorConfig reference is refused -- it would otherwise
# make every build fail at the config-binding step, after writes had begun.
reset()
orphan = copy.deepcopy(WANT_STATE)
orphan["authenticatorConfig"] = {}
missing_cfg = os.path.join(tmpdir, "orphan-config.json")
with open(missing_cfg, "w", encoding="utf-8") as fh:
json.dump(orphan, fh)
STATE_FILE = missing_cfg
rc, out = invoke()
expect(rc == 1, "a flow referencing an undeclared authenticatorConfig must fail "
"closed BEFORE any build begins")
expect(not [a for a in state["flows"] if a not in LEGACY_ALIASES],
"no flow may be built from an invalid desired state, got %r"
% ([a for a in state["flows"] if a not in LEGACY_ALIASES],))
# 12e. an empty desired state is fail-closed, not "everything is unmanaged".
reset()
empty = os.path.join(tmpdir, "empty.json")
with open(empty, "w", encoding="utf-8") as fh:
fh.write("{}")
STATE_FILE = empty
rc, out = invoke()
expect(rc == 1, "an empty desired state must fail closed, not silently pass")
expect(state["realm_writes"] == [] and state["action_writes"] == [],
"an empty desired state must write nothing")
# 12f. a CYCLIC flow declaration must be caught, not recursed into forever.
reset()
cyclic = copy.deepcopy(WANT_STATE)
cyclic["flows"]["browser-mfa-conditional-otp"]["executions"].append(
{"kind": "flow", "name": "browser-mfa", "requirement": "REQUIRED"})
cyc = os.path.join(tmpdir, "cyclic.json")
with open(cyc, "w", encoding="utf-8") as fh:
json.dump(cyclic, fh)
STATE_FILE = cyc
rc, out = invoke()
expect(rc == 1, "a cyclic flow declaration must fail closed")
expect("cyclic" in out, "the cycle must be named as such")
STATE_FILE = GOOD_STATE
# ======================= 13. A FLOW THAT IS CORRECT BUT NOT BOUND IS STILL CONVERGED-TO
# The realm holds the declared structure under the declared aliases but browserFlow
# points at Keycloak's built-in `browser`. A perfect flow nobody logs in through
# changes nothing, so this must be FIXED -- by binding, not by rebuilding.
reset(policy_drifted=False, tree=desired_tree(), bound="browser")
state["flows"]["browser"] = {"topLevel": True, "executions": [
{"id": new_id("ex"), "kind": "authenticator", "name": "auth-cookie",
"requirement": "ALTERNATIVE", "priority": 0}]}
rc, out = invoke()
expect(rc == 0, "an unbound-but-correct realm must converge (tail=%r)" % out[-400:])
expect(state["realm"]["browserFlow"] != "browser",
"the realm must end up bound to a flow matching git, got %r"
% state["realm"]["browserFlow"])
expect("browser" in state["flows"],
"the previously bound built-in flow must NOT be deleted")
# ================== 14. FINDING 2: THE PROTECTED SUBTREE INCLUDES THE REAL SUB-FLOWS
# Discovered by a REAL run against the Keycloak-shaped listing (sub-flows named by
# displayName, no `alias`), not handed to _set_protected() by the test.
reset()
write_bind("false")
invoke()
expect(_PROTECTED_FLOW_ALIASES is not None
and set(LEGACY_ALIASES) <= set(_PROTECTED_FLOW_ALIASES),
"a real run must protect the bound flow AND every sub-flow beneath it, as "
"26.0.6 reports them (displayName on sub-flow rows), got %r"
% (sorted(_PROTECTED_FLOW_ALIASES or ()),))
expect(_refused("POST", "/admin/realms/homelab/authentication/flows/"
"browser-mfa-forms/executions/execution"),
"after a real run, adding an execution to a protected SUB-flow "
"(browser-mfa-forms) must be REFUSED -- this is the write the alias-based "
"discovery let through")
expect(_refused("PUT", "/admin/realms/homelab/authentication/flows/"
"browser-mfa-conditional-otp/executions",
{"id": "ex-1", "requirement": "DISABLED", "priority": 0}),
"after a real run, a requirement write into the protected conditional-OTP "
"SUB-flow must be REFUSED")
expect(not [a for a in _PROTECTED_FLOW_ALIASES or () if a.startswith("Display: ")],
"an AUTHENTICATOR's displayName must never be mistaken for a sub-flow alias")
_reset_protected()
# 14b. a sub-flow row WITHOUT a displayName: the subtree cannot be named, so the flow
# pass must stay FAIL-CLOSED -- build nothing, bind nothing -- rather than
# protect a smaller set than is live.
reset()
write_bind("true")
state["hide_display_name"] = True
before_flows = set(state["flows"])
rc, out = invoke()
expect(rc == 1, "an unnameable sub-flow in the bound tree must FAIL the run")
expect("FAIL-CLOSED" in out,
"an unnameable sub-flow must leave the flow pass fail-closed, got tail=%r"
% out[-300:])
expect(set(state["flows"]) == before_flows and state["bind_writes"] == [],
"with the bound subtree unnameable NOTHING may be built or bound, got new=%r "
"binds=%r" % (set(state["flows"]) - before_flows, state["bind_writes"]))
# ===================== 15. FINDING 1: THE BIND SWITCH. "false" NEVER PUTs browserFlow
# 15a. first run with git saying "false": builds + verifies, binds NOTHING, exit 0.
reset()
write_bind("false")
rc, out = invoke()
expect(rc == 0, "bind=false must exit 0 -- an unbound verified candidate is the "
"DECLARED state, not a failure (tail=%r)" % out[-400:])
expect(state["bind_writes"] == [],
"bind=false: NO realm PUT may change browserFlow, got %r"
% (state["bind_writes"],))
expect(state["realm"]["browserFlow"] == "browser-mfa",
"bind=false: the realm must still be bound to the old flow, got %r"
% state["realm"]["browserFlow"])
cand = [a for a in state["flows"] if a.startswith("browser-mfa-")
and a not in LEGACY_ALIASES and a.count("-") == 2]
expect(len(cand) == 1, "bind=false must still BUILD exactly one candidate, got %r"
% (cand,))
expect("WOULD BIND" in out and (cand and cand[0] in out),
"bind=false must name the candidate it WOULD bind")
expect("KEYCLOAK_PASSKEY_RECONCILED_OK_PENDING_BIND" in out
and "KEYCLOAK_PASSKEY_RECONCILED_OK:" not in out,
"bind=false must NOT claim the bound flow matches git")
expect(state["realm"]["webAuthnPolicyPasswordlessRpId"] == RP_ID
and state["actions"][ENROL_ACTION]["enabled"] is True,
"bind=false gates ONLY the binding: passes 1 and 2 must still converge")
# 15b. and every later bind=false run is a no-op: reuse, zero writes of any kind.
flows_now = set(state["flows"])
rc, out = invoke()
writes = [t for t in state["touched"] if t.split(" ")[0] in _WRITE_METHODS
and not t.endswith("/openid-connect/token")]
expect(rc == 0 and writes == [] and set(state["flows"]) == flows_now,
"a second bind=false run must make ZERO writes and build nothing, got "
"rc=%r writes=%r new=%r" % (rc, writes, set(state["flows"]) - flows_now))
expect("reusing" in out and "WOULD BIND" in out,
"the second bind=false run must reuse the verified candidate and still report "
"what it would bind")
# 15c. bind=false AFTER A PREVIOUS BIND: the operator's rollback STICKS. Bind with
# "true", then the operator flips the PR to "false" and rebinds the old flow.
reset()
write_bind("true")
invoke()
was_bound = state["realm"]["browserFlow"]
expect(was_bound != "browser-mfa", "precondition: the bind=true run must have bound")
write_bind("false")
state["realm"]["browserFlow"] = "browser-mfa" # the operator's rollback write
flows_now = set(state["flows"])
for n in (1, 2, 3):
rc, out = invoke()
writes = [t for t in state["touched"] if t.split(" ")[0] in _WRITE_METHODS
and not t.endswith("/openid-connect/token")]
expect(state["bind_writes"] == [] and writes == [],
"ROLLBACK MUST STICK: run %d after bind=false must make NO write at all "
"(binds=%r writes=%r)" % (n, state["bind_writes"], writes))
expect(state["realm"]["browserFlow"] == "browser-mfa",
"ROLLBACK MUST STICK: run %d rebound %r over the operator's rollback"
% (n, state["realm"]["browserFlow"]))
expect(rc == 0, "a rolled-back realm under bind=false must exit 0 (run %d)" % n)
expect(set(state["flows"]) == flows_now,
"a rolled-back realm under bind=false must not build anything, got %r"
% (set(state["flows"]) - flows_now,))
# 15d. bind=false and an operator HOTFIX inside the bound candidate: left alone --
# no rebuild beside it (no -r2) and no rebind.
reset()
write_bind("true")
invoke()
live = state["realm"]["browserFlow"]
write_bind("false")
state["flows"][live]["executions"][0]["requirement"] = "DISABLED" # the hotfix
flows_now = set(state["flows"])
rc, out = invoke()
expect(state["realm"]["browserFlow"] == live and state["bind_writes"] == [],
"bind=false: a hand-edited bound flow must stay bound, got %r binds=%r"
% (state["realm"]["browserFlow"], state["bind_writes"]))
expect(set(state["flows"]) == flows_now,
"bind=false: a hand-edited bound flow must not spawn a replacement build "
"(no -r2), got %r" % (set(state["flows"]) - flows_now,))
expect("LEFT ALONE" in out, "the hand-edited bound flow must be reported as left alone")
expect(state["flows"][live]["executions"][0]["requirement"] == "DISABLED",
"the operator's hotfix must not be reverted")
# 15e. an ABSENT bind file reads as "false" (a ConfigMap without the key binds
# nothing), and an INVALID one stops the whole run before any write.
reset()
write_bind(None)
rc, out = invoke()
expect(state["bind_writes"] == [] and state["realm"]["browserFlow"] == "browser-mfa",
"an ABSENT bind-browser-flow must mean NO BIND, got %r"
% (state["bind_writes"],))
expect("bind-browser-flow (git): false" in out,
"the effective switch must be logged")
reset()
write_bind("yes")
rc, out = invoke()
expect(rc == 1 and state["realm_writes"] == [] and state["action_writes"] == []
and not [t for t in state["touched"] if t.startswith("POST /admin")],
"an INVALID bind-browser-flow must stop the run before ANY write, got rc=%r "
"realm=%r" % (rc, state["realm_writes"]))
expect("NOTHING WAS WRITTEN" in out, "the invalid switch must be named as fatal")
write_bind("true")
srv.shutdown()
print("")
if failures:
print("SELFTEST FAIL: %d of %d assertions failed" % (len(failures), checks[0]))
for f in failures:
print(" FAIL %s" % f)
return 1
print("SELFTEST OK: %d assertions (blue/green build-verify-flip, the git bind switch "
"and a rollback that sticks, the bound flow AND its 26.0.6-shaped sub-flows are "
"unwritable and fail-closed, body-id screening of the requirement PUT, a failed "
"build never binds, verify-before-bind, "
"the conditional-OTP regression, idempotence with zero writes, recovery and a "
"bounded alias budget, nothing is ever deleted, strict read-modify-write, the "
"RpEntityName gate, required-action blast radius, fail-closed inputs)"
% checks[0])
return 0
def main():
argv = sys.argv[1:]
if "--selftest" in argv:
return _selftest()
return run(argv, lambda m: print(m, flush=True))
if __name__ == "__main__":
sys.exit(main())
---
# CronJob — the reconcile loop. Every 15 minutes, matching the client-secret
# reconciler, so a policy edited in git converges within one interval instead of
# waiting for someone to notice that passkey login does not work. Runs are cheap: a
# converged realm performs 5 GETs and ZERO writes.
#
# A FAILING JOB IS THE ALARM. There is no failure that is "expected by design": with
# bind-browser-flow "false" an unbound, verified candidate is a SUCCESSFUL run (it is
# the declared state), so the steady state is GREEN either way. A failed Job raises
# KeycloakPasskeyReconcilerFailing (platform/monitoring/alerts.yaml, group
# platform-health.identity; runbooks/keycloak-passkey-reconciler.md), unit-tested in
# platform/monitoring/tests/keycloak-passkey-reconciler.test.yaml.
apiVersion: batch/v1
kind: CronJob
metadata:
name: keycloak-passkey-reconciler
namespace: keycloak
labels:
app.kubernetes.io/name: keycloak-passkey-reconciler
app.kubernetes.io/part-of: keycloak
spec:
schedule: "*/15 * * * *"
# Forbid: a run still waiting for Keycloak must not be lapped by the next tick.
# Two concurrent reconcilers would converge to the same values, but during an
# outage recovery they would double the write attempts against the IdP.
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 1
# Three, like the sibling: keeping the last three failures makes the Job log
# readable without letting completed pods accumulate.
failedJobsHistoryLimit: 3
jobTemplate:
spec:
# ZERO retries. Every failure this program reports is DETERMINISTIC within a tick
# (drift it may not repair, a refused write, a build that did not verify), so a
# retry repeats it -- and a retried BUILD failure consumes another of the
# MAX_BUILD_ATTEMPTS candidate aliases each time, burning the bounded budget in
# one tick instead of one per 15 minutes. The next scheduled run is the retry.
backoffLimit: 0
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app.kubernetes.io/name: keycloak-passkey-reconciler
app.kubernetes.io/part-of: keycloak
spec:
restartPolicy: Never
# The reconciler talks ONLY to the Keycloak admin API. It needs no
# Kubernetes API access at all — its desired state is a mounted ConfigMap,
# not a read of the KeycloakRealmImport CR — so it gets no ServiceAccount
# token (R-SEC-02, least privilege). This is also why NO rbac.yaml is added
# anywhere by this change: reading the CR through the API would have needed
# a Role, a RoleBinding and a token, which is a strictly wider blast radius
# than mounting a generated file whose generation the merge gate verifies.
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
# Mirrors the sibling reconciler: fsGroup makes the projected volumes
# group-readable by 65532 rather than root-owned and unreadable.
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: reconciler
# Stock upstream python; the reconciler is pure standard library, so
# nothing is installed at runtime. Same tag as the client-secret
# reconciler. PINNED BY DIGEST (the multi-arch index digest, read from
# Docker Hub 2026-10-29) because the official-image tag is MUTABLE: the
# sibling's pod on prod runs a different digest under this same tag.
# Renovate's `kubernetes` manager covers platform/** (renovate.json5), so
# it proposes tag and digest bumps for this line like any other image.
image: python:3.13.15-alpine3.23@sha256:6438599575cca0d1df94aeee0d2ae088d4d8846eab554b2ee7784a3a6df0d516
imagePullPolicy: IfNotPresent
command:
- python3
- /opt/reconciler/reconcile-keycloak-passkeys.py
env:
- name: KEYCLOAK_BASE_URL
value: http://keycloak-service.keycloak.svc.cluster.local:8080
- name: KEYCLOAK_REALM
value: homelab
# readOnlyRootFilesystem is on; keep CPython from trying to write
# bytecode caches beside the read-only ConfigMap mount.
- name: PYTHONDONTWRITEBYTECODE
value: "1"
- name: PYTHONUNBUFFERED
value: "1"
# Break-glass admin (R-IDP-04). The SAME Secret the Keycloak CR uses
# to bootstrap the admin and the client-secret reconciler already
# consumes; keys admin-user / admin-password. Referenced BY NAME only
# — never decrypted here, never logged, and the script never echoes a
# token-endpoint response body because that request carried it.
- name: KEYCLOAK_ADMIN_USER
valueFrom:
secretKeyRef:
name: keycloak-admin-credentials
key: admin-user
- name: KEYCLOAK_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-admin-credentials
key: admin-password
volumeMounts:
- name: script
mountPath: /opt/reconciler
readOnly: true
# The DESIRED STATE: the passwordless policy, the enrolment required
# action and the declared flow tree (passkey-desired-state.json), plus
# git's bind switch (bind-browser-flow) -- both keys of one ConfigMap,
# so both arrive through this one mount. The JSON is GENERATED from
# keycloak-realm-import.yaml by ci/render-keycloak-passkey-policy.py
# so the realm import stays the single source of truth. Mounted rather
# than read through the Kubernetes API for the reason given above: this
# pod has no API access at all.
- name: desired-state
mountPath: /etc/keycloak-passkey
readOnly: true
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 48Mi
limits:
memory: 128Mi
volumes:
- name: script
configMap:
name: keycloak-passkey-reconciler
defaultMode: 0444
# 0444 is fine where a Secret needs 0440: an RP ID, a user-verification
# setting and a flow tree are public configuration, not credentials.
- name: desired-state
configMap:
name: keycloak-passkey-desired-state
defaultMode: 0444