Files
home-network-k3s-impl/platform/keycloak/passkey-desired-state.yaml
T
jakman85andClaude Opus 5.5 17eed6e8ae fix(keycloak-passkey): bind only on git's say-so; protect real sub-flows; screen the requirement PUT by body id
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]>
2026-10-29 21:41:14 -05:00

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"
}
}