[KEYCLOAK-18891] Add support for searching users by custom user attributes

Users can now be searched by custom attributes using 'q' in the query parameters. The implementation is roughly the same as search clients by custom attributes.
This commit is contained in:
Bart Monhemius 2021-07-29 10:20:07 +02:00 committed by Hynek Mlnařík
parent ce0070508f
commit 5b0986e490
6 changed files with 133 additions and 30 deletions

View file

@ -109,6 +109,20 @@ public interface UsersResource {
@Produces(MediaType.APPLICATION_JSON)
List<UserRepresentation> search(@QueryParam("username") String username);
@GET
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
List<UserRepresentation> searchByAttributes(@QueryParam("q") String searchQuery);
@GET
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
List<UserRepresentation> searchByAttributes(@QueryParam("first") Integer firstResult,
@QueryParam("max") Integer maxResults,
@QueryParam("enabled") Boolean enabled,
@QueryParam("briefRepresentation") Boolean briefRepresentation,
@QueryParam("q") String searchQuery);
@GET
@Produces(MediaType.APPLICATION_JSON)
List<UserRepresentation> search(@QueryParam("username") String username, @QueryParam("exact") Boolean exact);

View file

@ -39,6 +39,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.models.jpa.entities.CredentialEntity;
import org.keycloak.models.jpa.entities.FederatedIdentityEntity;
import org.keycloak.models.jpa.entities.UserAttributeEntity;
import org.keycloak.models.jpa.entities.UserConsentClientScopeEntity;
import org.keycloak.models.jpa.entities.UserConsentEntity;
import org.keycloak.models.jpa.entities.UserEntity;
@ -49,15 +50,16 @@ import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.client.ClientStorageProvider;
import javax.persistence.EntityManager;
import javax.persistence.LockModeType;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Expression;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.persistence.criteria.Subquery;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
@ -68,8 +70,6 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import javax.persistence.LockModeType;
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing;
@ -768,7 +768,8 @@ public class JpaUserProvider implements UserProvider.Streams, UserCredentialStor
CriteriaQuery<UserEntity> queryBuilder = builder.createQuery(UserEntity.class);
Root<UserEntity> root = queryBuilder.from(UserEntity.class);
List<Predicate> predicates = new ArrayList();
List<Predicate> predicates = new ArrayList<>();
List<Predicate> attributePredicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realmId"), realm.getId()));
@ -788,7 +789,7 @@ public class JpaUserProvider implements UserProvider.Streams, UserCredentialStor
switch (key) {
case UserModel.SEARCH:
List<Predicate> orPredicates = new ArrayList();
List<Predicate> orPredicates = new ArrayList<>();
orPredicates
.add(builder.like(builder.lower(root.get(USERNAME)), "%" + value.toLowerCase() + "%"));
@ -799,7 +800,7 @@ public class JpaUserProvider implements UserProvider.Streams, UserCredentialStor
builder.coalesce(root.get(LAST_NAME), builder.literal("")))),
"%" + value.toLowerCase() + "%"));
predicates.add(builder.or(orPredicates.toArray(new Predicate[orPredicates.size()])));
predicates.add(builder.or(orPredicates.toArray(new Predicate[0])));
break;
@ -831,9 +832,24 @@ public class JpaUserProvider implements UserProvider.Streams, UserCredentialStor
}
predicates.add(builder.equal(federatedIdentitiesJoin.get("userId"), value));
break;
case UserModel.EXACT:
break;
// All unknown attributes will be assumed as custom attributes
default:
Join<UserEntity, UserAttributeEntity> attributesJoin = root.join("attributes", JoinType.LEFT);
attributePredicates.add(builder.and(
builder.equal(builder.lower(attributesJoin.get("name")), key.toLowerCase()),
builder.equal(builder.lower(attributesJoin.get("value")), value.toLowerCase())));
break;
}
}
if (!attributePredicates.isEmpty()) {
predicates.add(builder.and(attributePredicates.toArray(new Predicate[0])));
}
Set<String> userGroups = (Set<String>) session.getAttribute(UserModel.GROUPS);
if (userGroups != null) {

View file

@ -637,6 +637,11 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor
mcb = mcb.compare(SearchableFields.IDP_AND_USER, Operator.EQ, attributes.get(UserModel.IDP_ALIAS), value);
break;
}
case UserModel.EXACT:
break;
default:
mcb = mcb.compare(SearchableFields.ATTRIBUTE, Operator.EQ, key, value);
break;
}
}

View file

