PR #72 declared the WebAuthn passwordless policy and a restructured browser-mfa flow in the realm import. That declaration could not take effect: a KeycloakRealmImport is a ONE-SHOT, and prod's reads Done=True / Started=False, so every realm-level field and flow declared after the first import is inert -- in git, applied by Flux, reported Ready, reaching the realm never. Merging it changed nothing about how anyone logs in, and nothing anywhere said so. Same failure class as the 2026-09-06 client-secret incident, third field over. A SIBLING reconciler converges all three declared things every 15 minutes. It is a sibling, not a second pass of client-secret-reconciler.yaml, so that reconciler's allow-list -- and its guarantee that prod's real user and the media-users / platform-admins / viewers groups are structurally unreachable -- stays literally true. Its selftest still passes unchanged. pass 1 webAuthnPolicyPasswordless* -- strict read-modify-write of GET/PUT /admin/realms/homelab, readback-verified. pass 2 webauthn-register-passwordless -- strict read-modify-write of ONE literally-named alias; enabled, defaultAction false so enrolment stays opt-in. pass 3 the browser flow -- BLUE/GREEN. PASS 3 NEVER MUTATES THE FLOW USERS ARE LOGGING IN THROUGH. Converging a five-flow, ten-execution tree needs create-subflow, create-execution, set-requirement and bind-config as SEPARATE untransacted calls; doing that to the BOUND flow means a run dying between them leaves a half-built login path for every SSO app and kubectl, and this repo has had a total SSO outage from a realm change. So instead: 1. BUILD under a NEW, unused alias (declared alias + a hash of the desired shape, applied to EVERY alias in the tree -- reusing browser-mfa-forms would mean writing INTO the bound tree). A failure leaves an orphaned, unbound flow and zero user-visible effect. 2. VERIFY by readback -- levels, ordering, authenticators, requirements, and the conditional-OTP config CONTENTS -- before anything is bound, so the regression that once 403'd every platform-admin is caught BEFORE it bites. 3. BIND with ONE atomic PUT setting browserFlow. 4. ROLLBACK is one write; no DELETE exists anywhere in the allow-list, so the old flow survives as the rollback target. The widened allow-list is made safe structurally. It cannot say "but not the bound flow" -- its write entries are alias-parameterised -- so a second guard screens every write against the live browserFlow's WHOLE subtree (sub-flow aliases and execution ids) before a socket opens, FAIL-CLOSED until that binding has been read. The create-flow endpoint carries no alias in its path, so it is screened on its request BODY; the selftest caught that it otherwise escaped the guard. Idempotence is load-bearing: a rebuild-and-reflip every 15 minutes would churn the login path on a schedule. The bound flow is compared STRUCTURALLY and a match produces zero writes and no new flow. Sub-flow and config ALIASES are deliberately not compared -- a built flow carries a hash suffix, so comparing labels would make it permanently unrecognisable as converged. Single source of truth: the desired state is GENERATED from the realm import by ci/render-keycloak-passkey-policy.py and gated with --check, so the RP ID -- baked into every credential at enrolment and never re-keyable -- is declared once. The pod keeps automountServiceAccountToken: false and reads a mounted ConfigMap, so no rbac.yaml is added anywhere. Read from Keycloak 26.0.6 source before being relied on: updateRealm (DefaultExportImportManager 725-894) makes no getUsers/getClients/getGroups/ getRoles/getRequiredActions/getAuthenticationFlows call; it DOES read getBrowserFlow(), which is what makes the atomic bind possible and why the policy write pins it; the passwordless policy is applied all-or-nothing only when RpEntityName is non-empty, with defaults substituted for omitted keys; and updateExecutions compares rep.getPriority(), so a body omitting `priority` would silently reshuffle the whole execution order. Gate coverage: a new whole-repo L0 check extracts the script and runs its --selftest (216 assertions, including a build killed at four different points each asserting the old flow is still bound and the bound subtree was never written), plus mutations.py (30 injected regressions, all caught) proving the selftest can fail. An earlier iteration of this work only AUDITED the flow and left the restructure manual; decisions/2026-10-18-keycloak-passkey-reconciler.md records that design and why it was reconsidered. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
168 lines
6.3 KiB
YAML
168 lines
6.3 KiB
YAML
---
|
|
# =============================================================================
|
|
# GENERATED FILE — DO NOT EDIT BY HAND.
|
|
#
|
|
# source: platform/keycloak/keycloak-realm-import.yaml (spec.realm)
|
|
# regenerate: python3 ci/render-keycloak-passkey-policy.py --write
|
|
# gated by: conformance/tests/l0-static/keycloak-passkey-reconciler
|
|
# (runs this renderer with --check on every merge)
|
|
#
|
|
# WHAT THIS IS: the DESIRED STATE for the `homelab` realm's PASSKEY configuration —
|
|
# the WebAuthn passwordless policy, the enrolment required action, and the declared
|
|
# browser authentication flow tree. platform/keycloak/passkey-reconciler.yaml mounts
|
|
# it and (a) converges the policy and the required action onto it, (b) AUDITS the
|
|
# live browser flow against it and alarms on divergence without rewriting it.
|
|
#
|
|
# WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs
|
|
# once Done=True, so a realm-level field or a flow edited in git never reaches the
|
|
# realm. Measured on prod 2026-10-18, hours after the passkey declaration merged:
|
|
# Done=True / Started=False, with the whole passkey block inert. The reconciler is
|
|
# the level-triggered loop that closes that; this file is what it reconciles
|
|
# TOWARDS.
|
|
#
|
|
# WHY IT IS GENERATED AND NOT AUTHORED: a hand-written copy of the RP ID, the
|
|
# user-verification requirement and the flow tree would be a second source of truth,
|
|
# free to disagree with the realm import — the same "two declarations that can
|
|
# disagree" shape as the bug being closed. It is rendered from the import and the
|
|
# render is gated, so the two cannot drift.
|
|
#
|
|
# STRINGS ARE VERBATIM: webAuthnPolicyPasswordlessRpId is the ${BASE_DOMAIN}
|
|
# substitution token exactly as the realm import writes it. Both files are rendered
|
|
# by the SAME Flux Kustomization (./platform-config), so the token resolves to the
|
|
# same value in both and the domain is decided in exactly one place.
|
|
#
|
|
# `requiredAction` is the ONE entry that is DERIVED rather than copied: the realm
|
|
# import deliberately declares no `requiredActions` block, because Keycloak 26.0.6
|
|
# treats that as REPLACE and declaring one action would delete CONFIGURE_TOTP and
|
|
# nine more (a hard lock-out of every platform-admin). It is derived instead from
|
|
# the presence of a webauthn-authenticator-passwordless execution in the flow:
|
|
# passkeys are offered, therefore enrolment must be enabled — and defaultAction is
|
|
# false, so enrolment stays opt-in.
|
|
#
|
|
# A field NOT declared on the realm in the import is simply ABSENT here, and the
|
|
# reconciler leaves it alone. Absence means UNMANAGED, never "force to a default".
|
|
# =============================================================================
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: keycloak-passkey-desired-state
|
|
namespace: keycloak
|
|
labels:
|
|
app.kubernetes.io/name: keycloak-passkey-desired-state
|
|
app.kubernetes.io/part-of: keycloak
|
|
data:
|
|
passkey-desired-state.json: |
|
|
{
|
|
"authenticatorConfig": {
|
|
"conditional-otp-platform-admin": {
|
|
"condUserRole": "platform-admin",
|
|
"negate": "false"
|
|
}
|
|
},
|
|
"browserFlow": "browser-mfa",
|
|
"flows": {
|
|
"browser-mfa": {
|
|
"executions": [
|
|
{
|
|
"kind": "authenticator",
|
|
"name": "auth-cookie",
|
|
"requirement": "ALTERNATIVE"
|
|
},
|
|
{
|
|
"kind": "authenticator",
|
|
"name": "identity-provider-redirector",
|
|
"requirement": "ALTERNATIVE"
|
|
},
|
|
{
|
|
"kind": "flow",
|
|
"name": "browser-mfa-forms",
|
|
"requirement": "ALTERNATIVE"
|
|
}
|
|
],
|
|
"topLevel": true
|
|
},
|
|
"browser-mfa-conditional-otp": {
|
|
"executions": [
|
|
{
|
|
"configAlias": "conditional-otp-platform-admin",
|
|
"kind": "authenticator",
|
|
"name": "conditional-user-role",
|
|
"requirement": "REQUIRED"
|
|
},
|
|
{
|
|
"kind": "authenticator",
|
|
"name": "auth-otp-form",
|
|
"requirement": "REQUIRED"
|
|
}
|
|
],
|
|
"topLevel": false
|
|
},
|
|
"browser-mfa-forms": {
|
|
"executions": [
|
|
{
|
|
"kind": "authenticator",
|
|
"name": "auth-username-form",
|
|
"requirement": "REQUIRED"
|
|
},
|
|
{
|
|
"kind": "flow",
|
|
"name": "browser-mfa-passkey-or-password",
|
|
"requirement": "REQUIRED"
|
|
}
|
|
],
|
|
"topLevel": false
|
|
},
|
|
"browser-mfa-passkey-or-password": {
|
|
"executions": [
|
|
{
|
|
"kind": "authenticator",
|
|
"name": "webauthn-authenticator-passwordless",
|
|
"requirement": "ALTERNATIVE"
|
|
},
|
|
{
|
|
"kind": "flow",
|
|
"name": "browser-mfa-password-and-otp",
|
|
"requirement": "ALTERNATIVE"
|
|
}
|
|
],
|
|
"topLevel": false
|
|
},
|
|
"browser-mfa-password-and-otp": {
|
|
"executions": [
|
|
{
|
|
"kind": "authenticator",
|
|
"name": "auth-password-form",
|
|
"requirement": "REQUIRED"
|
|
},
|
|
{
|
|
"kind": "flow",
|
|
"name": "browser-mfa-conditional-otp",
|
|
"requirement": "CONDITIONAL"
|
|
}
|
|
],
|
|
"topLevel": false
|
|
}
|
|
},
|
|
"realm": "homelab",
|
|
"requiredAction": {
|
|
"alias": "webauthn-register-passwordless",
|
|
"defaultAction": false,
|
|
"enabled": true
|
|
},
|
|
"webAuthnPolicyPasswordless": {
|
|
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
|
|
"webAuthnPolicyPasswordlessAttestationConveyancePreference": "none",
|
|
"webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
|
|
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": true,
|
|
"webAuthnPolicyPasswordlessCreateTimeout": 300,
|
|
"webAuthnPolicyPasswordlessRequireResidentKey": "Yes",
|
|
"webAuthnPolicyPasswordlessRpEntityName": "Homelab SSO",
|
|
"webAuthnPolicyPasswordlessRpId": "${BASE_DOMAIN}",
|
|
"webAuthnPolicyPasswordlessSignatureAlgorithms": [
|
|
"ES256",
|
|
"RS256"
|
|
],
|
|
"webAuthnPolicyPasswordlessUserVerificationRequirement": "required"
|
|
}
|
|
}
|