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]>
102 lines
6.5 KiB
YAML
102 lines
6.5 KiB
YAML
# =============================================================================
|
|
# L0 STATIC — the passkey configuration is derived from one declaration, continuously
|
|
# reconciled, and the browser login flow is auditable but UNWRITABLE
|
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
|
#
|
|
# R-IDP-03 (identity config is declarative) and R-IDP-04 (MFA available) are both
|
|
# implemented through a mechanism that FAILS OPEN: the KeycloakRealmImport is a ONE-SHOT.
|
|
# Measured on prod 2026-10-18, hours after the passkey declaration merged:
|
|
#
|
|
# kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
|
|
# Done=True Started=False HasErrors=False
|
|
#
|
|
# Once it rests at Done=True the operator never re-runs it, so the WebAuthn passwordless
|
|
# policy and the restructured `browser-mfa` flow that keycloak-realm-import.yaml declares
|
|
# are INERT — in git, applied by Flux, reported Ready, reaching the realm never. Merging
|
|
# them changed nothing about how anyone logs in, and nothing anywhere said so.
|
|
# Declarative config that silently does not apply is not declarative config.
|
|
#
|
|
# GIVEN the repository as authored,
|
|
# THEN the reconciler's desired state is GENERATED from the realm import (so the RP ID —
|
|
# which is baked into every passkey at enrolment and can NEVER be re-keyed — is
|
|
# declared exactly once); both files are rendered by the SAME Flux Kustomization, so
|
|
# their ${BASE_DOMAIN} token cannot resolve to two values; the renderer's exported
|
|
# policy keys and the reconciler's writable keys are the SAME tuple; the realm
|
|
# import declares NO requiredActions block (26.0.6 treats it as REPLACE, which would
|
|
# delete CONFIGURE_TOTP and lock out every platform-admin); the enrolment action is
|
|
# enabled but NOT a defaultAction, so enrolment stays opt-in; the reconciler carries
|
|
# no ServiceAccount token, mounts only what it needs, and contains no dollar sign;
|
|
# the endpoint allow-list contains NO write for any authentication flow, execution
|
|
# or authenticator config, and NO required-action write for any alias other than the
|
|
# one named literally; the SIBLING client-secret reconciler's allow-list is NOT
|
|
# widened; and the reconciler's own `--selftest` (extracted from the shipped
|
|
# ConfigMap and executed) proves compare-then-write, idempotence, strict
|
|
# read-modify-write, the RpEntityName gate, flow-is-unwritable, the conditional-OTP
|
|
# regression and fail-closed — with mutations.py proving that selftest can FAIL.
|
|
#
|
|
# WHY THE FLOW IS AUDITED AND NOT CONVERGED, asserted here rather than only argued:
|
|
# converging a five-flow, ten-execution restructure needs create-subflow,
|
|
# create-execution, set-requirement, set-priority and bind-config as SEPARATE admin API
|
|
# calls with no transaction. A run that dies between them leaves a HALF-BUILT FLOW BOUND
|
|
# AS THE LOGIN PATH for Grafana, Gitea, Nextcloud, Headlamp, Semaphore, PiHole, Crafty,
|
|
# the *arr stack AND kubectl. This repo has already had a total SSO outage from a realm
|
|
# change (2026-09-06), and the conditional-OTP sub-flow — which the declared structure
|
|
# moves one level deeper than it sits today — once 403'd every platform-admin. A
|
|
# reconciler that half-rewrites the bound flow every 15 minutes is a worse failure than
|
|
# the one it replaces, because it is a loop rather than a single bad change.
|
|
#
|
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git + PyYAML +
|
|
# stdlib); conformance/run.sh --layer l0 and ci/l0.sh run it directly, and this Chainsaw
|
|
# Test wraps the SAME script.
|
|
#
|
|
# NOT proven here: that the LIVE realm's policy, enrolment action or browser flow match
|
|
# git (that is cluster state — the reconciler's `--audit` mode is the read-only probe for
|
|
# it, and NO L2 test is added by this change, so the live realm is UNVERIFIED by CI);
|
|
# that a passkey ceremony actually completes or that the ALTERNATIVE fork degrades to the
|
|
# password form (needs a real browser against a real realm); that the five flow-building
|
|
# admin-API calls behave against a live Keycloak the way the 26.0.6 source says they do
|
|
# (every proof here is against an in-process stub); or that the CronJob's schedule fires.
|
|
# =============================================================================
|
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
|
kind: Test
|
|
metadata:
|
|
name: keycloak-passkey-reconciler
|
|
labels:
|
|
conformance.home.k3s/R-IDP-03: "true"
|
|
conformance.home.k3s/R-IDP-04: "true"
|
|
annotations:
|
|
conformance.home.k3s/requirements: "R-IDP-03, R-IDP-04"
|
|
conformance.home.k3s/layer: "L0"
|
|
# WHOLE-REPO SCOPE, for the same reason the sibling keycloak-client-secret-reconciler
|
|
# check declares it: this invariant spans platform/ (the realm import, the reconciler,
|
|
# the generated desired state), platform-config/ (which Flux Kustomization renders
|
|
# them, and therefore which substitution pass they share), ci/ (the renderer) and
|
|
# clusters/ (that both are reachable from both entrypoints). A change that breaks it
|
|
# need not attribute R-IDP-03/04 at all, and the requirement-scoped selection would
|
|
# then filter this check out of exactly the change that introduces the bug — which is
|
|
# how 12 unusable prod OIDC clients sat behind an entirely green cluster on
|
|
# 2026-09-06.
|
|
conformance.home.k3s/scope: "whole-repo"
|
|
# Not safety-critical in the harness's sense (it mutates nothing and needs no
|
|
# cluster), but it guards a component that holds WRITE access to the identity
|
|
# provider's realm settings.
|
|
conformance.home.k3s/safety-critical: "false"
|
|
conformance.home.k3s/executable: "true"
|
|
conformance.home.k3s/intent: >-
|
|
The realm's passkey configuration — policy, enrolment action AND browser login
|
|
flow — is declared once in the realm import, derived from it by a gated renderer,
|
|
and continuously reconciled; the flow converges blue/green, so a one-shot import
|
|
that never re-runs can neither leave the configuration inert nor let a reconciler
|
|
half-rewrite the login path of the whole platform.
|
|
spec:
|
|
steps:
|
|
- name: audit-passkey-declaration-renderer-and-reconciler
|
|
description: >-
|
|
Assert the desired state is derived from the realm import and shares its
|
|
substitution pass, that the reconciler's write surface excludes every flow
|
|
mutation, that the sibling reconciler is unwidened, and run the reconciler's own
|
|
selftest plus the mutation suite that proves it can fail.
|
|
try:
|
|
- script:
|
|
content: bash ./check.sh
|