From 82026277534e9382427a9784dd8372ebc4ecb82a Mon Sep 17 00:00:00 2001 From: Stefan Guilhen Date: Wed, 12 Aug 2026 13:22:48 -0300 Subject: [PATCH 1/2] Fix SCIM unfiltered search returning non-imported federated users Closes #51343 Signed-off-by: Stefan Guilhen --- .../model/user/UserResourceTypeProvider.java | 7 +- scim/tests/base/pom.xml | 6 + .../tests/scim/tck/FederatedUserTest.java | 140 ++++++++++++++++++ 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FederatedUserTest.java diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserResourceTypeProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserResourceTypeProvider.java index 6f4015db2a6a..9007f9d87b51 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/UserResourceTypeProvider.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/UserResourceTypeProvider.java @@ -38,6 +38,7 @@ import org.keycloak.scim.resource.schema.attribute.Attribute; import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider; import org.keycloak.scim.resource.user.User; +import org.keycloak.storage.UserStoragePrivateUtil; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; @@ -100,7 +101,7 @@ protected User onUpdate(UserModel model, User resource) { @Override protected UserModel getModel(String id) { RealmModel realm = session.getContext().getRealm(); - UserModel model = session.users().getUserById(realm, id); + UserModel model = UserStoragePrivateUtil.userLocalStorage(session).getUserById(realm, id); if (model == null || model.getServiceAccountClientLink() == null) { return model; @@ -148,7 +149,7 @@ protected Stream getModels(SearchRequest searchRequest) { .map(entity -> session.users().getUserById(realm, entity.getId())) .filter(Objects::nonNull)); } else { - return session.users().searchForUserStream(realm, Map.of(UserModel.INCLUDE_SERVICE_ACCOUNT, "false"), searchRequest.getStartIndex() - 1, searchRequest.getCount()); + return UserStoragePrivateUtil.userLocalStorage(session).searchForUserStream(realm, Map.of(UserModel.INCLUDE_SERVICE_ACCOUNT, "false"), searchRequest.getStartIndex() - 1, searchRequest.getCount()); } } @@ -171,7 +172,7 @@ public Long count(SearchRequest searchRequest, int resourceSize) { query.select(cb.countDistinct(root)).where(predicates); return em.createQuery(query).getSingleResult(); } else { - return (long) session.users().getUsersCount(realm, false); + return (long) UserStoragePrivateUtil.userLocalStorage(session).getUsersCount(realm, false); } } diff --git a/scim/tests/base/pom.xml b/scim/tests/base/pom.xml index 9729ca7ccade..fbc5df4404c1 100755 --- a/scim/tests/base/pom.xml +++ b/scim/tests/base/pom.xml @@ -21,5 +21,11 @@ ${project.version} test + + org.keycloak.tests + keycloak-tests-custom-providers + ${project.version} + test + diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FederatedUserTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FederatedUserTest.java new file mode 100644 index 000000000000..f3a04572a445 --- /dev/null +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FederatedUserTest.java @@ -0,0 +1,140 @@ +package org.keycloak.tests.scim.tck; + +import java.util.List; +import java.util.stream.Collectors; + +import org.keycloak.common.Profile.Feature; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.representations.idm.ComponentRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.scim.client.ResourceFilter; +import org.keycloak.scim.protocol.response.ListResponse; +import org.keycloak.scim.resource.user.User; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.testframework.util.ApiUtil; + +import org.junit.jupiter.api.Test; + +import static org.keycloak.storage.UserStorageProviderModel.IMPORT_ENABLED; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests that verify SCIM search behavior with non-imported federated users. + * + * @see Issue 51343 + */ +@KeycloakIntegrationTest(config = FederatedUserTest.FederatedUserServerConfig.class) +public class FederatedUserTest extends AbstractScimTest { + + private static final String PROVIDER_ID = "user-password-map-arq"; + + /** + * Verifies that SCIM filtered and unfiltered searches return consistent results + * when non-imported federated users exist. + * + * Before the fix, unfiltered searches went through UserStorageManager (which + * queries federated providers), while filtered searches used direct JPA queries + * against the local UserEntity table - so non-imported federated users would + * only appear in unfiltered results. + */ + @Test + public void testFilteredAndUnfilteredSearchConsistency() { + // create local users first, before registering the federation provider - + // otherwise the provider (which implements UserRegistrationProvider) intercepts + // user creation and the users end up as federated, not local. + createLocalUser("local-alice"); + createLocalUser("local-bob"); + + // register the federation provider with import disabled and create federated users + String federationComponentId = registerFederationProvider(); + try { + String charlieId = createFederatedUser("fed-charlie"); + String dianaId = createFederatedUser("fed-diana"); + + // unfiltered SCIM search - now uses local storage after the fix + ListResponse unfilteredResponse = client.users().getAll(); + assertNotNull(unfilteredResponse); + List unfilteredUserNames = toUserNames(unfilteredResponse); + + // filtered SCIM search (broad filter matching all users) - goes through JPA + String broadFilter = ResourceFilter.filter().pr("userName").build(); + ListResponse filteredResponse = client.users().getAll(broadFilter); + assertNotNull(filteredResponse); + List filteredUserNames = toUserNames(filteredResponse); + + // both local users should appear in both result sets + assertThat(unfilteredUserNames, hasItems("local-alice", "local-bob")); + assertThat(filteredUserNames, hasItems("local-alice", "local-bob")); + + // both searches should return the same number of results + assertEquals(unfilteredResponse.getTotalResults(), filteredResponse.getTotalResults(), + "Filtered and unfiltered SCIM searches should return the same number of results. " + + "Unfiltered returned: " + unfilteredUserNames + ", filtered returned: " + filteredUserNames); + + // non-imported federated users should not appear in SCIM results + assertThat(unfilteredUserNames, not(hasItem("fed-charlie"))); + assertThat(unfilteredUserNames, not(hasItem("fed-diana"))); + assertThat(filteredUserNames, not(hasItem("fed-charlie"))); + assertThat(filteredUserNames, not(hasItem("fed-diana"))); + + // non-imported federated users should not be retrievable by ID either + assertNull(client.users().get(charlieId), "GET by ID should not return non-imported federated user"); + assertNull(client.users().get(dianaId), "GET by ID should not return non-imported federated user"); + } finally { + realm.admin().components().component(federationComponentId).remove(); + } + } + + private List toUserNames(ListResponse response) { + return response.getResources().stream() + .map(User::getUserName) + .collect(Collectors.toList()); + } + + private void createLocalUser(String username) { + User user = new User(); + user.setUserName(username); + user.setActive(true); + user = client.users().create(user); + assertNotNull(user); + } + + private String registerFederationProvider() { + ComponentRepresentation provider = new ComponentRepresentation(); + provider.setName("test-user-federation"); + provider.setProviderId(PROVIDER_ID); + provider.setProviderType(UserStorageProvider.class.getName()); + provider.setConfig(new MultivaluedHashMap()); + provider.getConfig().putSingle("priority", Integer.toString(0)); + provider.getConfig().putSingle(IMPORT_ENABLED, Boolean.toString(false)); + + return ApiUtil.getCreatedId(realm.admin().components().add(provider)); + } + + private String createFederatedUser(String username) { + UserRepresentation user = new UserRepresentation(); + user.setUsername(username); + user.setEnabled(true); + return ApiUtil.getCreatedId(realm.admin().users().create(user)); + } + + public static class FederatedUserServerConfig implements KeycloakServerConfig { + + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config + .features(Feature.SCIM_API) + .dependency("org.keycloak.tests", "keycloak-tests-custom-providers"); + } + } +} From 1eacd4d4afadabead66789e34170fd1561f05387 Mon Sep 17 00:00:00 2001 From: Stefan Guilhen Date: Thu, 13 Aug 2026 10:01:34 -0300 Subject: [PATCH 2/2] Document that SCIM user searches only return locally stored users Signed-off-by: Stefan Guilhen --- .../server_admin/topics/scim/managing-users.adoc | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/documentation/server_admin/topics/scim/managing-users.adoc b/docs/documentation/server_admin/topics/scim/managing-users.adoc index 87e4210b6cef..51f75f3bf024 100644 --- a/docs/documentation/server_admin/topics/scim/managing-users.adoc +++ b/docs/documentation/server_admin/topics/scim/managing-users.adoc @@ -149,6 +149,13 @@ The response uses the SCIM list format: For filtering and pagination of results, see <<_scim_filtering_resources,Filtering resources>>. +NOTE: The SCIM `/Users` endpoint only returns users that are stored locally in the {project_name} database. Users +that are managed by external user federation providers (such as LDAP or custom providers) and have not been imported +into {project_name} are not included in SCIM search results. Federated users that have been imported (that is, +synchronized into the local database) are included. This is because {project_name} acts as the identity authority +only for locally stored users - external identity sources should expose their own SCIM endpoints for managing +their users. + == Selecting attributes You can control which attributes are included in the response for both single-resource and list requests