fix(helm): upgrade keycloak to 26.6 - #679
Conversation
Keycloak 26.4 is within the vulnerable range of CVE-2026-7504 / GHSA-rp95-xpg9-c2cq, fixed upstream in 26.6.2. keycloak-config-cli follows to 6.5.1-26. Two upstream behaviour changes had to be handled to make the bump work: - Since 26.6, JavaScript-based policies require the `scripts` feature, otherwise importing the realm fails with "Couldn't find policy provider with type [script-owner-policy.js]". It is enabled at build time for `start --optimized` and through KC_FEATURES for `start-dev`, which re-runs the augmentation and drops build-time options. - Since 26.6.2 the Protection API forces the owner of a resource to the resource server itself (keycloak/keycloak#49910, confirmed intended and permanent by the maintainer), which broke the "Owner" policy: reviews became owned by the `api-platform-api` client instead of their author, and the front-office review permissions were denied. Resource creation, lookup and deletion now go through the Admin API as recommended upstream, and the service account is granted the single `manage-authorization` role this requires. The client's internal id comes from configuration, so no `view-clients` role is needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(ops): update docker base images FrankenPHP 1.12.4 -> 1.12.6, and the E2E mock server follows the node:lts tag already used by the PWA image instead of pinning node 22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(helm): update external-dns chart to 1.21.1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(api): update composer dependencies api-platform 4.3.14 -> 4.3.17, doctrine/doctrine-bundle 3.2.4 -> 3.3.1, phpunit 13.2.1 -> 13.2.6, rector 2.4.6 -> 2.5.8, phpstan 2.2.2 -> 2.2.7, webonyx/graphql-php 15.33.1 -> 15.37.1, zenstruck/foundry 2.10.1 -> 2.11.0. Rector 2.5.8 adds NegatedAndsToPositiveOrsRector, which rewrites two test mocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(api): upgrade symfony to 8.1 The lock was already half on 8.1 through transitive Dependabot bumps (#655, #656), while composer.json still pinned 8.0.*. Symfony 8.1 reports a more precise denormalization error for backed enums, so the BookTest expectations are derived from BookCondition::cases() instead of the old "must belong to a backed enumeration" wording. Recipe updates are available for several packages, some predating this change, and are deliberately left out to keep the upgrade reviewable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(pwa): update minor and patch dependencies react-admin and the ra-* packages to 5.15.x, next 16.2.12, better-auth 1.6.25, react 19.2.8, tailwindcss 4.3.3, react-hook-form 7.83.0, pg 8.22.0 and the remaining patches. packageManager follows pnpm 11.18.0: 11.8.0 and 11.18.0 disagree on the importer specifier recorded for a direct dependency that an override also covers, so the postcss range is aligned with its override to keep the lockfile stable whichever version writes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(pwa): upgrade typescript to 6 TypeScript 6 rejects the deprecated `target: es5` (TS5107), which the project no longer needs: `moduleResolution` follows `bundler` as Next expects, and spreading the `matchAll` iterator no longer requires downlevel iteration, so the `@ts-expect-error` that covered it is dropped. TypeScript 7 is not reachable yet: eslint-config-next bundles typescript-eslint 8, whose peer range is `>=4.8.4 <6.1.0`, so it would break `pnpm lint`. ESLint 10 is left out for the same class of reason: eslint-plugin-react 7.37.5, the latest release, peers at `^9.7` and crashes under ESLint 10 with "contextOrFilename.getFilename is not a function". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(pwa): upgrade mui to 9 MUI has no v7 -> v8 step, 9.2.0 is the next major. react-admin 5.15.1 and ra-ui-materialui already peer on `^5.16.12 || ^6 || ^7 || ^9`. Three breaking changes had to be followed: - Box no longer accepts system props, they move to `sx`. - Menu replaces `MenuListProps` with `slotProps.list`. - The icon named `PersonOutline` is now `PersonOutlined`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(pwa): align @types/node with the node lts runtime 26.1.2 is the latest published, but the types must describe the runtime: both images build on `node:lts`, currently Node 24. The previous `^25` matched no version of Node in use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(pwa): remove unused @babel/core and @popperjs/core Neither is used by the project: there is no babel config in the repository and Next 16 compiles through SWC, while @popperjs/core is referenced by no source file. Both remain in the tree as transitive dependencies, so declaring them brought nothing but a spurious `@babel/core` 7 -> 8 major to consider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(e2e): upgrade playwright to 1.62 The CI runner image tag follows, otherwise the container would keep installing 1.58 while package.json asks for 1.62. @types/node tracks the Node LTS the images run on, like the PWA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The container limit was 500Mi with a 400Mi request, which leaves no headroom: importing the demo realm, with its authorization services and JavaScript policy, leaves the JVM just under the limit, so the pod is OOMKilled during rollout and the post-upgrade keycloak-config-cli hook then times out waiting for a Keycloak that never becomes ready. Measured locally with the demo realm imported and `start --optimized`: | version | limit 500Mi | limit 768Mi | | --- | --- | --- | | 26.4.7 | OOMKilled | 671Mi (87%) | | 26.7.0 | 496Mi (99%) | 553Mi (72%), 590Mi under admin API load | This is not a consequence of the version bump: 26.7 is in fact leaner than 26.4 here. The limit was simply too small for both, and any rollout was one allocation away from being killed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploy failure: Keycloak is OOMKilled, not a chart-content problemBoth attempts of the feature deploy have the same root cause. Attempt 1 surfaced as The
I was wrong about the causeMy first read was that 26.7 needs more memory than 26.4. Measured, that is not true — 26.7 is the leaner of the two. Same host, demo realm imported,
At 768Mi, 26.7.0 stays at 590Mi (77%) after 75 admin API calls of the kind the config-cli job makes. So the 500Mi limit was already without headroom on Fix pushed
Two things left to your judgement, since cluster sizing is yours:
Ready for another deploy attempt whenever you want. |
realm-demo.json is a full realm export, so it carries Keycloak's own built-in
clients. keycloak-config-cli therefore tries to update `realm-management`, which
Keycloak 26.7 forbids:
Update client 'realm-management' in realm 'demo'
Cannot update client 'realm-management' in realm 'demo': HTTP 403 Forbidden
The post-upgrade hook then fails on every retry and the release never completes.
26.4 allowed the update, which is why this only shows up now.
Keycloak recreates `realm-management` and its roles itself when the realm is
created, so removing them from the export changes nothing functionally: the
service account still resolves its `manage-authorization` role against the
auto-created client. The other built-in clients are left in place — only
`realm-management` is protected, and config-cli updates the rest without
complaint.
Verified against 26.7.0 with keycloak-config-cli 6.5.1-26: import succeeds on a
fresh realm and is idempotent on the second pass, which is what the
post-install/post-upgrade hook needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second blocker, and it is not the memoryThe memory fix worked — no new OOM in the third attempt (the I reproduced it locally. The exact error, from keycloak-config-cli with debug logging:
IsolationSame host, same config-cli invocation, realm imported from scratch:
So it is neither the config-cli bump nor the Fix pushedDrop the
I kept the removal minimal: only Import against 26.7.0 now succeeds on a fresh realm (8.8s) and is idempotent on a second pass (0.8s) — which is exactly what the Ready for another deploy attempt. |
26.7.0 refuses to manage any realm role literally named `admin`, in any realm:
// RolePermissions.canManage(RoleModel), 26.7.0
if (role.getContainer() instanceof RealmModel) {
return root.realm().canManageRealm() && !isRealmAdminRole(role);
}
private boolean isRealmAdminRole(RoleModel role) {
return role.getContainer() instanceof RealmModel
&& List.of(AdminRoles.ADMIN, AdminRoles.CREATE_REALM).contains(role.getName());
}
The check is purely name-based and ignores which realm the role belongs to, so the
demo realm's own `admin` role becomes unmanageable: `PUT /admin/realms/demo/roles/admin`
answers 403 even to a full master-realm admin, while `roles/user` and a freshly
created `admin2` update fine. keycloak-config-cli hits it as soon as that role has
drifted from the file, so the post-upgrade hook fails on every retry.
`isRealmAdminRole` exists only in 26.7.0 — it is absent from 26.6.4, 26.5.7 and
26.4.7 — and the advisory only requires >= 26.6.2. 26.6 therefore closes
CVE-2026-7504 without the regression, and keeps every other adjustment relevant:
26.6 already requires the `scripts` feature, and its Protection API already forces
the resource owner to the resource server.
Verified on 26.6.4: realm imported at startup, keycloak-config-cli succeeds and is
idempotent on a second pass, review resources are owned by their author again, and
the E2E @Write suite passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third blocker found, with cluster access — and it settles the version choiceThanks for the The memory fix worked. The Keycloak pod has been The But the hook still failed, on a third, different cause. What the pod logs showedReproduced directly against the cluster's Keycloak, with a full master-realm admin token:
So it is not the name pattern, not the composites, and not admin permissions v2 ( // RolePermissions.canManage(RoleModel)
if (role.getContainer() instanceof RealmModel) {
return root.realm().canManageRealm() && !isRealmAdminRole(role);
}
private boolean isRealmAdminRole(RoleModel role) {
return role.getContainer() instanceof RealmModel
&& List.of(AdminRoles.ADMIN, AdminRoles.CREATE_REALM).contains(role.getName());
}The check is purely name-based and ignores which realm the role belongs to. The demo realm defines its own realm role named Fix: pin 26.6, not 26.7
The PR title is updated accordingly. Verified on 26.6.4 locally: realm imported at startup, Worth reporting upstream
I left the cluster clean: the debug pods and jobs I created in |
Deploy is green
Cluster state on Keycloak reports The deployed environment answers:
And, as expected on this PR, the advisory is still open here: A live confirmation that the version bump alone does not remediate GHSA-2rmv-4743-38c6 — #676 is what closes it. |
Split out of #676 so it can land quickly, and carrying the dependency updates already reviewed in #678. The wildcard
redirectUrisremoval is now #676 alone.Summary
Keycloak
26.4is inside the vulnerable range of CVE-2026-7504 / GHSA-rp95-xpg9-c2cq, fixed upstream in26.6.2. This bumps the pinned image to26.7(latest stable) andadorsys/keycloak-config-clito6.5.1-26.Two upstream behaviour changes had to be handled
Without them the bump simply does not work.
Since 26.6, JavaScript-based policies require the
scriptsfeature. Otherwise the realm import aborts withCouldn't find policy provider with type [script-owner-policy.js]and Keycloak never starts. Enabled at build time forstart --optimized, and throughKC_FEATURESforstart-dev, which re-runs the augmentation and drops build-time options.Since 26.6.2 the Protection API forces the owner of a resource to the resource server itself (keycloak/keycloak#49910, introduced by Enforce owner checks when calling the resource set service keycloak/keycloak#49121 while fixing CVE-2025-14778). Confirmed intended and permanent by the maintainer:
This broke the
Ownerpolicy: reviews ended up owned by theapi-platform-apiclient instead of their author, soresource.owner == identity.idnever matched and the three front-office review E2E tests failed. Resource creation, lookup and deletion now go through the Admin API, and the service account is granted the singlemanage-authorizationrole that requires. The client's internal id comes from configuration, so noview-clientsrole is needed.Verified against
26.6.4and26.7.0, withownersent as a string and as an object: the Protection API always returned the resource server. Isolating it further, the same realm on26.4kept every test green.Dependency updates
The commits of #678 ride along unchanged — ops images, Helm chart, Composer, Symfony 8.1, the PWA block and Playwright, one commit per step. Their rationale and the deliberate exclusions (ESLint 10, TypeScript 7, PostgreSQL, Symfony recipes) are in #678.
Verification
Composed stacks, not just unit level.
owner: {"name": "john.doe@example.com"}instead of the client.compose.yaml:compose.prod.yaml:compose.e2e.yaml, images rebuilt, realm reimported from scratch, fixtures reloaded):@write8 passed / 3 flaky / 0 failed — the flaky set is the pre-existing login-page timing, green on retry.rector --dry-runclean ·doctrine:schema:validatein sync ·api:openapi:export | redocly lintvalid · php-cs-fixer 0 of 76 files · hadolint clean.helm lintplushelm templateondemo.api-platform.comandpr-42-demo.api-platform.com.Follow-up
Production only deploys on tag push (
cd.yml), sodemo.api-platform.comneeds a tag once this lands.🤖 Generated with Claude Code