@ -389,6 +389,8 @@ public interface UserQueryProvider {
* the given userId (case sensitive string)</li>
* </ul>
*
* Any other parameters will be treated as custom user attributes.
*
* This method is used by the REST API when querying users.
*
* @param realm a reference to the realm.

View file

@ -16,8 +16,6 @@
*/
package org.keycloak.services.resources.admin;
import static org.keycloak.userprofile.UserProfileContext.USER_API;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
@ -43,6 +41,7 @@ import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluato
import org.keycloak.services.resources.admin.permissions.UserPermissionEvaluator;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.utils.SearchQueryUtils;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
@ -56,11 +55,14 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import static org.keycloak.userprofile.UserProfileContext.USER_API;
/**
* Base resource for managing users
*
@ -215,7 +217,7 @@ public class UsersResource {
/**
* Get users
*
* Returns a stream of users, filtered according to query parameters
* Returns a stream of users, filtered according to query parameters.
*
* @param search A String contained in username, first or last name, or email
* @param last A String contained in lastName, or the complete lastName, if param "exact" is true
@ -230,6 +232,7 @@ public class UsersResource {
* @param enabled Boolean representing if user is enabled or not
* @param briefRepresentation Boolean which defines whether brief representations are returned (default: false)
* @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 a non-null {@code Stream} of users
*/
@GET
@ -247,7 +250,8 @@ public class UsersResource {
@QueryParam("max") Integer maxResults,
@QueryParam("enabled") Boolean enabled,
@QueryParam("briefRepresentation") Boolean briefRepresentation,
@QueryParam("exact") Boolean exact) {
@QueryParam("exact") Boolean exact,
@QueryParam("q") String searchQuery) {
UserPermissionEvaluator userPermissionEvaluator = auth.users();
userPermissionEvaluator.requireQuery();
@ -255,6 +259,10 @@ public class UsersResource {
firstResult = firstResult != null ? firstResult : -1;
maxResults = maxResults != null ? maxResults : Constants.DEFAULT_MAX_RESULTS;
Map<String, String> searchAttributes = searchQuery == null
? Collections.emptyMap()
: SearchQueryUtils.getFields(searchQuery);
Stream<UserModel> userModels = Stream.empty();
if (search != null) {
if (search.startsWith(SEARCH_ID_PARAMETER)) {
@ -273,7 +281,7 @@ public class UsersResource {
maxResults, false);
}
} else if (last != null || first != null || email != null || username != null || emailVerified != null
|| idpAlias != null || idpUserId != null || enabled != null || exact != null) {
|| idpAlias != null || idpUserId != null || enabled != null || exact != null || !searchAttributes.isEmpty()) {
Map<String, String> attributes = new HashMap<>();
if (last != null) {
attributes.put(UserModel.LAST_NAME, last);
@ -302,6 +310,9 @@ public class UsersResource {
if (exact != null) {
attributes.put(UserModel.EXACT, exact.toString());
}
attributes.putAll(searchAttributes);
return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult,
maxResults, true);
} else {

View file

@ -61,6 +61,8 @@ import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.federation.DummyUserFederationProviderFactory;
import org.keycloak.testsuite.page.LoginPasswordUpdatePage;
@ -101,6 +103,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
@ -116,9 +119,6 @@ import static org.junit.Assert.fail;
import static org.keycloak.testsuite.Assert.assertNames;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@ -595,6 +595,12 @@ public class UserTest extends AbstractAdminTest {
user.setFirstName("First" + i);
user.setLastName("Last" + i);
HashMap<String, List<String>> attributes = new HashMap<>();
attributes.put("test", Collections.singletonList("test" + i));
attributes.put("test" + i, Collections.singletonList("test" + i));
attributes.put("attr", Collections.singletonList("common"));
user.setAttributes(attributes);
ids.add(createUser(user));
}
@ -623,6 +629,55 @@ public class UserTest extends AbstractAdminTest {
assertEquals(9, users.size());
}
private String mapToSearchQuery(Map<String, String> search) {
return search.entrySet()
.stream()
.map(e -> String.format("%s:%s", e.getKey(), e.getValue()))
.collect(Collectors.joining(" "));
}
@Test
public void searchByAttribute() {
createUsers();
Map<String, String> attributes = new HashMap<>();
attributes.put("test", "test1");
List<UserRepresentation> users = realm.users().searchByAttributes(mapToSearchQuery(attributes));
assertEquals(1, users.size());
attributes.clear();
attributes.put("attr", "common");
users = realm.users().searchByAttributes(mapToSearchQuery(attributes));
assertEquals(9, users.size());
}
@Test
public void searchByMultipleAttributes() {
createUsers();
Map<String, String> attributes = new HashMap<>();
attributes.put("test", "test1");
attributes.put("attr", "common");
attributes.put("test1", "test1");
List<UserRepresentation> users = realm.users().searchByAttributes(mapToSearchQuery(attributes));
assertEquals(1, users.size());
}
@Test
public void searchByAttributesWithPagination() {
createUsers();
Map<String, String> attributes = new HashMap<>();
attributes.put("attr", "common");
for (int i = 1; i < 10; i++) {
List<UserRepresentation> users = realm.users().searchByAttributes(i - 1, 1, null, false, mapToSearchQuery(attributes));
assertEquals(1, users.size());
assertTrue(users.get(0).getAttributes().keySet().stream().anyMatch(attributes::containsKey));
}
}
@Test
public void searchByUsernameExactMatch() {
createUsers();