[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:
parent
ce0070508f
commit
5b0986e490
6 changed files with 133 additions and 30 deletions
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue