From 81a7f38a7682d53d194b300bacdd744174384e92 Mon Sep 17 00:00:00 2001 From: Barathwaja S Date: Thu, 5 Jun 2025 00:49:57 -0500 Subject: [PATCH] Added emailVerified filtering for users endpoint; updated user count endpoint with logic to support enabled, emailVerified, idpAlias, idpUserId, and exact field query parameters Closes #38556 Closes #29295 Signed-off-by: Barathwaja S --- .../admin/client/resource/UsersResource.java | 50 +++++ .../resources/admin/UsersResource.java | 66 +++++-- .../org/keycloak/tests/admin/UsersTest.java | 172 +++++++++++++----- 3 files changed, 230 insertions(+), 58 deletions(-) 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 cbe8f46f4c0..75c7dc5027f 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 @@ -250,6 +250,29 @@ public interface UsersResource { @QueryParam("first") Integer firstResult, @QueryParam("max") Integer maxResults); + /** + * Returns the users that can be viewed and match the given filters. + * + * @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 + * @return the list of users matching the given filters + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + List search(@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("q") String searchQuery); + @GET @Produces(MediaType.APPLICATION_JSON) List list(@QueryParam("first") Integer firstResult, @@ -348,6 +371,33 @@ public interface UsersResource { @QueryParam("enabled") Boolean enabled, @QueryParam("q") String searchQuery); + /** + * Returns the number of users that can be viewed and match the given filters. + * If none of the filters is specified this is equivalent to {{@link #count()}}. + * + * @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 + * @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("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 d9b79ed3017..7d6c798ff84 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 @@ -312,6 +312,10 @@ public class UsersResource { if (enabled != null) { attributes.put(UserModel.ENABLED, enabled.toString()); } + if (emailVerified != null) { + attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString()); + } + return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, maxResults, false); } @@ -373,12 +377,17 @@ public class UsersResource { * {@code email} or {@code username} those criteria are matched against their * respective fields on a user entity. Combined with a logical and. * - * @param search arbitrary search string for all the fields below. Default search behavior is prefix-based (e.g., foo or foo*). Use *foo* for infix search and "foo" for exact search. - * @param last last name filter - * @param first first name filter - * @param email email filter - * @param username username filter + * @param search A String contained in username, first or last name, or email. Default search behavior is prefix-based (e.g., foo or foo*). Use *foo* for infix search and "foo" for exact search. + * @param last A String contained in lastName, or the complete lastName, if param "exact" is true + * @param first A String contained in firstName, or the complete firstName, if param "exact" is true + * @param email A String contained in email, or the complete email, if param "exact" is true + * @param username A String contained in username, or the complete username, if param "exact" is true + * @param emailVerified whether the email has been verified + * @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 enabled Boolean representing if user is enabled or not + * @param exact Boolean which defines whether the params "last", "first", "email" and "username" must match exactly + * @param searchQuery A query to search for custom attributes, in the format 'key1:value2 key2:value2' * @return the number of users that match the given criteria */ @Path("count") @@ -397,32 +406,46 @@ public class UsersResource { "2. If {@code search} is specified other criteria such as {@code last} will be ignored even though you set them. The {@code search} string will be matched against the first and last name, the username and the email of a user.

