Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Fix `$${javascript: ...}` expressions returning arrays or objects failing with `The Context is already closed` [#1653](https://github.com/adorsys/keycloak-config-cli/issues/1653)
- Fix organization import erasing the Keycloak 26.8 domain routing (`identityProviderAlias`, `autoRedirect`) on every run and failing to create an organization with a routed domain. Identity providers are now linked before the organization is updated, routing not declared in the import is kept, and an empty `identityProviderAlias` removes it [#1691](https://github.com/adorsys/keycloak-config-cli/issues/1691)
- Fix `Unrecognized field "types"` when importing or normalizing identity providers exported from Keycloak 26.5+ (`keycloak-client-common-synced` is now pinned to `keycloak.client.version` instead of the older version from the Keycloak BOM) [#1665](https://github.com/adorsys/keycloak-config-cli/issues/1665)
- Fix role change-detection issuing spurious updates on cross-instance imports by ignoring server-generated `id` and `containerId` when comparing realm and client roles (previously caused HTTP 403 failures on `admin`, `create-realm`, and `realm-management` roles on Keycloak 26.6.4+)

## [6.5.1] - 2026-05-22

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import de.adorsys.keycloak.config.util.CloneUtil;
import de.adorsys.keycloak.config.util.KeycloakUtil;
import de.adorsys.keycloak.config.util.ParallelUtil;
import org.apache.commons.lang3.ArrayUtils;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.RolesRepresentation;
import org.slf4j.Logger;
Expand All @@ -52,6 +53,7 @@ public class RoleImportService {
private static final String[] propertiesWithDependencies = new String[]{
"composites",
};
private static final String[] propertiesToIgnoreOnUpdate = ArrayUtils.addAll(propertiesWithDependencies, "id", "containerId");

private final RealmRoleCompositeImportService realmRoleCompositeImport;
private final ClientRoleCompositeImportService clientRoleCompositeImport;
Expand Down Expand Up @@ -213,12 +215,12 @@ private void updateRoleIfNeeded(
RoleRepresentation roleToImport
) {
String roleName = roleToImport.getName();
RoleRepresentation patchedRole = CloneUtil.patch(existingRole, roleToImport, propertiesWithDependencies);
RoleRepresentation patchedRole = CloneUtil.patch(existingRole, roleToImport, propertiesToIgnoreOnUpdate);
if (roleToImport.getAttributes() != null) {
Comment on lines +218 to 219

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.

I disagree that this change is the cause of the underlying analysis.
Further the method is only called if existingRole != null and CloneUtil.patch only returns null if origin (or existingRole in this case) is null.

patchedRole.setAttributes(roleToImport.getAttributes());
}

if (!CloneUtil.deepEquals(existingRole, patchedRole)) {
if (!CloneUtil.deepEquals(existingRole, patchedRole, propertiesToIgnoreOnUpdate)) {
logger.debug("Update realm-level role '{}' in realm '{}'", roleName, realmName);
roleRepository.updateRealmRole(realmName, patchedRole);
} else {
Expand All @@ -232,10 +234,10 @@ private void updateClientRoleIfNecessary(
RoleRepresentation existingRole,
RoleRepresentation roleToImport
) {
RoleRepresentation patchedRole = CloneUtil.patch(existingRole, roleToImport, propertiesWithDependencies);
RoleRepresentation patchedRole = CloneUtil.patch(existingRole, roleToImport, propertiesToIgnoreOnUpdate);
String roleName = existingRole.getName();
Comment on lines +237 to 238

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 above!


if (CloneUtil.deepEquals(existingRole, patchedRole)) {
if (CloneUtil.deepEquals(existingRole, patchedRole, propertiesToIgnoreOnUpdate)) {
logger.debug("No need to update client-level role '{}' for client '{}' in realm '{}'", roleName, clientId, realmName);
} else {
logger.debug("Update client-level role '{}' for client '{}' in realm '{}'", roleName, clientId, realmName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,76 @@ void shouldImportRealmWithNestedComposites() throws IOException {
assertThat(composites.getRealm(), is(nullValue()));
}

@Test
@Order(80)
void shouldNotChangeRolesWhenOnlyRoleIdentifiersDiffer() throws IOException {
// Given: a realm whose roles were created by a previous import
doImport("80.1_create_realm_with_roles_for_id_mismatch.json");

String realmName = "realmWithRoles80";
RealmRepresentation createdRealm = keycloakProvider.getInstance().realm(realmName).partialExport(true, true);

RoleRepresentation createdRealmRole = keycloakRepository.getRealmRole(createdRealm, "my_realm_role_80");
RoleRepresentation createdClientRole = keycloakRepository.getClientRole(createdRealm, "moped-client", "my_client_role_80");

String realmRoleId = createdRealmRole.getId();
String realmRoleContainerId = createdRealmRole.getContainerId();
String clientRoleId = createdClientRole.getId();
String clientRoleContainerId = createdClientRole.getContainerId();

// When: the same content is imported again, but carrying identifiers of a foreign instance
doImport("80.2_update_realm_with_mismatching_role_ids.json");

// Then: no role was changed - identifiers of this instance are untouched, content is unchanged
RealmRepresentation realm = keycloakProvider.getInstance().realm(realmName).partialExport(true, true);

RoleRepresentation realmRole = keycloakRepository.getRealmRole(realm, "my_realm_role_80");
assertThat(realmRole.getId(), is(realmRoleId));
assertThat(realmRole.getContainerId(), is(realmRoleContainerId));
assertThat(realmRole.getDescription(), is("My realm role"));

RoleRepresentation clientRole = keycloakRepository.getClientRole(realm, "moped-client", "my_client_role_80");
assertThat(clientRole.getId(), is(clientRoleId));
assertThat(clientRole.getContainerId(), is(clientRoleContainerId));
assertThat(clientRole.getDescription(), is("My moped-client role"));
}

@Test
@Order(81)
void shouldStillApplyRealContentChangeWhenRoleIdentifiersDiffer() throws IOException {
// Given: the roles of the realm as left by the previous import
String realmName = "realmWithRoles80";
RealmRepresentation realmBefore = keycloakProvider.getInstance().realm(realmName).partialExport(true, true);

String realmRoleId = keycloakRepository.getRealmRole(realmBefore, "my_realm_role_80").getId();
String clientRoleId = keycloakRepository.getClientRole(realmBefore, "moped-client", "my_client_role_80").getId();

// When: an import with foreign identifiers AND changed descriptions is applied
doImport("80.3_update_realm_with_mismatching_role_ids_and_changed_content.json");

// Then: the content change is applied, while this instance keeps its own role identifiers
RealmRepresentation realm = keycloakProvider.getInstance().realm(realmName).partialExport(true, true);

RoleRepresentation realmRole = keycloakRepository.getRealmRole(realm, "my_realm_role_80");
assertThat(realmRole.getDescription(), is("My changed realm role"));
assertThat(realmRole.getId(), is(realmRoleId));

RoleRepresentation clientRole = keycloakRepository.getClientRole(realm, "moped-client", "my_client_role_80");
assertThat(clientRole.getDescription(), is("My changed moped-client role"));
assertThat(clientRole.getId(), is(clientRoleId));
}

@Test
@Order(82)
void shouldImportProtectedRolesWithMatchingContentWithoutUpdate() {
// Given: an import containing protected roles (admin, create-realm, realm-management client roles)
// whose content matches the target, but whose identifiers come from a foreign instance

// When: the import runs
// Then: it completes - a spurious update of a protected role would be rejected with HTTP 403
assertDoesNotThrow(() -> doImport("82_import_realm_with_protected_roles.json"));
}

@SuppressWarnings("SpringJavaAutowiredMembersInspection")
@Nested
@Order(60)
Expand Down
Loading
Loading