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]>
This commit is contained in:
1 parent
f5931944fa
commit
17eed6e8ae
8 files changed
+893
-310
No files matched your search
+79
-22
@@ -31,8 +31,8 @@ files pulled as remote kustomize bases (the kubevirt-stub pattern); Renovate mai
|
||||
| `keycloak-realm-import.yaml` (`KeycloakRealmImport`: realm/groups/clients/MFA) | **platform-config** | config |
|
||||
| `client-secret-reconciler.yaml` (CronJob: client secrets **and** client URLs ← git, every 15m) | **platform-config** | config |
|
||||
| `client-urls.yaml` (**GENERATED** ConfigMap: the client-URL desired state) | **platform-config** | config |
|
||||
| `passkey-reconciler.yaml` (CronJob: WebAuthn passwordless policy + enrolment action + the **browser flow** ← git every 15m; the flow blue/green, never mutating the bound one) | **platform-config** | config |
|
||||
| `passkey-desired-state.yaml` (**GENERATED** ConfigMap: the passkey desired state) | **platform-config** | config |
|
||||
| `passkey-reconciler.yaml` (CronJob: WebAuthn passwordless policy + enrolment action + the **browser flow** ← git every 15m; the flow blue/green, never mutating the bound one, **bound only when git says so**) | **platform-config** | config |
|
||||
| `passkey-desired-state.yaml` (**GENERATED** ConfigMap: the passkey desired state — plus the one hand-edited line, `bind-browser-flow`) | **platform-config** | config |
|
||||
| `httproute.yaml` (keycloak.home.keithjackson.dev) | **platform-config** | config |
|
||||
| `servicemonitor.yaml` (Prometheus scrape) | **platform-config** | config |
|
||||
|
||||
@@ -207,7 +207,7 @@ unrecoverable-credential bug, not a cosmetic one.
|
||||
|---|---|---|
|
||||
| `webAuthnPolicyPasswordless*` (realm policy) | **The reconciler. GitOps.** | Realm top-level attributes; strict read-modify-write of `GET`/`PUT /admin/realms/homelab` |
|
||||
| `webauthn-register-passwordless` required action | **The reconciler. GitOps.** | Per-alias `PUT`, one literally-named alias; enabled, `defaultAction: false` |
|
||||
| The `browser-mfa` flow restructure | **The reconciler. GitOps.** | Blue/green: build under a new alias, verify, flip `browserFlow` in one atomic PUT — see below |
|
||||
| The `browser-mfa` flow restructure | **The reconciler builds and verifies it. Git decides whether it is bound.** | Blue/green: build under a new alias, verify, and flip `browserFlow` in one atomic PUT **only when `bind-browser-flow: "true"`** — see below |
|
||||
|
||||
#### The flow converges blue/green — it never mutates the flow you log in through
|
||||
|
||||
@@ -229,8 +229,46 @@ flow. So the reconciler does not.
|
||||
2. **Verify** by readback — nesting levels, ordering, authenticators, requirements, and
|
||||
the conditional-OTP config *contents*. Nothing is bound unless this passes, so the
|
||||
regression that once 403'd every platform-admin is caught **before** it can bite.
|
||||
3. **Bind** with **one atomic PUT** of the realm, setting `browserFlow`.
|
||||
4. **Rollback is one write**, and nothing is ever deleted, so the old flow stays usable.
|
||||
3. **Bind** with **one atomic PUT** of the realm, setting `browserFlow` — **only if git says
|
||||
so** (next section). Otherwise the run stops here and logs `WOULD BIND browser-mfa-<hash>`.
|
||||
4. **Rollback is a PR plus one write** (below), and nothing is ever deleted, so the old flow
|
||||
stays usable.
|
||||
|
||||
#### The bind switch: `bind-browser-flow` in `passkey-desired-state.yaml`
|
||||
|
||||
That generated file carries exactly one hand-edited line, `bind-browser-flow`, and it is
|
||||
`"false"` by default. The renderer carries it through on `--write`, and `--check` refuses
|
||||
anything but the quoted strings `"true"` / `"false"` (an unquoted `true` is a YAML boolean,
|
||||
which a ConfigMap rejects at apply time and which would fail the whole `platform-config`
|
||||
Kustomization). The CronJob reads it from the same mount as the desired state; no flag can
|
||||
widen it.
|
||||
|
||||
| `bind-browser-flow` | What the reconciler does to the realm's `browserFlow` |
|
||||
|---|---|
|
||||
| `"false"` (default) | **Never writes it.** It builds and verifies the candidate (or reuses an already verified one), logs `WOULD BIND <alias>`, and exits 0. Whatever the realm is bound to — including your rollback — is left alone on every run. A hand-edited bound flow is left alone too (no rebuild beside it). |
|
||||
| `"true"` | Binds the verified candidate in one atomic PUT, **and keeps asserting it**: a binding changed by hand is changed back within 15 minutes, and a hand-edit *inside* the bound flow makes the next run build and bind a fresh copy. That is GitOps doing its job — so never hotfix the flow while this is `"true"`. |
|
||||
|
||||
**Merging with `"false"` changes nothing about how anyone logs in.** Passes 1 and 2 do write
|
||||
the realm (the passwordless policy and the enrolment action, neither of which any login
|
||||
step uses until a flow offers passkeys), and pass 3 creates the unbound candidate flow, but
|
||||
`browserFlow` is untouched.
|
||||
|
||||
**Going live** is a later one-line PR — `bind-browser-flow: "true"` — made **after** the
|
||||
candidate has been tested in a browser (see "Testing the candidate flow before binding it").
|
||||
|
||||
**Rolling back**, in this order:
|
||||
|
||||
1. A PR setting `bind-browser-flow: "false"`, merged and applied by Flux. **First** — while it
|
||||
is `"true"`, the next run rebinds the candidate over your rollback.
|
||||
2. One write: bind the previous flow again. The Job log of the bind printed it
|
||||
(`ROLLBACK is one write: set browserFlow back to ...`); it is `browser-mfa` for the first
|
||||
go-live. Admin console: *Authentication → Flows → <previous flow> → Action → Bind flow →
|
||||
Browser flow*, or `PUT /admin/realms/homelab` as a read-modify-write of the full realm
|
||||
representation (a partial body drops the passkey policy).
|
||||
|
||||
If login is broken *right now*, do step 2 immediately and step 1 straight after: the worst
|
||||
case of that order is that one scheduled run (≤15 min) rebinds the candidate before Flux
|
||||
applies the PR, and you repeat step 2.
|
||||
|
||||
**The safety property is structural, not intentional.** The reconciler is *incapable* of
|
||||
writing to the bound flow or anything beneath it: the live `browserFlow` alias and its
|
||||
@@ -257,36 +295,55 @@ flows to clean up rather than creating unbounded sprawl.
|
||||
kubectl --context k3s-prod -n keycloak get jobs -l app.kubernetes.io/name=keycloak-passkey-reconciler
|
||||
kubectl --context k3s-prod -n keycloak logs -l app.kubernetes.io/name=keycloak-passkey-reconciler --tail=200
|
||||
|
||||
# Force a run now rather than wait for the tick. NOTE: this runs the DEFAULT mode, which
|
||||
# converges (including flipping browserFlow once a new flow verifies). `kubectl create job
|
||||
# --from=cronjob/...` CANNOT override the container command, so there is no way to get
|
||||
# --audit or --no-bind out of it — for those, copy the CronJob's pod spec into a one-off
|
||||
# Job and append the flag.
|
||||
# Which candidate it would bind (bind-browser-flow "false"):
|
||||
kubectl --context k3s-prod -n keycloak logs -l app.kubernetes.io/name=keycloak-passkey-reconciler --tail=300 \
|
||||
| grep -E 'WOULD BIND|BOUND browserFlow|PROBLEM'
|
||||
|
||||
# Force a run now rather than wait for the tick. It does exactly what the CronJob does,
|
||||
# including honouring bind-browser-flow. `kubectl create job --from=cronjob/...` CANNOT
|
||||
# override the container command, so --audit / --no-bind need a one-off Job with the flag
|
||||
# appended (--no-bind can only NARROW git's switch, never widen it).
|
||||
kubectl --context k3s-prod -n keycloak create job --from=cronjob/keycloak-passkey-reconciler \
|
||||
passkey-run-once
|
||||
kubectl --context k3s-prod -n keycloak logs job/passkey-run-once
|
||||
|
||||
# ROLLBACK — one write. The run log names the previous alias ("ROLLBACK is one write:
|
||||
# set browserFlow back to ..."). The old flow is never deleted, so this always works.
|
||||
# browserFlow is a realm setting: Realm settings -> ... or
|
||||
# PUT /admin/realms/homelab with browserFlow set to the previous alias
|
||||
# (read-modify-write the realm representation; a partial body drops the passkey policy).
|
||||
# The admin console path is Authentication -> Flows -> <old flow> -> Action -> Bind flow
|
||||
# -> Browser flow. The console does this as one atomic change too.
|
||||
# ROLLBACK — see "The bind switch" above: PR bind-browser-flow "false" FIRST, then rebind
|
||||
# the previous flow (Authentication -> Flows -> <previous> -> Action -> Bind flow ->
|
||||
# Browser flow). The old flow is never deleted, so this always works.
|
||||
|
||||
# Regenerate the desired state after editing the passkey policy or the flow in the realm
|
||||
# import (the merge gate will demand it).
|
||||
python3 ci/render-keycloak-passkey-policy.py --write
|
||||
```
|
||||
|
||||
**If you would rather the go-live be a human act,** the reconciler takes `--no-bind`: it
|
||||
builds and verifies the flow and stops, printing the one write needed to bind it. That
|
||||
automates everything except one atomic operator action.
|
||||
### Testing the candidate flow before binding it
|
||||
|
||||
### Verifying it actually works (nobody has, as of 2026-10-18)
|
||||
With `bind-browser-flow: "false"` the candidate exists but nothing logs in through it. Test it
|
||||
on **one client** without touching the realm binding, using a per-client flow override:
|
||||
|
||||
1. Find the candidate: the Job log line `WOULD BIND browser-mfa-<hash>`.
|
||||
2. Admin console → *Clients → `account-console` → Advanced → Authentication flow overrides →
|
||||
Browser Flow* = `browser-mfa-<hash>`. This is a temporary, hand-made client change; remove it
|
||||
in step 7.
|
||||
3. **Password fallback.** Private window → `https://keycloak.<base domain>/realms/homelab/account`.
|
||||
Expect a **username** screen, then a choice: passkey, or the password form. As a user with no
|
||||
passkey, choose password and sign in.
|
||||
4. **Platform-admin TOTP on the password branch.** Repeat step 3 as a `platform-admins` member:
|
||||
after the password, a TOTP prompt **must** appear. If it does not — or you get a 403 — the
|
||||
conditional-OTP step is wrong: stop, do not bind.
|
||||
5. **Passkey enrolment.** Signed in to the account console → *Signing in → Passkey → Set up*. A
|
||||
browser/OS passkey prompt must appear; if it does not, the RP ID is wrong for the origin and
|
||||
nothing server-side will say so.
|
||||
6. **Passkey login.** Sign out; private window again; username, then choose passkey. Expect to be
|
||||
signed in with **no** TOTP prompt, even as a platform-admin.
|
||||
7. Remove the override from step 2.
|
||||
8. Only now: the one-line PR `bind-browser-flow: "true"`. The next run binds the candidate you
|
||||
tested (same alias — the hash is of the declared shape).
|
||||
|
||||
### Verifying it actually works (nobody has, as of 2026-10-29)
|
||||
|
||||
No passkey has been enrolled and no browser has been pointed at this. The checks that would prove
|
||||
it, in order:
|
||||
it on the BOUND flow, after go-live, in order:
|
||||
|
||||
```sh
|
||||
# 1. the policy is live (RP ID must read as the base domain, NOT the keycloak host, NOT a URL)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# GENERATED FILE — DO NOT EDIT BY HAND.
|
||||
# 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
|
||||
@@ -10,8 +10,14 @@
|
||||
# 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.
|
||||
# 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
|
||||
@@ -51,6 +57,22 @@ metadata:
|
||||
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": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user