diff --git a/apps/wave-1/keycloak.yaml b/apps/wave-1/keycloak.yaml index a21e8ff..29a38d7 100644 --- a/apps/wave-1/keycloak.yaml +++ b/apps/wave-1/keycloak.yaml @@ -75,7 +75,7 @@ spec: spec: containers: - name: keycloak - image: quay.io/keycloak/keycloak + image: quay.io/keycloak/keycloak:26.0.6 ports: - containerPort: 8080 volumeMounts: diff --git a/catalog/keycloak.yaml b/catalog/keycloak.yaml index 30b5dd5..06dcc08 100644 --- a/catalog/keycloak.yaml +++ b/catalog/keycloak.yaml @@ -5,8 +5,15 @@ # Validates against schemas/servicedefinition.schema.json. name: keycloak kind: container +# DOCUMENTATION-ONLY IMAGE. The running server is NOT chosen here: the Keycloak operator +# (platform/keycloak/kustomization.yaml, three pinned release URLs) picks it via its +# RELATED_IMAGE_KEYCLOAK, and the Keycloak CR has no spec.image. This SD's render, +# apps/wave-1/keycloak.yaml, is not listed in apps/wave-1/kustomization.yaml and deploys nothing +# (ci/render-golden.sh RENDER_GOLDEN_NOT_ONBOARDED). The tag records the operator's version for +# readers; renovate.json5 disables Renovate for this image so it cannot raise PRs against a file +# nothing deploys. The Keycloak version moves with the operator — update this tag by hand then. source: - image: quay.io/keycloak/keycloak + image: quay.io/keycloak/keycloak:26.0.6 exposure: public # login/redirect flows for internet-exposed services; hardened (docs/15; R-DNS/R-NET) egress: internet # normal WAN egress (docs/03; R-NET-05) storage: diff --git a/platform/keycloak/README.md b/platform/keycloak/README.md index 8b6c6c9..032fa98 100644 --- a/platform/keycloak/README.md +++ b/platform/keycloak/README.md @@ -19,7 +19,9 @@ Docker Hub images were purged (DECISIONS-LOG **F-KY1**) → that path ImagePullB 3-replica **`longhorn`** StorageClass (DR-critical, R-IDP-05), with declarative backup hooks. quay.io + ghcr.io are unaffected by the Bitnami purge. Operator manifests are pinned upstream release -files pulled as remote kustomize bases (the kubevirt-stub pattern); Renovate maintains the version. +files pulled as remote kustomize bases (the kubevirt-stub pattern). Renovate tracks the version via +`# renovate:` markers on those URLs (since 2026-10-30 — before that it could not see them) and never +auto-merges it; see [Upgrading Keycloak](#upgrading-keycloak-the-operator-is-the-version). ## Layout (controllers / configs split — F-MON1) @@ -360,6 +362,38 @@ it on the BOUND flow, after go-live, in order: A failed passkey ceremony is usually silent in the server log — the browser refuses before Keycloak is involved — so step 2 failing with no log entry is the expected signature of a wrong RP ID. +## Upgrading Keycloak: the operator IS the version + +The `Keycloak` CR has no `spec.image`, so the server image is whatever the operator's +`RELATED_IMAGE_KEYCLOAK` names — the same tag as the operator (prod, 2026-10-30: operator +`26.0.6`, `keycloak-0/1` on `quay.io/keycloak/keycloak:26.0.6`). Operator, CRDs and server are +released in lockstep from `keycloak/keycloak-k8s-resources`, and the three URLs in +`kustomization.yaml` are the one knob. Renovate raises them as ONE PR (`keycloak-operator` group, +labels `needs-review` + `blast-radius`), **never auto-merged**; majors wait for a dashboard tick. +`catalog/keycloak.yaml`'s image tag is documentation only (its render deploys nothing) and +Renovate is disabled for it — update it by hand to match after an operator bump merges. + +**An operator bump requires re-checking the passkey reconciler's assumptions.** +`passkey-reconciler.yaml` (and `ci/render-keycloak-passkey-policy.py`, which generates its desired +state) is written against **Keycloak 26.0.6 Admin API behaviour read from that tag's source** — +grep it for `26.0.6`. The gate cannot catch a break: it is static and never runs the reconciler +against a server. Before merging a bump, diff the new tag against 26.0.6 for at least: + +- `AuthenticationManagementResource.recurseExecutions` — does a sub-flow row still carry its name + in `displayName` (not `alias`)? The reconciler's flow matching depends on it. +- `updateExecutions` / execution-requirement updates — same request shape and id resolution? +- the WebAuthn Passwordless policy — is it still gated all-or-nothing on a non-empty + `webAuthnPolicyPasswordlessRpEntityName`, and does a realm `PUT` still REPLACE rather than merge + `requiredActions`? (The strict read-modify-write exists because of both.) +- authenticator-config alias handling for copied flows. + +Then, after the merge reconciles (operator actions): watch the `Keycloak` CR reach Ready and both +pods roll; run the reconciler once **with `--audit`** (see "Passkeys" above for the one-off Job) +and confirm it reports convergence with no write; confirm the client-secret and client-URL +reconcilers' next runs succeed; log in to one SSO app and run one `kubectl --context k3s-prod` +OIDC command. Roll back by reverting the PR — but a server minor may migrate the database +schema on start, so take/confirm a fresh CNPG backup before merging. + ## Exceptions that keep native auth (do not force SSO) — ADR-0014 Plex/Seerr (Plex auth), Minecraft (Mojang/Microsoft), UniFi (native). diff --git a/platform/keycloak/kustomization.yaml b/platform/keycloak/kustomization.yaml index de861be..8b58b52 100644 --- a/platform/keycloak/kustomization.yaml +++ b/platform/keycloak/kustomization.yaml @@ -21,8 +21,17 @@ namespace: keycloak resources: - namespace.yaml # --- Keycloak Operator v26.0.6 (pinned upstream release manifests) --- + # The operator, its CRDs and the SERVER IMAGE move in lockstep: the Keycloak CR has no + # spec.image, so the operator's RELATED_IMAGE_KEYCLOAK (quay.io/keycloak/keycloak:) + # is what runs. These three URLs are therefore THE Keycloak version. Each carries a Renovate + # marker (custom.regex manager, renovate.json5) under ONE depName, so all three bump in ONE + # branch, never auto-merged (packageRule "keycloak-operator"). Before merging a bump, re-check + # passkey-reconciler.yaml's 26.0.6 Admin API assumptions — see README.md "Upgrading Keycloak". + # renovate: datasource=github-tags depName=keycloak/keycloak-k8s-resources - https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.0.6/kubernetes/keycloaks.k8s.keycloak.org-v1.yml + # renovate: datasource=github-tags depName=keycloak/keycloak-k8s-resources - https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.0.6/kubernetes/keycloakrealmimports.k8s.keycloak.org-v1.yml + # renovate: datasource=github-tags depName=keycloak/keycloak-k8s-resources - https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.0.6/kubernetes/kubernetes.yml # --- Admin (break-glass) credential — SOPS-encrypted, referenced by name (ADR-0007, R-IDP-04) --- - keycloak-admin-credentials.sops.yaml diff --git a/renovate.json5 b/renovate.json5 index 08c9d2d..712d6d0 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -260,6 +260,35 @@ "blast-radius" ] }, + // ADDED 2026-10-30 — THE KEYCLOAK OPERATOR IS THE KEYCLOAK VERSION. The operator, its CRDs + // and the server image are released in lockstep from keycloak/keycloak-k8s-resources, and the + // Keycloak CR carries no spec.image, so the operator's RELATED_IMAGE_KEYCLOAK picks the server + // (measured on prod 2026-10-30: operator 26.0.6 -> keycloak-0/1 run quay.io/keycloak/keycloak: + // 26.0.6). The three release URLs in platform/keycloak/kustomization.yaml carry `# renovate:` + // markers under this ONE depName, so they move together in one branch (groupName makes that + // explicit). NEVER AUTO-MERGED, not even a patch: Keycloak is the IdP that gates SSO for every + // app AND kubectl OIDC, and platform/keycloak/passkey-reconciler.yaml encodes Keycloak 26.0.6 + // Admin API shapes read from that tag's source (sub-flow rows named by displayName, the + // RpEntityName all-or-nothing guard, requiredActions REPLACE-not-merge, ...). The gate is + // static and does not run that reconciler against a server, so a green check says nothing + // about whether a bump breaks it. Majors still wait on the dashboard (rule at the bottom). + { + "description": "Keycloak operator + CRDs + server (lockstep, one branch). IdP blast radius; passkey-reconciler.yaml encodes 26.0.6 Admin API shapes — re-check it against the new tag before merging. Never auto-merge.", + "matchDepNames": ["keycloak/keycloak-k8s-resources"], + "groupName": "keycloak-operator", + "automerge": false, + "addLabels": ["needs-review", "blast-radius"] + }, + // ADDED 2026-10-30 — quay.io/keycloak/keycloak IS NOT A DEPENDENCY RENOVATE MAY MOVE. Its only + // occurrences are catalog/keycloak.yaml `source.image` and the apps/wave-1/keycloak.yaml it + // renders, which is NOT listed in apps/wave-1/kustomization.yaml and deploys nothing (the + // operator above runs the server). A PR bumping it would change no running pod while looking + // like a Keycloak upgrade, and could drift from the operator's real version. + { + "description": "quay.io/keycloak/keycloak appears only in the documentation-only catalog/keycloak.yaml and its undeployed render; the operator (keycloak/keycloak-k8s-resources) chooses the real server image.", + "matchPackageNames": ["quay.io/keycloak/keycloak"], + "enabled": false + }, { "description": "ADDED 2026-10-29. This repo's own images are followed ONLY through the `# renovate-digest:` markers above. The `kubernetes` manager also sees platform/pm-agent/deployment.yaml and infra/agent/worker-job-template.yaml, where the image has a digest and no tag; left on, it would look those up as `latest` (a tag CI never pushes) and, for the template, re-open the self-referential loop the custom manager deliberately avoids.", "matchManagers": ["kubernetes"], @@ -334,6 +363,8 @@ // version before any automation can maintain them, and until then they drift on every pull with // nothing recording what is running. CORRECTED 2026-10-30: nextcloud:31-apache and docker:27-dind // ARE maintained (the first dry run proposed 35-apache and 29-dind; the suffix is kept). + // CORRECTED 2026-10-30: quay.io/keycloak/keycloak was never a running reference — the operator + // picks the server image — and the Keycloak version is now tracked via the operator URLs. // Separately, the HelmRelease minor-locking ranges (9 of 12, e.g. `>=88.0.0 <89.0.0` on // kube-prometheus-stack): THIS FILE USED TO SAY they cap Renovate at patches. They do not — // Renovate WIDENS the range (runs 353/354). The `range-widen` rule above keeps that a reviewed