Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Enhance getting all Clients to remove Flow Override by using pagination by 100 to avoid timeout [#1384](https://github.com/adorsys/keycloak-config-cli/issues/1384)
- JavaScript variable substitution support in configuration files [#934](https://github.com/adorsys/keycloak-config-cli/issues/934)
- Add support for Keycloak Workflows management
- Add option to configure ignored user properties during user update (`--import.behaviors.user-update-ignored-properties`) [#910](https://github.com/adorsys/keycloak-config-cli/issues/910)

### Fixed
- Fix bug where `clientProfiles` and `clientPolicies` were erased when importing multiple realm configuration files
- Fix Keycloak compatibility by stripping `clientProfiles` and `clientPolicies` from top-level realm updates
- Improve idempotency for OTP policy, state, and checksum updates to avoid redundant realm updates
- Fix issue where empty or null composite realm roles were not being cleared during import
- Increase code point limit to 500MB for import and normalization processes

## [6.4.1] - 2026-01-28

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.springframework.validation.annotation.Validated;

import java.util.Collection;
import java.util.List;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
Expand Down Expand Up @@ -383,13 +384,18 @@ public static class ImportBehaviorsProperties {
@NotNull
private final ChecksumChangedOption checksumChanged;

@NotNull
private final Collection<String> userUpdateIgnoredProperties;

public ImportBehaviorsProperties(boolean syncUserFederation, boolean removeDefaultRoleFromUser, boolean skipAttributesForFederatedUser,
boolean checksumWithCacheKey, ChecksumChangedOption checksumChanged) {
boolean checksumWithCacheKey, ChecksumChangedOption checksumChanged,
@DefaultValue("attributes") Collection<String> userUpdateIgnoredProperties) {
this.syncUserFederation = syncUserFederation;
this.removeDefaultRoleFromUser = removeDefaultRoleFromUser;
this.skipAttributesForFederatedUser = skipAttributesForFederatedUser;
this.checksumWithCacheKey = checksumWithCacheKey;
this.checksumChanged = checksumChanged;
this.userUpdateIgnoredProperties = userUpdateIgnoredProperties == null ? List.of("attributes") : userUpdateIgnoredProperties;
}

public boolean isSyncUserFederation() {
Expand All @@ -412,6 +418,10 @@ public ChecksumChangedOption getChecksumChanged() {
return checksumChanged;
}

public Collection<String> getUserUpdateIgnoredProperties() {
return userUpdateIgnoredProperties;
}

public enum ChecksumChangedOption {
CONTINUE, FAIL
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
public class UserImportService {
private static final Logger logger = LoggerFactory.getLogger(UserImportService.class);

private static final String[] IGNORED_PROPERTIES_FOR_UPDATE = {"realmRoles", "clientRoles", "serviceAccountClientId", "attributes"};
private static final String[] ALWAYS_IGNORED_PROPERTIES_FOR_UPDATE = {"realmRoles", "clientRoles", "serviceAccountClientId", "attributes"};
private static final String USER_LABEL_FOR_INITIAL_CREDENTIAL = "initial";

private final RealmRepository realmRepository;
Expand Down Expand Up @@ -172,7 +172,7 @@ public void importUser() {

private void updateUser(UserRepresentation existingUser) {
UserRepresentation patchedUser = CloneUtil
.patch(existingUser, userToImport, IGNORED_PROPERTIES_FOR_UPDATE);
.patch(existingUser, userToImport, getIgnoredPropertiesForUpdate());

if (importConfigProperties.getBehaviors().isSkipAttributesForFederatedUser() && patchedUser.getFederationLink() != null) {
patchedUser.setAttributes(null);
Expand Down Expand Up @@ -253,6 +253,27 @@ private boolean isPasswordHistoryViolation(String errorMessage) {
|| lowerCaseError.contains("passwordpolicynotmetexception");
}

private String[] getIgnoredPropertiesForUpdate() {
Set<String> ignored = new LinkedHashSet<>();
ignored.addAll(Arrays.asList(ALWAYS_IGNORED_PROPERTIES_FOR_UPDATE));

Collection<String> configuredIgnored = null;
if (importConfigProperties != null && importConfigProperties.getBehaviors() != null) {
configuredIgnored = importConfigProperties.getBehaviors().getUserUpdateIgnoredProperties();
}

if (configuredIgnored != null) {
for (String prop : configuredIgnored) {
if (prop == null) continue;
String trimmed = prop.trim();
if (trimmed.isEmpty()) continue;
ignored.add(trimmed);
}
}

return ignored.toArray(new String[0]);
}

private void handleGroups() {
List<String> userGroupsToUpdate = userToImport.getGroups();
if (userGroupsToUpdate == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ void shouldPopulateConfigurationProperties() {
assertThat(properties.getBehaviors().isSkipAttributesForFederatedUser(), is(true));
assertThat(properties.getBehaviors().isChecksumWithCacheKey(), is(true));
assertThat(properties.getBehaviors().getChecksumChanged(), is(ChecksumChangedOption.FAIL));
assertThat(properties.getBehaviors().getUserUpdateIgnoredProperties(), contains("attributes"));
}

@EnableConfigurationProperties(ImportConfigProperties.class)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*-
* ---license-start
* keycloak-config-cli
* ---
* Copyright (C) 2017 - 2021 adorsys GmbH & Co. KG @ https://adorsys.com
* ---
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ---license-end
*/

package de.adorsys.keycloak.config.service;

import de.adorsys.keycloak.config.AbstractImportIT;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.test.context.TestPropertySource;

import java.io.IOException;
import java.util.List;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.core.Is.is;

@TestPropertySource(properties = {
"import.behaviors.user-update-ignored-properties=attributes,email"
})
@SuppressWarnings({"java:S5961", "java:S5976"})
class ImportUserUpdateIgnoredPropertiesIT extends AbstractImportIT {

private static final String REALM_NAME = "realmUserUpdateIgnoredProps";

ImportUserUpdateIgnoredPropertiesIT() {
this.resourcePath = "import-files/user-update-ignored-properties";
}

@Test
@Order(0)
void shouldCreateRealmWithUser() throws IOException {
doImport("00_create_realm_with_user.json");

UserRepresentation user = keycloakRepository.getUser(REALM_NAME, "ldapuser");
assertThat(user.getEmail(), is("ldapuser@old.example"));
}

@Test
@Order(1)
void shouldUpdateRolesButNotOverwriteEmailWhenConfigured() throws IOException {
UserRepresentation userBefore = keycloakRepository.getUser(REALM_NAME, "ldapuser");
assertThat(userBefore.getEmail(), is("ldapuser@old.example"));

doImport("01_update_user_roles_try_change_email.json");

UserRepresentation userAfter = keycloakRepository.getUser(REALM_NAME, "ldapuser");
assertThat(userAfter.getEmail(), is("ldapuser@old.example"));

List<String> realmLevelRoles = keycloakRepository.getUserRealmLevelRoles(REALM_NAME, "ldapuser");
assertThat(realmLevelRoles, hasItems("role_a", "role_b"));

List<String> clientLevelRoles = keycloakRepository.getUserClientLevelRoles(REALM_NAME, "ldapuser", "test-client");
assertThat(clientLevelRoles, contains("client_role_a"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Collections;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;

Expand Down Expand Up @@ -130,6 +132,32 @@ void shouldUpdateFieldsWithoutChangingPassword() throws IOException {
verify(userRepository, times(1)).updateUser(eq(realm), any(UserRepresentation.class));
}

@Test
void shouldIgnoreConfiguredUserUpdateProperties() throws IOException {
UserRepresentation userToImport = loadUserFromJson("import-files/users/61_create_realm_with_password_history_policy.json");
String realm = "testIgnoredUserProperties";

UserRepresentation existingUser = loadUserFromJson("import-files/users/61_create_realm_with_password_history_policy.json");
existingUser.setId("user1");

existingUser.setEmail("ldap@example.com");
userToImport.setEmail("new@example.com");
userToImport.setLastName("UpdatedLastName");

when(userRepository.search(realm, userToImport.getUsername())).thenReturn(Optional.of(existingUser));

RealmRepresentation realmRepresentation = new RealmRepresentation();
realmRepresentation.setRegistrationEmailAsUsername(false);
when(realmRepository.get(realm)).thenReturn(realmRepresentation);

when(importBehaviorsProperties.getUserUpdateIgnoredProperties()).thenReturn(List.of("attributes", "email"));

assertDoesNotThrow(() -> userImportService.doImport(createRealmImport(realm, userToImport)));

verify(userRepository).updateUser(eq(realm), any(UserRepresentation.class));
verify(userRepository).updateUser(eq(realm), argThat(u -> "ldap@example.com".equals(u.getEmail())));
}

private RealmImport createRealmImport(String realm, UserRepresentation user) {
RealmImport realmImport = new RealmImport();
realmImport.setRealm(realm);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"enabled": true,
"realm": "realmUserUpdateIgnoredProps",
"roles": {
"realm": [
{
"name": "role_a",
"description": "Role A",
"composite": false,
"clientRole": false
},
{
"name": "role_b",
"description": "Role B",
"composite": false,
"clientRole": false
}
],
"client": {
"test-client": [
{
"name": "client_role_a",
"description": "Client Role A",
"composite": false,
"clientRole": true
}
]
}
},
"clients": [
{
"clientId": "test-client",
"name": "test-client",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "my-special-client-secret",
"redirectUris": [
"*"
],
"webOrigins": [
"*"
]
}
],
"users": [
{
"username": "ldapuser",
"email": "ldapuser@old.example",
"enabled": true,
"firstName": "LDAP",
"lastName": "User"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"enabled": true,
"realm": "realmUserUpdateIgnoredProps",
"roles": {
"realm": [
{
"name": "role_a",
"description": "Role A",
"composite": false,
"clientRole": false
},
{
"name": "role_b",
"description": "Role B",
"composite": false,
"clientRole": false
}
],
"client": {
"test-client": [
{
"name": "client_role_a",
"description": "Client Role A",
"composite": false,
"clientRole": true
}
]
}
},
"clients": [
{
"clientId": "test-client",
"name": "test-client",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "my-special-client-secret",
"redirectUris": [
"*"
],
"webOrigins": [
"*"
]
}
],
"users": [
{
"username": "ldapuser",
"email": "ldapuser@new.example",
"enabled": true,
"firstName": "LDAP",
"lastName": "User",
"realmRoles": [
"role_a",
"role_b"
],
"clientRoles": {
"test-client": [
"client_role_a"
]
}
}
]
}
Loading