From 6cc38815a2463b6a5da4d3118743dcdbb4f038c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Akdo=C4=9Fan?= Date: Mon, 15 Jun 2026 11:10:08 +0300 Subject: [PATCH] Combine search query param with other filters in users endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'search' query param on GET /admin/realms/{realm}/users overrode the other attribute filters (email, username, idpAlias, idpUserId, firstName, lastName, exact, q) instead of stacking with them, so a request like ?email=foo&username=baz&search=bam ignored email and username and only applied the full-text search. The branching is reworked so that, unless the search uses a special prefix (id:/username:/email:) which keeps its dedicated lookup path, all provided filters are collected into a single attribute map and applied together. Closes #49995 Signed-off-by: Hüseyin Akdoğan --- .../resources/admin/UsersResource.java | 103 ++++++++---------- .../tests/admin/user/UserSearchTest.java | 40 +++++++ 2 files changed, 88 insertions(+), 55 deletions(-) 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 4ba686ecd9fe..fbddeff292cf 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 @@ -303,73 +303,66 @@ public Stream getUsers( ? Collections.emptyMap() : SearchQueryUtils.getFields(searchQuery); - Stream userModels = Stream.empty(); if (search != null) { SearchQueryUtils.UserSearchPrefix prefix = SearchQueryUtils.UserSearchPrefix.matching(search); if (prefix != null) { - userModels = Arrays.stream(prefix.splitTerms(search)) + Stream userModels = Arrays.stream(prefix.splitTerms(search)) .map(term -> prefix.lookup(session.users(), realm, term)) .filter(Objects::nonNull); if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) { userModels = userModels.filter(userPermissionEvaluator::canView); } - } else { - Map attributes = new HashMap<>(); - attributes.put(UserModel.SEARCH, search.trim()); - if (enabled != null) { - attributes.put(UserModel.ENABLED, enabled.toString()); - } - if (emailVerified != null) { - attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString()); - } - addCreatedTimestampConditions(attributes, createdAfter, createdBefore); - - return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, - maxResults, false); + return toRepresentation(realm, userPermissionEvaluator, briefRepresentation, userModels); } - } else if (last != null || first != null || email != null || username != null || emailVerified != null + } + + if (search != null || last != null || first != null || email != null || username != null || emailVerified != null || idpAlias != null || idpUserId != null || enabled != null || exact != null || !searchAttributes.isEmpty() || createdAfter != null || createdBefore != null) { - Map attributes = new HashMap<>(); - if (last != null) { - attributes.put(UserModel.LAST_NAME, last); - } - if (first != null) { - attributes.put(UserModel.FIRST_NAME, first); - } - if (email != null) { - attributes.put(UserModel.EMAIL, email); - } - if (username != null) { - attributes.put(UserModel.USERNAME, username); - } - if (emailVerified != null) { - attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString()); - } - if (idpAlias != null) { - attributes.put(UserModel.IDP_ALIAS, idpAlias); - } - if (idpUserId != null) { - attributes.put(UserModel.IDP_USER_ID, idpUserId); - } - if (enabled != null) { - attributes.put(UserModel.ENABLED, enabled.toString()); - } - if (exact != null) { - attributes.put(UserModel.EXACT, exact.toString()); - } - addCreatedTimestampConditions(attributes, createdAfter, createdBefore); - - attributes.putAll(searchAttributes); - - return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, - maxResults, true); - } else { - return searchForUser(new HashMap<>(), realm, userPermissionEvaluator, briefRepresentation, - firstResult, maxResults, false); - } + Map attributes = new HashMap<>(); + if (search != null) { + attributes.put(UserModel.SEARCH, search.trim()); + } + if (last != null) { + attributes.put(UserModel.LAST_NAME, last); + } + if (first != null) { + attributes.put(UserModel.FIRST_NAME, first); + } + if (email != null) { + attributes.put(UserModel.EMAIL, email); + } + if (username != null) { + attributes.put(UserModel.USERNAME, username); + } + if (emailVerified != null) { + attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString()); + } + if (idpAlias != null) { + attributes.put(UserModel.IDP_ALIAS, idpAlias); + } + if (idpUserId != null) { + attributes.put(UserModel.IDP_USER_ID, idpUserId); + } + if (enabled != null) { + attributes.put(UserModel.ENABLED, enabled.toString()); + } + if (exact != null) { + attributes.put(UserModel.EXACT, exact.toString()); + } + addCreatedTimestampConditions(attributes, createdAfter, createdBefore); - return toRepresentation(realm, userPermissionEvaluator, briefRepresentation, userModels); + attributes.putAll(searchAttributes); + + // Service accounts are excluded from full-text search, but included when filtering by explicit attributes. + boolean includeServiceAccounts = search == null; + + return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult, + maxResults, includeServiceAccounts); + } else { + return searchForUser(new HashMap<>(), realm, userPermissionEvaluator, briefRepresentation, + firstResult, maxResults, false); + } } /** 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 1986f8333db5..285b2c73ebee 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 @@ -343,6 +343,46 @@ public void searchWithFilters() { assertEquals(0, searchInvalidSizeAndDisabled.size()); } + @Test + @DatabaseTest + public void searchStacksWithOtherFilters() { + // The 'search' query param must combine with the other filters instead of overriding them, see + // https://github.com/keycloak/keycloak/issues/49995 + UserRepresentation alice = new UserRepresentation(); + alice.setUsername("stackalice"); + alice.setEmail("alice@stackbug.test"); + alice.setFirstName("Alice"); + alice.setLastName("Stackbugcommon"); + alice.setEmailVerified(true); + alice.setRequiredActions(Collections.emptyList()); + alice.setEnabled(true); + createUser(alice); + + UserRepresentation bob = new UserRepresentation(); + bob.setUsername("stackbob"); + bob.setEmail("bob@stackbug.test"); + bob.setFirstName("Bob"); + bob.setLastName("Stackbugcommon"); + bob.setEmailVerified(true); + bob.setRequiredActions(Collections.emptyList()); + bob.setEnabled(true); + createUser(bob); + + // sanity check: the search term on its own matches both users via their last name + List bySearchOnly = managedRealm.admin().users().search("Stackbugcommon", null, null, null, true, null, null, null); + assertThat(bySearchOnly, hasSize(2)); + + // search combined with username must narrow down to a single user, not ignore the username filter + List bySearchAndUsername = managedRealm.admin().users().search("Stackbugcommon", null, null, null, true, "stackalice", null, null); + assertThat(bySearchAndUsername, hasSize(1)); + assertEquals("stackalice", bySearchAndUsername.get(0).getUsername()); + + // search combined with email must narrow down as well + List bySearchAndEmail = managedRealm.admin().users().search("Stackbugcommon", null, null, "bob@stackbug.test", true, null, null, null); + assertThat(bySearchAndEmail, hasSize(1)); + assertEquals("stackbob", bySearchAndEmail.get(0).getUsername()); + } + @Test public void searchWithFilterAndEnabledAttribute() { createUser();