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]>
190 lines
7.7 KiB
YAML
190 lines
7.7 KiB
YAML
---
|
|
# =============================================================================
|
|
# GENERATED FILE — DO NOT EDIT BY HAND, except the one bind-browser-flow line below.
|
|
#
|
|
# source: platform/keycloak/keycloak-realm-import.yaml (spec.realm)
|
|
# regenerate: python3 ci/render-keycloak-passkey-policy.py --write
|
|
# gated by: conformance/tests/l0-static/keycloak-passkey-reconciler
|
|
# (runs this renderer with --check on every merge)
|
|
#
|
|
# WHAT THIS IS: the DESIRED STATE for the `homelab` realm's PASSKEY configuration —
|
|
# the WebAuthn passwordless policy, the enrolment required action, and the declared
|
|
# browser authentication flow tree. platform/keycloak/passkey-reconciler.yaml mounts
|
|
# it and (a) converges the policy and the required action onto it, (b) builds the
|
|
# declared flow tree BLUE/GREEN under a new alias and verifies it by readback, never
|
|
# writing the flow users are logging in through, and (c) BINDS that verified flow as
|
|
# the realm's browserFlow ONLY when `bind-browser-flow` below is "true".
|
|
#
|
|
# EVERYTHING HERE IS GENERATED EXCEPT ONE LINE: `bind-browser-flow`. That is an
|
|
# operator decision, carried through by the renderer from this committed file (see
|
|
# ci/render-keycloak-passkey-policy.py). It defaults to "false".
|
|
#
|
|
# WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs
|
|
# once Done=True, so a realm-level field or a flow edited in git never reaches the
|
|
# realm. Measured on prod 2026-10-18, hours after the passkey declaration merged:
|
|
# Done=True / Started=False, with the whole passkey block inert. The reconciler is
|
|
# the level-triggered loop that closes that; this file is what it reconciles
|
|
# TOWARDS.
|
|
#
|
|
# WHY IT IS GENERATED AND NOT AUTHORED: a hand-written copy of the RP ID, the
|
|
# user-verification requirement and the flow tree would be a second source of truth,
|
|
# free to disagree with the realm import — the same "two declarations that can
|
|
# disagree" shape as the bug being closed. It is rendered from the import and the
|
|
# render is gated, so the two cannot drift.
|
|
#
|
|
# STRINGS ARE VERBATIM: webAuthnPolicyPasswordlessRpId is the ${BASE_DOMAIN}
|
|
# substitution token exactly as the realm import writes it. Both files are rendered
|
|
# by the SAME Flux Kustomization (./platform-config), so the token resolves to the
|
|
# same value in both and the domain is decided in exactly one place.
|
|
#
|
|
# `requiredAction` is the ONE entry that is DERIVED rather than copied: the realm
|
|
# import deliberately declares no `requiredActions` block, because Keycloak 26.0.6
|
|
# treats that as REPLACE and declaring one action would delete CONFIGURE_TOTP and
|
|
# nine more (a hard lock-out of every platform-admin). It is derived instead from
|
|
# the presence of a webauthn-authenticator-passwordless execution in the flow:
|
|
# passkeys are offered, therefore enrolment must be enabled — and defaultAction is
|
|
# false, so enrolment stays opt-in.
|
|
#
|
|
# A field NOT declared on the realm in the import is simply ABSENT here, and the
|
|
# reconciler leaves it alone. Absence means UNMANAGED, never "force to a default".
|
|
# =============================================================================
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: keycloak-passkey-desired-state
|
|
namespace: keycloak
|
|
labels:
|
|
app.kubernetes.io/name: keycloak-passkey-desired-state
|
|
app.kubernetes.io/part-of: keycloak
|
|
data:
|
|
# ---------------------------------------------------------------------------
|
|
# THE BIND SWITCH -- the ONLY hand-edited line in this file. Exactly "true" or
|
|
# "false", QUOTED.
|
|
#
|
|
# "false" the reconciler builds and verifies the candidate browser flow and
|
|
# logs `WOULD BIND <alias>`, but NEVER changes the realm's browserFlow.
|
|
# Whatever the realm is bound to -- including a rollback -- is left alone.
|
|
# "true" it binds the verified candidate in one atomic PUT, and keeps the
|
|
# realm bound to it: a binding changed by hand is changed back.
|
|
#
|
|
# GO LIVE: test the candidate in a browser first (platform/keycloak/README.md,
|
|
# "Passkeys"), then a PR setting this to "true".
|
|
# ROLL BACK: a PR setting this to "false" FIRST (or the next run rebinds), then
|
|
# one write setting browserFlow back to the previous alias from the Job log.
|
|
# ---------------------------------------------------------------------------
|
|
bind-browser-flow: "false"
|
|
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"
|
|
}
|
|
}
|