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]>
109 lines
7.0 KiB
YAML
109 lines
7.0 KiB
YAML
# =============================================================================
|
|
# L0 STATIC — the passkey configuration is derived from one declaration, continuously
|
|
# reconciled, and the browser login flow converges BLUE/GREEN: the BOUND flow is
|
|
# unwritable, and binding a new one is git's explicit decision (bind-browser-flow)
|
|
# 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, passes no mode flag, and
|
|
# contains no dollar sign; the endpoint allow-list contains NO delete, NO
|
|
# single-execution or config-edit write, and NO required-action write for any alias
|
|
# other than the one named literally; the protected-subtree guard exists, is wired
|
|
# into the single request choke point and is fail-closed; 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, the bound flow and
|
|
# its sub-flows unwritable, the git bind switch and a rollback that sticks, the
|
|
# conditional-OTP regression and fail-closed — with mutations.py proving that
|
|
# selftest can FAIL.
|
|
#
|
|
# WHY THE FLOW IS BUILT BLUE/GREEN AND BOUND ONLY ON GIT'S SAY-SO, asserted here rather
|
|
# than only argued: converging a five-flow, ten-execution restructure needs
|
|
# create-subflow, create-execution, set-requirement and bind-config as SEPARATE admin API
|
|
# calls with no transaction. Doing that to the BOUND flow means a run that dies between
|
|
# them leaves a HALF-BUILT FLOW AS THE LOGIN PATH for Grafana, Gitea, Nextcloud, Headlamp,
|
|
# Semaphore, PiHole, Crafty, the *arr stack AND kubectl — this repo has had a total SSO
|
|
# outage from a realm change (2026-09-06), and the conditional-OTP sub-flow once 403'd
|
|
# every platform-admin. So every multi-call write lands on a NEW, unused candidate flow,
|
|
# which is verified by readback before anything is bound. Binding it is then ONE atomic
|
|
# PUT of browserFlow, made only when passkey-desired-state.yaml sets
|
|
# bind-browser-flow: "true" (default "false"): an untested login flow never goes live on
|
|
# a schedule tick, and with the switch off an operator's rollback is never undone.
|
|
#
|
|
# 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 cannot write the bound flow or delete
|
|
anything and binds only on git's bind-browser-flow, 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
|