From 4f91b5246e1924bf2ae3665a6b1bd6246e2ba896 Mon Sep 17 00:00:00 2001 From: Martin Kanis Date: Tue, 13 Jan 2026 14:18:24 +0100 Subject: [PATCH] User REST Admin API - count and search returns different amount of users Closes #45219 Signed-off-by: Martin Kanis --- .../admin/client/resource/UsersResource.java | 32 +++++++++ .../resources/admin/UsersResource.java | 3 +- .../tests/admin/user/UserSearchTest.java | 65 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java index 477b8e6a1b3..478c16ba6df 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java @@ -401,6 +401,38 @@ public interface UsersResource { @QueryParam("idpUserId") String idpUserId, @QueryParam("q") String searchQuery); + /** + * Returns the number of users that can be viewed and match the given filters. + * Includes support for exact matching. + * + * @param search arbitrary search string for all the fields below + * @param last last name field of a user + * @param first first name field of a user + * @param email email field of a user + * @param emailVerified emailVerified field of a user + * @param username username field of a user + * @param enabled Boolean representing if user is enabled or not + * @param idpAlias The alias of an Identity Provider linked to the user + * @param idpUserId The userId at an Identity Provider linked to the user + * @param exact Boolean which defines whether the params must match exactly + * @param searchQuery A query to search for custom attributes + * @return number of users matching the given filters + */ + @Path("count") + @GET + @Produces(MediaType.APPLICATION_JSON) + Integer count(@QueryParam("search") String search, + @QueryParam("lastName") String last, + @QueryParam("firstName") String first, + @QueryParam("email") String email, + @QueryParam("emailVerified") Boolean emailVerified, + @QueryParam("username") String username, + @QueryParam("enabled") Boolean enabled, + @QueryParam("idpAlias") String idpAlias, + @QueryParam("idpUserId") String idpUserId, + @QueryParam("exact") Boolean exact, + @QueryParam("q") String searchQuery); + /** * Returns the number of users with the given status for emailVerified. * If none of the filters is specified this is equivalent to {{@link #count()}}. diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index d53f139928c..d2e41f77261 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -450,7 +450,8 @@ public class UsersResource { return session.users().getUsersCount(realm, parameters); } } - } else if (last != null || first != null || email != null || username != null || emailVerified != null || enabled != null || !searchAttributes.isEmpty()) { + } else if (last != null || first != null || email != null || username != null || emailVerified != null + || idpAlias != null || idpUserId != null || enabled != null || exact != null || !searchAttributes.isEmpty()) { Map parameters = new HashMap<>(); if (last != null) { parameters.put(UserModel.LAST_NAME, last); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/user/UserSearchTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/user/UserSearchTest.java index 203b4583edd..bbcb08cf6fb 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/user/UserSearchTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/user/UserSearchTest.java @@ -788,4 +788,69 @@ public class UserSearchTest extends AbstractUserTest { users = managedRealm.admin().users().search("username", 0, 20); assertEquals(9, users.size()); } + + @Test + public void testCountAndSearchConsistencyWithMissingParameters() { + createUser("user1", "user1@example.com"); + + // Count users before creating service account (should be 1 regular user) + Integer countBefore = managedRealm.admin().users().count(null, null, null, null, null, null, null, null, null, true, null); + assertEquals(1, countBefore.intValue(), "Should have 1 regular user before service account creation"); + + ClientRepresentation client = new ClientRepresentation(); + client.setClientId("service-account-client"); + client.setServiceAccountsEnabled(true); + client.setEnabled(true); + client.setPublicClient(false); + client.setSecret("secret"); + client.setRedirectUris(Arrays.asList("http://localhost")); + + String clientId = ApiUtil.getCreatedId(managedRealm.admin().clients().create(client)); + + // Count users after creating service account (should be 2: 1 regular + 1 service account) + Integer countAfter = managedRealm.admin().users().count(null, null, null, null, null, null, null, null, null, true, null); + assertEquals(2, countAfter.intValue(), "Should have 2 users after service account creation (1 regular + 1 service account)"); + + // exact=true inconsistency with service accounts + Integer count = managedRealm.admin().users().count(null, null, null, null, null, null, null, null, null, true, null); + List users = managedRealm.admin().users().search(null, null, null, null, 0, 10, null, null, true); + assertEquals(count.intValue(), users.size(), "Count and search should return same number with exact=true"); + + // idpAlias parameter with service account inclusion inconsistency + Integer countWithIdp = managedRealm.admin().users().count(null, null, null, null, null, null, null, "nonexistent-idp", null, null); + List usersWithIdp = managedRealm.admin().users().search(null, null, null, null, null, "nonexistent-idp", null, 0, 10, null, null); + assertEquals(countWithIdp.intValue(), usersWithIdp.size(), "Count and search should return same number with idpAlias parameter"); + + // idpUserId parameter with same inconsistency + Integer countWithIdpUserId = managedRealm.admin().users().count(null, null, null, null, null, null, null, null, "nonexistent-user-id", null); + List usersWithIdpUserId = managedRealm.admin().users().search(null, null, null, null, null, null, "nonexistent-user-id", 0, 10, null, null); + assertEquals(countWithIdpUserId.intValue(), usersWithIdpUserId.size(), "Count and search should return same number with idpUserId parameter"); + + managedRealm.admin().clients().get(clientId).remove(); + } + + @Test + public void testCountAndSearchConsistencyWithIdpParameters() { + createUser("user1", "user1@example.com"); + createUser("user2", "user2@example.com"); + + addSampleIdentityProvider("test-idp", 0); + + // Link only user1 to the IDP + String user1Id = managedRealm.admin().users().search("user1").get(0).getId(); + FederatedIdentityRepresentation link = new FederatedIdentityRepresentation(); + link.setUserId("external-user-id"); + link.setUserName("user1"); + addFederatedIdentity(user1Id, "test-idp", link); + + // search with idpAlias should return 1 user, count should also return 1 + Integer countWithIdpAlias = managedRealm.admin().users().count(null, null, null, null, null, null, null, "test-idp", null, null); + List usersWithIdpAlias = managedRealm.admin().users().search(null, null, null, null, null, "test-idp", null, 0, 10, null, null); + assertEquals(countWithIdpAlias.intValue(), usersWithIdpAlias.size(), "Count and search should return same number with idpAlias parameter"); + + // search with idpUserId should return 1 user, count should also return 1 + Integer countWithIdpUserId = managedRealm.admin().users().count(null, null, null, null, null, null, null, null, "external-user-id", null); + List usersWithIdpUserId = managedRealm.admin().users().search(null, null, null, null, null, null, "external-user-id", 0, 10, null, null); + assertEquals(countWithIdpUserId.intValue(), usersWithIdpUserId.size(), "Count and search should return same number with idpUserId parameter"); + } }