Generalize user search prefix lookups via UserSearchPrefix enum (#26602)

- Introduce UserSearchPrefix enum in SearchQueryUtils pairing each prefix
(id:, username:, email:) with its UserProvider lookup,
plus a splitTerms helper backed by a precompiled "\\s+" pattern
- Collapse duplicate prefix branches in UsersResource#getUsers,
UsersResource#getUsersCount and BruteForceUsersResource#searchUser
- BruteForceUsersResource: support multi-term lookups (e.g. "username:foo bar"),
aligning with UsersResource
- Tests: add searchByUsernameSearch / searchByEmailSearch covering
single-term, multi-term and whitespace-tolerant variants
- Docs: add "Search by fields" section to proc-searching-user.adoc

Fixes #26602

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
This commit is contained in:
Thomas Darimont
2026-04-29 11:46:40 +02:00
committed by Pedro Igor
parent 2e7a63bc68
commit 8b357d610a
5 changed files with 169 additions and 14 deletions
@@ -22,6 +22,16 @@ The criteria used to match users depends on the syntax used on the search box:
.. `\*somevalue*` -> performs infix search, akin to a `LIKE '%somevalue%'` DB query;
.. `somevalue*` or `somevalue` -> performs prefix search, akin to a `LIKE 'somevalue%'` DB query.
== Search by fields
{project_name} supports direct user lookups for selected fields by prefixing the search term in the search box:
* `id:myUUID` -> performs an exact user lookup by id for `"myUUID"`;
* `username:myuser` -> performs an exact user lookup by username for `"myuser"`;
* `email:myuser@domain.org` -> performs an exact user lookup by email for `"myuser@domain.org"`.
Multiple values can be supplied separated by whitespace (for example `id:uuid1 uuid2`) to look up several users in a single request.
== Attribute search
.Procedure
@@ -1,8 +1,10 @@
package org.keycloak.admin.ui.rest;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
@@ -39,7 +41,7 @@ import org.jboss.logging.Logger;
public class BruteForceUsersResource {
private static final Logger logger = Logger.getLogger(BruteForceUsersResource.class);
private static final String SEARCH_ID_PARAMETER = "id:";
private final KeycloakSession session;
private final RealmModel realm;
private final AdminPermissionEvaluator auth;
@@ -90,12 +92,11 @@ public class BruteForceUsersResource {
Stream<UserModel> userModels = Stream.empty();
if (search != null) {
if (search.startsWith(SEARCH_ID_PARAMETER)) {
UserModel userModel =
session.users().getUserById(realm, search.substring(SEARCH_ID_PARAMETER.length()).trim());
if (userModel != null) {
userModels = Stream.of(userModel);
}
SearchQueryUtils.UserSearchPrefix prefix = SearchQueryUtils.UserSearchPrefix.matching(search);
if (prefix != null) {
userModels = Arrays.stream(prefix.splitTerms(search))
.map(term -> prefix.lookup(session.users(), realm, term))
.filter(Objects::nonNull);
} else {
Map<String, String> attributes = new HashMap<>();
attributes.put(UserModel.SEARCH, search.trim());
@@ -100,7 +100,6 @@ import static org.keycloak.userprofile.UserProfileContext.USER_API;
public class UsersResource {
private static final Logger logger = Logger.getLogger(UsersResource.class);
private static final String SEARCH_ID_PARAMETER = "id:";
protected final RealmModel realm;
@@ -306,9 +305,11 @@ public class UsersResource {
Stream<UserModel> userModels = Stream.empty();
if (search != null) {
if (search.startsWith(SEARCH_ID_PARAMETER)) {
String[] userIds = search.substring(SEARCH_ID_PARAMETER.length()).trim().split("\\s+");
userModels = Arrays.stream(userIds).map(id -> session.users().getUserById(realm, id)).filter(Objects::nonNull);
SearchQueryUtils.UserSearchPrefix prefix = SearchQueryUtils.UserSearchPrefix.matching(search);
if (prefix != null) {
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);
}
@@ -435,9 +436,13 @@ public class UsersResource {
? 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;
SearchQueryUtils.UserSearchPrefix prefix = SearchQueryUtils.UserSearchPrefix.matching(search);
if (prefix != null) {
return (int) Arrays.stream(prefix.splitTerms(search))
.map(term -> prefix.lookup(session.users(), realm, term))
.filter(Objects::nonNull)
.filter(userPermissionEvaluator::canView)
.count();
}
Map<String, String> parameters = new HashMap<>();
@@ -19,12 +19,65 @@ package org.keycloak.utils;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class SearchQueryUtils {
public static final String SEARCH_ID_PREFIX = "id:";
public static final String SEARCH_USERNAME_PREFIX = "username:";
public static final String SEARCH_EMAIL_PREFIX = "email:";
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
public enum UserSearchPrefix {
ID(SEARCH_ID_PREFIX, UserProvider::getUserById),
USERNAME(SEARCH_USERNAME_PREFIX, UserProvider::getUserByUsername),
EMAIL(SEARCH_EMAIL_PREFIX, UserProvider::getUserByEmail);
private final String prefix;
private final UserLookup lookup;
UserSearchPrefix(String prefix, UserLookup lookup) {
this.prefix = prefix;
this.lookup = lookup;
}
public String getPrefix() {
return prefix;
}
public UserModel lookup(UserProvider users, RealmModel realm, String term) {
return lookup.apply(users, realm, term);
}
public String[] splitTerms(String search) {
return WHITESPACE.split(search.substring(prefix.length()).trim());
}
public static UserSearchPrefix matching(String search) {
for (UserSearchPrefix p : values()) {
if (search.startsWith(p.prefix)) {
return p;
}
}
return null;
}
@FunctionalInterface
private interface UserLookup {
UserModel apply(UserProvider users, RealmModel realm, String term);
}
}
public static Map<String, String> getFields(final String query) {
Map<String, String> ret = new HashMap<>();
char[] chars = query.trim().toCharArray();
@@ -520,6 +520,92 @@ public class UserSearchTest extends AbstractUserTest {
assertThat(multipleUsers2.get(1).getId(), is(expectedUserId2));
}
@Test
@DatabaseTest
public void searchByUsernameSearch() {
List<String> userIds = createUsers();
String expectedUserId = userIds.get(0);
UserRepresentation expectedUserRep = managedRealm.admin().users().get(expectedUserId).toRepresentation();
String expectedUsername = expectedUserRep.getUsername();
List<UserRepresentation> users = managedRealm.admin().users().search("username:" + expectedUsername, null, null);
assertEquals(1, users.size());
assertEquals(expectedUserId, users.get(0).getId());
assertEquals(expectedUsername, users.get(0).getUsername());
assertThat(managedRealm.admin().users().count("username:" + expectedUsername), is(1));
// ensure spaces are ignored
users = managedRealm.admin().users().search("username: " + expectedUsername + " ", null, null);
assertEquals(1, users.size());
assertEquals(expectedUserId, users.get(0).getId());
assertEquals(expectedUsername, users.get(0).getUsername());
assertThat(managedRealm.admin().users().count("username: " + expectedUsername + " "), is(1));
// Should allow searching for multiple users
String expectedUserId2 = userIds.get(1);
String expectedUsername2 = managedRealm.admin().users().get(expectedUserId2).toRepresentation().getUsername();
List<UserRepresentation> multipleUsers = managedRealm.admin().users().search(String.format("username:%s %s", expectedUsername, expectedUsername2), 0, 10);
assertThat(multipleUsers, hasSize(2));
assertThat(multipleUsers.get(0).getId(), is(expectedUserId));
assertThat(multipleUsers.get(1).getId(), is(expectedUserId2));
assertThat(managedRealm.admin().users().count(String.format("username:%s %s", expectedUsername, expectedUsername2)), is(2));
// Should take arbitrary amount of spaces in between usernames
List<UserRepresentation> multipleUsers2 = managedRealm.admin().users().search(String.format("username: %s %s ", expectedUsername, expectedUsername2), 0, 10);
assertThat(multipleUsers2, hasSize(2));
assertThat(multipleUsers2.get(0).getId(), is(expectedUserId));
assertThat(multipleUsers2.get(1).getId(), is(expectedUserId2));
assertThat(managedRealm.admin().users().count(String.format("username: %s %s ", expectedUsername, expectedUsername2)), is(2));
// Unknown username yields a count of zero
assertThat(managedRealm.admin().users().count("username:does-not-exist"), is(0));
}
@Test
@DatabaseTest
public void searchByEmailSearch() {
List<String> userIds = createUsers();
String expectedUserId = userIds.get(0);
UserRepresentation expectedUserRep = managedRealm.admin().users().get(expectedUserId).toRepresentation();
String expectedEmail = expectedUserRep.getEmail();
List<UserRepresentation> users = managedRealm.admin().users().search("email:" + expectedEmail, null, null);
assertEquals(1, users.size());
assertEquals(expectedUserId, users.get(0).getId());
assertEquals(expectedEmail, users.get(0).getEmail());
assertThat(managedRealm.admin().users().count("email:" + expectedEmail), is(1));
// ensure spaces are ignored
users = managedRealm.admin().users().search("email: " + expectedEmail + " ", null, null);
assertEquals(1, users.size());
assertEquals(expectedUserId, users.get(0).getId());
assertEquals(expectedEmail, users.get(0).getEmail());
assertThat(managedRealm.admin().users().count("email: " + expectedEmail + " "), is(1));
// Should allow searching for multiple users
String expectedUserId2 = userIds.get(1);
String expectedEmail2 = managedRealm.admin().users().get(expectedUserId2).toRepresentation().getEmail();
List<UserRepresentation> multipleUsers = managedRealm.admin().users().search(String.format("email:%s %s", expectedEmail, expectedEmail2), 0, 10);
assertThat(multipleUsers, hasSize(2));
assertThat(multipleUsers.get(0).getId(), is(expectedUserId));
assertThat(multipleUsers.get(1).getId(), is(expectedUserId2));
assertThat(managedRealm.admin().users().count(String.format("email:%s %s", expectedEmail, expectedEmail2)), is(2));
// Should take arbitrary amount of spaces in between emails
List<UserRepresentation> multipleUsers2 = managedRealm.admin().users().search(String.format("email: %s %s ", expectedEmail, expectedEmail2), 0, 10);
assertThat(multipleUsers2, hasSize(2));
assertThat(multipleUsers2.get(0).getId(), is(expectedUserId));
assertThat(multipleUsers2.get(1).getId(), is(expectedUserId2));
assertThat(managedRealm.admin().users().count(String.format("email: %s %s ", expectedEmail, expectedEmail2)), is(2));
// Unknown email yields a count of zero
assertThat(managedRealm.admin().users().count("email:does-not-exist@nowhere.test"), is(0));
}
@Test
@DatabaseTest
public void infixSearch() {