" + "3. If {@code search} is unspecified but any of {@code last}, {@code first}, {@code email} or {@code username} those criteria are matched against their respective fields on a user entity. Combined with a logical and.") public Integer getUsersCount( - @Parameter(description = "arbitrary search string for all the fields below. Default search behavior is prefix-based (e.g., foo or foo*). Use *foo* for infix search and \"foo\" for exact search.") @QueryParam("search") String search, - @Parameter(description = "last name filter") @QueryParam("lastName") String last, - @Parameter(description = "first name filter") @QueryParam("firstName") String first, - @Parameter(description = "email filter") @QueryParam("email") String email, - @QueryParam("emailVerified") Boolean emailVerified, - @Parameter(description = "username filter") @QueryParam("username") String username, + @Parameter(description = "A String contained in username, first or last name, or email. Default search behavior is prefix-based (e.g., foo or foo*). Use *foo* for infix search and \"foo\" for exact search.") @QueryParam("search") String search, + @Parameter(description = "A String contained in lastName, or the complete lastName, if param \"exact\" is true") @QueryParam("lastName") String last, + @Parameter(description = "A String contained in firstName, or the complete firstName, if param \"exact\" is true") @QueryParam("firstName") String first, + @Parameter(description = "A String contained in email, or the complete email, if param \"exact\" is true") @QueryParam("email") String email, + @Parameter(description = "A String contained in username, or the complete username, if param \"exact\" is true") @QueryParam("username") String username, + @Parameter(description = "whether the email has been verified") @QueryParam("emailVerified") Boolean emailVerified, + @Parameter(description = "The alias of an Identity Provider linked to the user") @QueryParam("idpAlias") String idpAlias, + @Parameter(description = "The userId at an Identity Provider linked to the user") @QueryParam("idpUserId") String idpUserId, @Parameter(description = "Boolean representing if user is enabled or not") @QueryParam("enabled") Boolean enabled, - @QueryParam("q") String searchQuery) { + @Parameter(description = "Boolean which defines whether the params \"last\", \"first\", \"email\" and \"username\" must match exactly") @QueryParam("exact") Boolean exact, + @Parameter(description = "A query to search for custom attributes, in the format 'key1:value2 key2:value2'") @QueryParam("q") String searchQuery) { UserPermissionEvaluator userPermissionEvaluator = auth.users(); userPermissionEvaluator.requireQuery(); Map searchAttributes = searchQuery == null ? Collections.emptyMap() : SearchQueryUtils.getFields(searchQuery); - if (search != null) { if (search.startsWith(SEARCH_ID_PARAMETER)) { UserModel userModel = session.users().getUserById(realm, search.substring(SEARCH_ID_PARAMETER.length()).trim()); return userModel != null && userPermissionEvaluator.canView(userModel) ? 1 : 0; - } else if (userPermissionEvaluator.canView()) { - return session.users().getUsersCount(realm, search.trim()); + } + + Map parameters = new HashMap<>(); + parameters.put(UserModel.SEARCH, search.trim()); + + if (enabled != null) { + parameters.put(UserModel.ENABLED, enabled.toString()); + } + if (emailVerified != null) { + parameters.put(UserModel.EMAIL_VERIFIED, emailVerified.toString()); + } + + if (userPermissionEvaluator.canView()) { + return session.users().getUsersCount(realm, parameters); } else { if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) { - return session.users().getUsersCount(realm, search.trim()); + return session.users().getUsersCount(realm, parameters); } else { - return session.users().getUsersCount(realm, search.trim(), auth.groups().getGroupIdsWithViewPermission()); + return session.users().getUsersCount(realm, parameters, auth.groups().getGroupIdsWithViewPermission()); } } } else if (last != null || first != null || email != null || username != null || emailVerified != null || enabled != null || !searchAttributes.isEmpty()) { @@ -442,9 +465,18 @@ public class UsersResource { if (emailVerified != null) { parameters.put(UserModel.EMAIL_VERIFIED, emailVerified.toString()); } + if (idpAlias != null) { + parameters.put(UserModel.IDP_ALIAS, idpAlias); + } + if (idpUserId != null) { + parameters.put(UserModel.IDP_USER_ID, idpUserId); + } if (enabled != null) { parameters.put(UserModel.ENABLED, enabled.toString()); } + if (exact != null) { + parameters.put(UserModel.EXACT, exact.toString()); + } parameters.putAll(searchAttributes); if (userPermissionEvaluator.canView()) { diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/UsersTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/UsersTest.java index da01c4e42ef..7914174623c 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/UsersTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/UsersTest.java @@ -19,12 +19,17 @@ package org.keycloak.tests.admin; import org.junit.jupiter.api.Test; import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserProvider; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.injection.LifeCycle; import org.keycloak.testframework.realm.ManagedRealm; import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; +import org.keycloak.testframework.remote.runonserver.RunOnServerClient; import java.util.List; @@ -37,23 +42,17 @@ import static org.hamcrest.Matchers.hasSize; @KeycloakIntegrationTest public class UsersTest { + private static final String realmName = "default"; + @InjectRealm(lifecycle = LifeCycle.METHOD) ManagedRealm realm; - private void createUser(String username, String password, String firstName, String lastName, String email) { - UserRepresentation user = UserConfigBuilder.create() - .username(username) - .password(password) - .name(firstName, lastName) - .email(email) - .enabled(true) - .build(); - realm.admin().users().create(user); - } + @InjectRunOnServer(permittedPackages = {"org.keycloak.tests", "org.keycloak.admin"}) + RunOnServerClient runOnServer; @Test public void searchUserWithWildcards() { - createUser("User", "password", "firstName", "lastName", "user@example.com"); + createUser("User", "firstName", "lastName", "user@example.com"); assertThat(realm.admin().users().search("Use%", null, null), hasSize(0)); assertThat(realm.admin().users().search("Use_", null, null), hasSize(0)); @@ -65,14 +64,14 @@ public class UsersTest { @Test public void searchUserDefaultSettings() throws Exception { - createUser("User", "password", "firstName", "lastName", "user@example.com"); + createUser("User", "firstName", "lastName", "user@example.com"); assertCaseInsensitiveSearch(); } @Test public void searchUserMatchUsersCount() { - createUser("john.doe", "password", "John", "Doe Smith", "john.doe@keycloak.org"); + createUser("john.doe", "John", "Doe Smith", "john.doe@keycloak.org"); String search = "jo do"; assertThat(realm.admin().users().count(search), is(1)); @@ -86,24 +85,22 @@ public class UsersTest { */ @Test public void findUsersByEmailVerifiedStatus() { - UserRepresentation user1 = UserConfigBuilder.create() + createUser(UserConfigBuilder.create() .username("user1") .password("password") .name("user1FirstName", "user1LastName") .email("user1@example.com") .emailVerified() .enabled(true) - .build(); - realm.admin().users().create(user1); + .build()); - UserRepresentation user2 = UserConfigBuilder.create() + createUser(UserConfigBuilder.create() .username("user2") .password("password") .name("user2FirstName", "user2LastName") .email("user2@example.com") .enabled(true) - .build(); - realm.admin().users().create(user2); + .build()); boolean emailVerified; emailVerified = true; @@ -111,10 +108,95 @@ public class UsersTest { assertThat(usersEmailVerified, is(not(empty()))); assertThat(usersEmailVerified.get(0).getUsername(), is("user1")); + createUser(UserConfigBuilder.create() + .username("testuser2") + .password("password") + .name("testuser2", "testuser2") + .email("testuser2@example.com") + .emailVerified() + .enabled(true) + .build()); + + usersEmailVerified = realm.admin().users().search("user", null, null, null, emailVerified, null, null, null); + assertThat(usersEmailVerified, is(not(empty()))); + assertThat(usersEmailVerified.size(), is(1)); + assertThat(usersEmailVerified.get(0).getUsername(), is("user1")); + assertThat(realm.admin().users().count("user", null, null, null, emailVerified, null, null, null), is(1)); + emailVerified = false; List usersEmailNotVerified = realm.admin().users().search(null, null, null, null, emailVerified, null, null, null, true); assertThat(usersEmailNotVerified, is(not(empty()))); assertThat(usersEmailNotVerified.get(0).getUsername(), is("user2")); + + createUser(UserConfigBuilder.create() + .username("testuser3") + .password("password") + .name("testuser3", "testuser3") + .email("testuser3@example.com") + .enabled(true) + .build()); + + usersEmailVerified = realm.admin().users().search("user", null, null, null, emailVerified, null, null, null); + assertThat(usersEmailVerified, is(not(empty()))); + assertThat(usersEmailVerified.size(), is(1)); + assertThat(usersEmailVerified.get(0).getUsername(), is("user2")); + assertThat(realm.admin().users().count("user", null, null, null, emailVerified, null, null, null), is(1)); + } + + @Test + public void testCountUsersByEnabledStatus() { + createUser(UserConfigBuilder.create() + .username("user1") + .password("password") + .name("user1FirstName", "user1LastName") + .email("user1@example.com") + .emailVerified() + .enabled(true) + .build()); + + createUser(UserConfigBuilder.create() + .username("user2") + .password("password") + .name("user2FirstName", "user2LastName") + .email("user2@example.com") + .enabled(false) + .build()); + + assertThat(realm.admin().users().count("user", null, null, null, null, null, true, null), is(1)); + assertThat(realm.admin().users().count("user", null, null, null, null, null, false, null), is(1)); + } + + @Test + public void testCountUsersByFederatedIdentity() { + createUser(UserConfigBuilder.create() + .username("user1") + .password("password") + .name("user1FirstName", "user1LastName") + .email("user1@example.com") + .emailVerified() + .enabled(true) + .build()); + createUser(UserConfigBuilder.create() + .username("user2") + .password("password") + .name("user2FirstName", "user2LastName") + .email("user2@example.com") + .enabled(false) + .build()); + + runOnServer.run((session -> { + RealmModel realm = session.realms().getRealmByName(realmName); + session.getContext().setRealm(realm); + UserProvider users = session.users(); + users.addFederatedIdentity(realm, users.getUserById(realm, users.getUserByUsername(realm, "user1").getId()), new FederatedIdentityModel("user1Broker", "user1BrokerId", "user1BrokerUsername")); + users.addFederatedIdentity(realm, users.getUserById(realm, users.getUserByUsername(realm, "user2").getId()), new FederatedIdentityModel("user2Broker", "user2BrokerId", "user2BrokerUsername")); + })); + + assertThat(realm.admin().users().count(null, "user", null, null, null, null, null, "user1Broker", null, null), is(1)); + assertThat(realm.admin().users().count(null, "user", null, null, null, null, null, "user1Broker", "user1BrokerId", null), is(1)); + assertThat(realm.admin().users().count(null, "user", null, null, null, null, null, "user1Broker", "invalidId", null), is(0)); + assertThat(realm.admin().users().count(null, "user", null, null, null, null, false, "user2Broker", null, null), is(1)); + assertThat(realm.admin().users().count(null, "user", null, null, null, null, false, "user2Broker", "user1BrokerId", null), is(0)); } /** @@ -122,34 +204,31 @@ public class UsersTest { */ @Test public void countUsersByEmailVerifiedStatus() { - UserRepresentation user1 = UserConfigBuilder.create() + createUser(UserConfigBuilder.create() .username("user1") .password("password") .name("user1FirstName", "user1LastName") .email("user1@example.com") .emailVerified() .enabled(true) - .build(); - realm.admin().users().create(user1); + .build()); - UserRepresentation user2 = UserConfigBuilder.create() + createUser(UserConfigBuilder.create() .username("user2") .password("password") .name("user2FirstName", "user2LastName") .email("user2@example.com") .enabled(true) - .build(); - realm.admin().users().create(user2); + .build()); - UserRepresentation user3 = UserConfigBuilder.create() + createUser(UserConfigBuilder.create() .username("user3") .password("password") .name("user3FirstName", "user3LastName") .email("user3@example.com") .emailVerified() .enabled(true) - .build(); - realm.admin().users().create(user3); + .build()); boolean emailVerified; emailVerified = true; @@ -163,41 +242,38 @@ public class UsersTest { @Test public void countUsersWithViewPermission() { - createUser("user1", "password", "user1FirstName", "user1LastName", "user1@example.com"); - createUser("user2", "password", "user2FirstName", "user2LastName", "user2@example.com"); + createUser("user1", "user1FirstName", "user1LastName", "user1@example.com"); + createUser("user2", "user2FirstName", "user2LastName", "user2@example.com"); assertThat(realm.admin().users().count(), is(2)); } @Test public void countUsersBySearchWithViewPermission() { - UserRepresentation user1 = UserConfigBuilder.create() + createUser(UserConfigBuilder.create() .username("user1") .password("password") .name("user1FirstName", "user1LastName") .email("user1@example.com") .emailVerified() .enabled(true) - .build(); - realm.admin().users().create(user1); + .build()); - UserRepresentation user2 = UserConfigBuilder.create() + createUser(UserConfigBuilder.create() .username("user2") .password("password") .name("user2FirstName", "user2LastName") .email("user2@example.com") .enabled(true) - .build(); - realm.admin().users().create(user2); + .build()); - UserRepresentation user3 = UserConfigBuilder.create() + createUser(UserConfigBuilder.create() .username("user3") .password("password") .name("user3FirstName", "user3LastName") .email("user3@example.com") .emailVerified() .enabled(true) - .build(); - realm.admin().users().create(user3); + .build()); // Prefix search count assertSearchMatchesCount(realm.admin(), "user", 3); @@ -231,8 +307,8 @@ public class UsersTest { @Test public void countUsersByFiltersWithViewPermission() { - createUser("user1", "password", "user1FirstName", "user1LastName", "user1@example.com"); - createUser("user2", "password", "user2FirstName", "user2LastName", "user2@example.com"); + createUser("user1", "user1FirstName", "user1LastName", "user1@example.com"); + createUser("user2", "user2FirstName", "user2LastName", "user2@example.com"); //search username assertThat(realm.admin().users().count(null, null, null, "user"), is(2)); assertThat(realm.admin().users().count(null, null, null, "user1"), is(1)); @@ -284,4 +360,18 @@ public class UsersTest { assertThat(realm.admin().users().search("USER", true), hasSize(1)); assertThat(realm.admin().users().search("Use", true), hasSize(0)); } + + private void createUser(UserRepresentation user) { + realm.admin().users().create(user).close(); + } + + private void createUser(String username, String firstName, String lastName, String email) { + createUser(UserConfigBuilder.create() + .username(username) + .password("password") + .name(firstName, lastName) + .email(email) + .enabled(true) + .build()); + } }