Skip to content

feat(roles): skip import writes to protected roles - #1675

Open
RutgerLubbers wants to merge 3 commits into
adorsys:mainfrom
Q24:feat/unalterable-roles
Open

RutgerLubbers wants to merge 3 commits into
adorsys:mainfrom
Q24:feat/unalterable-roles

Conversation

@RutgerLubbers

@RutgerLubbers RutgerLubbers commented Sep 3, 2026 •

Copy link
Copy Markdown

What this PR does / why we need it:

Keycloak 26.6.4 stopped allowing changes to a few roles. The realm roles admin and
create-realm, and all roles of the realm-management client, now return 403 if you
try to update or delete them through the Admin API. Also see #1673 .

Every realm export contains those roles. Today we feed all of them into the update,
delete and composite-sync paths, so the moment one of them actually differs from the
target realm, Keycloak answers 403 and the whole import stops. Importing a realm export
is our main use case, so this is a hard blocker.

This PR adds import.protected-roles.* — a set of roles the import never updates,
deletes, or composite-syncs. It works out of the box: with no configuration at all, the
roles Keycloak blocks are already protected.

The same setting solves a second thing: keeping a break-glass superuser or similar
high-privilege role out of reach of whoever edits the config files.

Details:

  • Protection covers update, delete and composite sync. It does not cover
    creation — a protected role that doesn't exist yet is still created as before.
  • Protection is not transitive. An unprotected role can still list a protected role
    among its composites, and that gets synced normally. Only writes to the protected
    role are blocked.
  • Realm roles and client roles are separate namespaces. A realm role named
    manage-users is not protected just because realm-management has a role by that
    name — matching is on the client + role pair.
  • Skipped writes are logged at info, in the same style as the existing
    admin-permissions client skip.
  • No version checks anywhere. The behaviour is the same on every Keycloak version we
    build against.

New settings:

Setting Meaning
import.protected-roles.mode add (default) protects the built-ins plus anything you configure. replace protects only what you configure.
import.protected-roles.realm-roles Extra realm role names to protect.
import.protected-roles.client-roles.<clientId> Role names to protect for that client. * protects all of them, including roles added later.

Defaults, i.e. what you get with no configuration:

import:
  protected-roles:
    mode: add
    realm-roles: [ admin, create-realm ]
    client-roles:
      realm-management: [ "*" ]

Full opt-out, restoring the old behaviour exactly:

import:
  protected-roles:
    mode: replace
    realm-roles: [ ]
    client-roles: { }

Which issue this PR fixes (optional, in fixes #<issue number>(, fixes #<issue_number>, ...) format, will close that issue when PR gets merged): fixes #1673

How this PR was tested:

  • New unit tests for the matching logic: realm vs client namespaces, the * wildcard,
    add vs replace, the empty-replace opt-out, unknown clients, and the name-collision
    case.
  • New unit tests on the import service covering the update skip, the delete skip under
    managed.role=full, and the log message.
  • New property-binding tests, including that an unknown mode value fails at startup.
  • New integration tests in ImportRolesIT (orders 80–86) against a real Keycloak.
    These deliberately force a real difference on a protected role — an export that
    already matches the server performs no writes at all, so a naive export→import→import
    test would pass even without this change. Covered: the role's own fields, its
    composites, managed.role=full deletion, both modes, the opt-out, the namespace
    collision, and a protected client that isn't in the realm.
  • ImportRolesIT: 49 tests, 0 failures, against Keycloak 26.5.5. Full unit suite green,
    along with checkstyle, license, spotbugs and pmd.

Will run tests against a container to verify the issue on our setup.

Screenshots / logs (if relevant):

Special notes for your reviewer:

  1. This is a behaviour change, and the CHANGELOG says so. On Keycloak versions older
    than 26.6.4, changing e.g. the description of the admin realm role used to work and
    will now be skipped with an info log. That's the deliberate trade-off for making the
    defaults work with zero configuration; mode: replace with empty lists is the
    documented way back.

  2. The built-in defaults are constants in the code, not property defaults. If they
    were declared as defaults in application.properties, mode: replace with an empty
    map would be impossible to express — Spring can't unset an already-bound map key, so
    the opt-out wouldn't actually opt out. Resolving them in code keeps add a real union
    and replace a real replacement.

PR Readiness Checklist:

  • tests pass locally (mvn test or relevant subset)
  • added/updated tests for the changes (if applicable)
  • documentation has been updated (if applicable)
  • the CHANGELOG.md release notes have been updated to reflect any significant (and particularly user-facing) changes introduced by this PR

Keycloak 26.6.4 blocks updates and deletes of the realm roles admin and
create-realm and of all realm-management client roles, returning 403.
Since a realm export always contains those roles, any genuine drift made
the whole import abort.

Add import.protected-roles.* to define roles the import never updates,
deletes or composite-syncs. Creation is unaffected, and protection is not
transitive: an unprotected role may still reference a protected one.

Defaults protect admin, create-realm and every realm-management client
role, so the fix applies without configuration. mode=add unions extra
roles onto those defaults, mode=replace uses exactly what is configured,
and mode=replace with empty lists restores the previous behaviour.

This is a behaviour change: on Keycloak versions older than 26.6.4,
updates to the default-protected roles are now skipped with an info log.
Inject the resolver into RoleImportService instead of constructing it per
doImport call, and drop the threaded parameter from eight private methods.
Move the two role-filter helpers onto the resolver. No behaviour change.
Use Stream.toList() over Collectors.toList(), extract the role-delete loop
bodies into methods to drop the double continue, and clean up redundant test
annotations and an unreachable checked exception. No behaviour change.
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Comment on lines 228 to 230
RoleRepresentation patchedRole = CloneUtil.patch(existingRole, roleToImport, propertiesWithDependencies);
if (roleToImport.getAttributes() != null) {
patchedRole.setAttributes(roleToImport.getAttributes());

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is untouched.
Besides there is a guard against existingRole being null before this method is called. So the return is never null.

Comment on lines 247 to 248
RoleRepresentation patchedRole = CloneUtil.patch(existingRole, roleToImport, propertiesWithDependencies);
String roleName = existingRole.getName();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment for lines 228-230,

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow marking roles as unalterable so imports never modify or delete them

1 participant