PR #72 declared the WebAuthn passwordless policy and a restructured browser-mfa flow in the realm import. That declaration could not take effect: a KeycloakRealmImport is a ONE-SHOT, and prod's reads Done=True / Started=False, so every realm-level field and flow declared after the first import is inert -- in git, applied by Flux, reported Ready, reaching the realm never. Merging it changed nothing about how anyone logs in, and nothing anywhere said so. Same failure class as the 2026-09-06 client-secret incident, third field over. A SIBLING reconciler converges all three declared things every 15 minutes. It is a sibling, not a second pass of client-secret-reconciler.yaml, so that reconciler's allow-list -- and its guarantee that prod's real user and the media-users / platform-admins / viewers groups are structurally unreachable -- stays literally true. Its selftest still passes unchanged. pass 1 webAuthnPolicyPasswordless* -- strict read-modify-write of GET/PUT /admin/realms/homelab, readback-verified. pass 2 webauthn-register-passwordless -- strict read-modify-write of ONE literally-named alias; enabled, defaultAction false so enrolment stays opt-in. pass 3 the browser flow -- BLUE/GREEN. PASS 3 NEVER MUTATES THE FLOW USERS ARE LOGGING IN THROUGH. Converging a five-flow, ten-execution tree needs create-subflow, create-execution, set-requirement and bind-config as SEPARATE untransacted calls; doing that to the BOUND flow means a run dying between them leaves a half-built login path for every SSO app and kubectl, and this repo has had a total SSO outage from a realm change. So instead: 1. BUILD under a NEW, unused alias (declared alias + a hash of the desired shape, applied to EVERY alias in the tree -- reusing browser-mfa-forms would mean writing INTO the bound tree). A failure leaves an orphaned, unbound flow and zero user-visible effect. 2. VERIFY by readback -- levels, ordering, authenticators, requirements, and the conditional-OTP config CONTENTS -- before anything is bound, so the regression that once 403'd every platform-admin is caught BEFORE it bites. 3. BIND with ONE atomic PUT setting browserFlow. 4. ROLLBACK is one write; no DELETE exists anywhere in the allow-list, so the old flow survives as the rollback target. The widened allow-list is made safe structurally. It cannot say "but not the bound flow" -- its write entries are alias-parameterised -- so a second guard screens every write against the live browserFlow's WHOLE subtree (sub-flow aliases and execution ids) before a socket opens, FAIL-CLOSED until that binding has been read. The create-flow endpoint carries no alias in its path, so it is screened on its request BODY; the selftest caught that it otherwise escaped the guard. Idempotence is load-bearing: a rebuild-and-reflip every 15 minutes would churn the login path on a schedule. The bound flow is compared STRUCTURALLY and a match produces zero writes and no new flow. Sub-flow and config ALIASES are deliberately not compared -- a built flow carries a hash suffix, so comparing labels would make it permanently unrecognisable as converged. Single source of truth: the desired state is GENERATED from the realm import by ci/render-keycloak-passkey-policy.py and gated with --check, so the RP ID -- baked into every credential at enrolment and never re-keyable -- is declared once. The pod keeps automountServiceAccountToken: false and reads a mounted ConfigMap, so no rbac.yaml is added anywhere. Read from Keycloak 26.0.6 source before being relied on: updateRealm (DefaultExportImportManager 725-894) makes no getUsers/getClients/getGroups/ getRoles/getRequiredActions/getAuthenticationFlows call; it DOES read getBrowserFlow(), which is what makes the atomic bind possible and why the policy write pins it; the passwordless policy is applied all-or-nothing only when RpEntityName is non-empty, with defaults substituted for omitted keys; and updateExecutions compares rep.getPriority(), so a body omitting `priority` would silently reshuffle the whole execution order. Gate coverage: a new whole-repo L0 check extracts the script and runs its --selftest (216 assertions, including a build killed at four different points each asserting the old flow is still bound and the bound subtree was never written), plus mutations.py (30 injected regressions, all caught) proving the selftest can fail. An earlier iteration of this work only AUDITED the flow and left the restructure manual; decisions/2026-10-18-keycloak-passkey-reconciler.md records that design and why it was reconsidered. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2969 lines
165 KiB
YAML
2969 lines
165 KiB
YAML
---
|
|
# =============================================================================
|
|
# Keycloak PASSKEY RECONCILER (R-IDP-03, R-IDP-04)
|
|
#
|
|
# THE FAILURE THIS EXISTS FOR, MEASURED ON PROD 2026-10-18
|
|
# -------------------------------------------------------
|
|
# platform/keycloak/keycloak-realm-import.yaml declares the WebAuthn PASSWORDLESS
|
|
# policy and a restructured `browser-mfa` browser flow (merged as PR #72). That
|
|
# declaration CANNOT TAKE EFFECT:
|
|
#
|
|
# 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 the operator never
|
|
# re-runs it, 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. Merging the passkey block changed nothing about how
|
|
# anyone logs in, and nothing anywhere said so.
|
|
#
|
|
# This is the SAME failure class platform/keycloak/client-secret-reconciler.yaml
|
|
# already closes for client secrets (2026-09-06: all 12 prod clients holding the
|
|
# literal `(env:...)` placeholder behind an entirely green cluster) and for
|
|
# environment-derived client URLs. This CronJob closes it for the passkey
|
|
# configuration, the same way: a one-shot that fails open is replaced by a
|
|
# LEVEL-TRIGGERED loop that converges every 15 minutes — the posture Flux gives
|
|
# every other resource in this repo (R-GITOPS-03 drift correction), applied to the
|
|
# one piece of state Flux itself cannot reach, Keycloak's own database.
|
|
#
|
|
# ALL THREE DECLARED THINGS CONVERGE. Nothing here is left as a manual step:
|
|
# pass 1 webAuthnPolicyPasswordless* realm policy strict read-modify-write
|
|
# pass 2 the webauthn-register-passwordless action strict read-modify-write,
|
|
# ONE literally-named alias
|
|
# pass 3 the browser authentication flow BLUE/GREEN
|
|
#
|
|
# WHY A SIBLING AND NOT A SECOND PASS OF THE EXISTING RECONCILER
|
|
# -------------------------------------------------------------
|
|
# client-secret-reconciler.yaml's stated guarantee is that prod's real user and the
|
|
# media-users / platform-admins / viewers groups survive every run, and its
|
|
# --selftest ASSERTS that it refuses /users, /groups, /roles, /authentication and
|
|
# the whole-realm endpoint. This work needs the realm endpoint and the
|
|
# /authentication tree. Widening that allow-list would break its selftest and, more
|
|
# importantly, would retire a guarantee that is currently literally true. So this is
|
|
# a SEPARATE program with a SEPARATE, differently-shaped allow-list, and the sibling
|
|
# is untouched — its selftest still passes unchanged.
|
|
#
|
|
# PASS 3: 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 COMPONENT WAS "AUDIT THE FLOW, NEVER WRITE IT", on the
|
|
# reasoning that a run dying part-way through that sequence would leave a HALF-BUILT
|
|
# FLOW BOUND AS THE LOGIN PATH for Grafana, Gitea, Nextcloud, Headlamp, Semaphore,
|
|
# PiHole, Crafty, the *arr stack AND kubectl — 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:
|
|
#
|
|
# 1. BUILD the target structure under a NEW, UNUSED alias — the declared alias
|
|
# plus a hash of the desired shape, applied to every flow in the tree. 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 IT 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, so the regression the operator
|
|
# fears is caught BEFORE it can bite rather than after.
|
|
# 3. BIND with ONE ATOMIC WRITE: PUT /admin/realms/homelab setting browserFlow.
|
|
# One atomic mutation to live behaviour, not five.
|
|
# 4. ROLLBACK IS ONE WRITE. The previous alias is printed in the run log, and
|
|
# NOTHING IS EVER DELETED, so the old flow remains a working rollback target.
|
|
#
|
|
# THE SAFETY PROPERTY, ENFORCED STRUCTURALLY RATHER THAN BY INTENTION
|
|
# ------------------------------------------------------------------
|
|
# Blue/green needs flow-create and execution-add endpoints, so the allow-list alone
|
|
# is no longer sufficient: its write entries are alias-parameterised and cannot say
|
|
# "but not the flow that is bound". A SECOND structural guard sits at the same choke
|
|
# point in _http():
|
|
#
|
|
# 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; every write is screened against
|
|
# those sets before a socket is opened. Protecting the whole subtree matters: adding
|
|
# an execution to `browser-mfa-forms` changes live login behaviour just as surely as
|
|
# touching `browser-mfa` itself. The create-flow endpoint carries no alias in its
|
|
# path, so it is screened on its request BODY — otherwise it would escape the guard.
|
|
#
|
|
# AND IT FAILS CLOSED: the protected sets start each run as "not yet determined", and
|
|
# while they are, EVERY flow write is refused. If the bound flow's executions cannot
|
|
# be read, they stay undetermined and the flow pass writes nothing at all. 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 on a schedule. 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 and authenticator-config aliases are compared as
|
|
# STRUCTURE, not as names — a flow this program built carries a hash suffix on every
|
|
# alias, so comparing labels would mean it could never be recognised as converged.
|
|
#
|
|
# RECOVERY. A crashed earlier run can leave a partial flow under the primary
|
|
# candidate alias. A later run reads that flow: if it MATCHES desired it is a
|
|
# completed-but-unbound build and is simply BOUND (no rebuild); if it does not, it is
|
|
# left alone and the next candidate alias is used. The number of candidates is
|
|
# BOUNDED, so a persistently failing build cannot create unbounded flow sprawl — it
|
|
# fails loudly instead, naming the flows an operator should clean up.
|
|
#
|
|
# THE REALM-LEVEL PUT, CHECKED AGAINST 26.0.6 RATHER THAN ASSUMED
|
|
# --------------------------------------------------------------
|
|
# Verified against quay.io/keycloak/keycloak:26.0.6, the image the cluster runs:
|
|
# * RealmAdminResource.updateRealm is the only handler for PUT /admin/realms/<r>.
|
|
# Its 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: NO rep.getUsers(), NO
|
|
# getClients(), NO getGroups(), NO getRoles(), NO getRequiredActions(), NO
|
|
# getAuthenticationFlows(), NO getAuthenticatorConfig(). 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. One function may change it, and only
|
|
# after verification passes.
|
|
# * The passwordless policy is applied ALL-OR-NOTHING and only when RpEntityName is
|
|
# non-empty, with a Keycloak DEFAULT substituted for every omitted key. That is
|
|
# why the write is a strict read-modify-write of the full live representation and
|
|
# never a partial body — a partial PUT would silently reset the keys it left out,
|
|
# and one missing RpEntityName would drop the whole policy while returning 204.
|
|
# * A third trap, 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. Every requirement write carries the
|
|
# execution's current priority back.
|
|
#
|
|
# NOTHING IS EVER DELETED. There is no DELETE method anywhere in the allow-list.
|
|
#
|
|
# MODES: default reconciles; `--audit` is read-only and builds and binds nothing;
|
|
# `--no-bind` builds and VERIFIES but leaves the flip as a one-write human act.
|
|
#
|
|
# 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.
|
|
#
|
|
# PROOF: the script's own `--selftest` drives these real code paths against an
|
|
# in-process stub Keycloak that MODELS the 26.0.6 behaviours above — including the
|
|
# RpEntityName gate, the all-or-nothing policy rebuild, the required-action replace,
|
|
# the updateExecutions priority trap, and flow construction where everything is
|
|
# created DISABLED. It asserts blue/green build-verify-flip, that a build killed at
|
|
# four different points NEVER binds and leaves login untouched, that the bound flow
|
|
# is unwritable and fail-closed, idempotence with zero writes, recovery and the
|
|
# bounded alias budget, that nothing is ever deleted, and the conditional-OTP
|
|
# regression. The L0 static gate EXTRACTS the script from this ConfigMap and runs it,
|
|
# and mutations.py proves the selftest can FAIL, so what CI proves is byte-for-byte
|
|
# what ships: conformance/tests/l0-static/keycloak-passkey-reconciler/.
|
|
# =============================================================================
|
|
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, ALL OF WHICH CONVERGE
|
|
# -----------------------------------
|
|
# 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, verify it, then flip the
|
|
# realm's browserFlow in ONE atomic write.
|
|
#
|
|
# 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.
|
|
# 4. ROLLBACK IS ONE WRITE. The previous alias is printed in the run log, and the
|
|
# old flow is NEVER DELETED, so it remains a working rollback target.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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,
|
|
# and only after verification passes.
|
|
# 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.
|
|
#
|
|
# MODES
|
|
# (default) reconcile: compare, and write only what differs.
|
|
# --audit READ-ONLY throughout. Report drift and exit 1 if anything differs.
|
|
# Builds nothing and binds nothing.
|
|
# --no-bind build and VERIFY the new flow but do NOT flip browserFlow. Leaves a
|
|
# verified, unbound flow and prints the one-line bind command. The
|
|
# deliberate fallback if an operator wants the flip to be a human act.
|
|
# --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")
|
|
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
|
|
|
|
# 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")
|
|
|
|
|
|
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
|
|
_PROTECTED_FLOW_ALIASES = None
|
|
_PROTECTED_EXECUTION_IDS = None
|
|
|
|
|
|
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
|
|
m = _FLOW_WRITE_PATH.fullmatch(path)
|
|
if m is None:
|
|
m = _EXEC_CONFIG_PATH.fullmatch(path)
|
|
if m is None:
|
|
return # not a flow write (realm PUT, action PUT, token)
|
|
target, protected, what = (m.group(1), _PROTECTED_EXECUTION_IDS, "execution")
|
|
else:
|
|
target = urllib.parse.unquote(m.group(1))
|
|
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: %s %r is part of the CURRENTLY BOUND browser flow. This "
|
|
"program builds a NEW flow and flips browserFlow 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))
|
|
|
|
|
|
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 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 is kept as the 6th member so reports and the
|
|
protected-subtree walk can name it.
|
|
"""
|
|
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"))
|
|
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 "",
|
|
(ex.get("alias") if is_flow else ex.get("providerId")) or "",
|
|
))
|
|
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)))
|
|
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))
|
|
return top
|
|
|
|
|
|
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.
|
|
|
|
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, []
|
|
|
|
|
|
def reconcile_flow(base, realm, token, state, audit, no_bind, log):
|
|
"""Return (changed, problems). Blue/green: build new, verify, 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.
|
|
"""
|
|
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:
|
|
# 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 no_bind:
|
|
log("flow --no-bind: %s is built and VERIFIED but NOT bound. To go live, "
|
|
"one write: set the realm's browserFlow to %s" % (target, target))
|
|
return 1, 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 run(argv, log):
|
|
audit = "--audit" in argv
|
|
no_bind = "--no-bind" in argv
|
|
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, build but DO NOT 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
|
|
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, no_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")
|
|
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):
|
|
"""True iff _http() refuses (method, path) outright."""
|
|
try:
|
|
_http(method, "http://127.0.0.1:1", path, 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, 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": []}
|
|
|
|
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."""
|
|
out = []
|
|
|
|
def walk(a, level):
|
|
for i, ex in enumerate(sorted(state["flows"][a]["executions"],
|
|
key=lambda e: e["priority"])):
|
|
is_flow = ex["kind"] == "flow"
|
|
out.append({
|
|
"id": ex["id"],
|
|
"level": level,
|
|
"index": i,
|
|
"priority": ex["priority"],
|
|
"requirement": ex["requirement"],
|
|
"authenticationFlow": True if is_flow else None,
|
|
"alias": ex["name"] if is_flow else None,
|
|
"displayName": ex["name"],
|
|
"providerId": None if is_flow else ex["name"],
|
|
"flowId": ("flowid-" + ex["name"]) if is_flow else None,
|
|
"authenticationConfig": ex.get("configId"),
|
|
})
|
|
if is_flow:
|
|
walk(ex["name"], level + 1)
|
|
|
|
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()
|
|
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
|
|
|
|
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["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")
|
|
expect(not _refused("POST", "/admin/realms/homelab/authentication/executions/"
|
|
"ex-99/config"),
|
|
"an execution outside the bound subtree must be writable")
|
|
_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("NOT bound" in out and "one write" in out,
|
|
"--no-bind must print the one-write instruction to go live")
|
|
|
|
# ========================================== 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")
|
|
|
|
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 bound flow is "
|
|
"unwritable and fail-closed, 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, and unlike the first design of this component there is
|
|
# no longer any failure that is "expected by design" — the flow converges too, so a
|
|
# steady state here is a GREEN steady state. That makes a PrometheusRule on
|
|
# kube_job_status_failed{job_name=~"keycloak-passkey-reconciler.*"} both meaningful
|
|
# and worth having. It is NOT added here, and that is a real gap rather than a
|
|
# decision: this change is already large and the alert belongs with the rest of the
|
|
# keycloak alerting rules. Until it exists, a silently failing CronJob (image pull
|
|
# failure, a rotated admin password, a changed Secret key) is visible only to someone
|
|
# who looks at Jobs. Named explicitly so it is not mistaken for covered.
|
|
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: a flow divergence fails every run until an operator
|
|
# acts, and keeping the last three failures makes the Job log readable without
|
|
# letting completed pods accumulate.
|
|
failedJobsHistoryLimit: 3
|
|
jobTemplate:
|
|
spec:
|
|
backoffLimit: 2
|
|
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. Pinned exactly, and identical to the
|
|
# client-secret reconciler's image so the two share a layer. Renovate
|
|
# does not manage plain `image:` tags (renovate.json disables image-tag
|
|
# updates so it never fights Flux image-automation), so this is bumped
|
|
# by hand.
|
|
image: python:3.13.15-alpine3.23
|
|
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, 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
|