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