enhance group search to allow searching for groups via attribute keycloak/keycloak#12964

Co-authored-by: Abhijeet Gandhewar <agandhew@redhat.com>
Co-authored-by: Michal Hajas <mhajas@redhat.com>
This commit is contained in:
Alice Wood 2022-09-14 15:55:09 -05:00 committed by Michal Hajas
parent 0a832fc744
commit 55a660f50b
29 changed files with 587 additions and 39 deletions

View file

@ -124,4 +124,7 @@ public interface GroupsResource {
@Path("{id}")
GroupResource group(@PathParam("id") String id);
@GET
@Produces(MediaType.APPLICATION_JSON)
List<GroupRepresentation> query(@QueryParam("q") String searchQuery);
}

View file

@ -1028,6 +1028,11 @@ public class RealmCacheSession implements CacheRealmProvider {
return getGroupDelegate().searchForGroupByNameStream(realm, search, first, max);
}
@Override
public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
return getGroupDelegate().searchGroupsByAttributes(realm, attributes, firstResult, maxResults);
}
@Override
public boolean removeGroup(RealmModel realm, GroupModel group) {
invalidateGroup(group.getId(), realm.getId(), true);

View file

@ -70,7 +70,7 @@ public class JpaClientProviderFactory implements ClientProviderFactory {
@Override
public ClientProvider create(KeycloakSession session) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
return new JpaRealmProvider(session, em, clientSearchableAttributes);
return new JpaRealmProvider(session, em, clientSearchableAttributes, null);
}
@Override

View file

@ -46,7 +46,7 @@ public class JpaClientScopeProviderFactory implements ClientScopeProviderFactory
@Override
public ClientScopeProvider create(KeycloakSession session) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
return new JpaRealmProvider(session, em, null);
return new JpaRealmProvider(session, em, null, null);
}
@Override

View file

@ -45,7 +45,7 @@ public class JpaDeploymentStateProviderFactory implements DeploymentStateProvide
@Override
public DeploymentStateProvider create(KeycloakSession session) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
return new JpaRealmProvider(session, em, null);
return new JpaRealmProvider(session, em, null, null);
}
@Override

View file

@ -25,13 +25,31 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import javax.persistence.EntityManager;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import static org.keycloak.models.jpa.JpaRealmProviderFactory.PROVIDER_ID;
import static org.keycloak.models.jpa.JpaRealmProviderFactory.PROVIDER_PRIORITY;
public class JpaGroupProviderFactory implements GroupProviderFactory {
private Set<String> groupSearchableAttributes = null;
@Override
public void init(Config.Scope config) {
String[] searchableAttrsArr = config.getArray("searchableAttributes");
if (searchableAttrsArr == null) {
String s = System.getProperty("keycloak.group.searchableAttributes");
searchableAttrsArr = s == null ? null : s.split("\\s*,\\s*");
}
if (searchableAttrsArr != null) {
groupSearchableAttributes = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(searchableAttrsArr)));
}
else {
groupSearchableAttributes = Collections.emptySet();
}
}
@Override
@ -47,7 +65,7 @@ public class JpaGroupProviderFactory implements GroupProviderFactory {
@Override
public GroupProvider create(KeycloakSession session) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
return new JpaRealmProvider(session, em, null);
return new JpaRealmProvider(session, em, null, groupSearchableAttributes);
}
@Override

View file

@ -65,6 +65,7 @@ import org.keycloak.models.jpa.entities.ClientAttributeEntity;
import org.keycloak.models.jpa.entities.ClientEntity;
import org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity;
import org.keycloak.models.jpa.entities.ClientScopeEntity;
import org.keycloak.models.jpa.entities.GroupAttributeEntity;
import org.keycloak.models.jpa.entities.GroupEntity;
import org.keycloak.models.jpa.entities.RealmEntity;
import org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity;
@ -81,11 +82,13 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
private final KeycloakSession session;
protected EntityManager em;
private Set<String> clientSearchableAttributes;
private Set<String> groupSearchableAttributes;
public JpaRealmProvider(KeycloakSession session, EntityManager em, Set<String> clientSearchableAttributes) {
public JpaRealmProvider(KeycloakSession session, EntityManager em, Set<String> clientSearchableAttributes, Set<String> groupSearchableAttributes) {
this.session = session;
this.em = em;
this.clientSearchableAttributes = clientSearchableAttributes;
this.groupSearchableAttributes = groupSearchableAttributes;
}
@Override
@ -949,6 +952,43 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
}).sorted(GroupModel.COMPARE_BY_NAME).distinct());
}
@Override
public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
Map<String, String> filteredAttributes = groupSearchableAttributes == null || groupSearchableAttributes.isEmpty()
? attributes
: attributes.entrySet().stream().filter(m -> groupSearchableAttributes.contains(m.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<GroupEntity> queryBuilder = builder.createQuery(GroupEntity.class);
Root<GroupEntity> root = queryBuilder.from(GroupEntity.class);
List<Predicate> predicates = new ArrayList<>();
predicates.add(builder.equal(root.get("realm"), realm.getId()));
for (Map.Entry<String, String> entry : filteredAttributes.entrySet()) {
String key = entry.getKey();
if (key == null || key.isEmpty()) {
continue;
}
String value = entry.getValue();
Join<GroupEntity, GroupAttributeEntity> attributeJoin = root.join("attributes");
Predicate attrNamePredicate = builder.equal(attributeJoin.get("name"), key);
Predicate attrValuePredicate = builder.equal(attributeJoin.get("value"), value);
predicates.add(builder.and(attrNamePredicate, attrValuePredicate));
}
Predicate finalPredicate = builder.and(predicates.toArray(new Predicate[0]));
queryBuilder.where(finalPredicate).orderBy(builder.asc(root.get("name")));
TypedQuery<GroupEntity> query = em.createQuery(queryBuilder);
return closing(paginateQuery(query, firstResult, maxResults).getResultStream())
.map(g -> session.groups().getGroupById(realm, g.getId()));
}
@Override
public void removeExpiredClientInitialAccess() {
int currentTime = Time.currentTime();

View file

@ -62,7 +62,7 @@ public class JpaRealmProviderFactory implements RealmProviderFactory, ProviderEv
@Override
public JpaRealmProvider create(KeycloakSession session) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
return new JpaRealmProvider(session, em, null);
return new JpaRealmProvider(session, em, null, null);
}
@Override

View file

@ -46,7 +46,7 @@ public class JpaRoleProviderFactory implements RoleProviderFactory {
@Override
public RoleProvider create(KeycloakSession session) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
return new JpaRealmProvider(session, em, null);
return new JpaRealmProvider(session, em, null, null);
}
@Override

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
~ * Copyright 2022 Red Hat, Inc. and/or its affiliates
~ * and other contributors as indicated by the @author tags.
~ *
~ * Licensed under the Apache License, Version 2.0 (the "License");
~ * you may not use this file except in compliance with the License.
~ * You may obtain a copy of the License at
~ *
~ * http://www.apache.org/licenses/LICENSE-2.0
~ *
~ * Unless required by applicable law or agreed to in writing, software
~ * distributed under the License is distributed on an "AS IS" BASIS,
~ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ * See the License for the specific language governing permissions and
~ * limitations under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet author="keycloak" id="20.0.0-12964-supported-dbs">
<preConditions onSqlOutput="TEST" onFail="MARK_RAN">
<or>
<dbms type="mysql"/>
<dbms type="mariadb"/>
<dbms type="postgresql"/>
<dbms type="oracle"/>
</or>
</preConditions>
<createIndex tableName="GROUP_ATTRIBUTE" indexName="IDX_GROUP_ATT_BY_NAME_VALUE">
<column name="NAME" type="VARCHAR(255)"/>
<column name="VALUE(255)" valueComputed="VALUE(255)" />
</createIndex>
<modifySql dbms="postgresql">
<replace replace="VALUE(255)" with="(value::varchar(250))" />
</modifySql>
<modifySql dbms="oracle">
<replace replace="VALUE(255)" with="SUBSTR(VALUE, 1, 250)" />
</modifySql>
</changeSet>
<changeSet author="keycloak" id="20.0.0-12964-unsupported-dbs">
<preConditions onSqlOutput="TEST" onFail="MARK_RAN">
<not>
<or>
<dbms type="mysql"/>
<dbms type="mariadb"/>
<dbms type="postgresql"/>
<dbms type="oracle"/>
</or>
</not>
</preConditions>
<createIndex tableName="GROUP_ATTRIBUTE" indexName="IDX_GROUP_ATT_BY_NAME_VALUE">
<column name="NAME" type="VARCHAR(255)"/>
<!-- Do not include VALUE column -->
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -74,5 +74,6 @@
<include file="META-INF/jpa-changelog-17.0.0.xml"/>
<include file="META-INF/jpa-changelog-18.0.0.xml"/>
<include file="META-INF/jpa-changelog-19.0.0.xml"/>
<include file="META-INF/jpa-changelog-20.0.0.xml"/>
</databaseChangeLog>

View file

@ -26,6 +26,7 @@ import org.keycloak.storage.group.GroupStorageProvider;
import org.keycloak.storage.group.GroupStorageProviderFactory;
import org.keycloak.storage.group.GroupStorageProviderModel;
import java.util.Map;
import java.util.stream.Stream;
public class GroupStorageManager extends AbstractStorageManager<GroupStorageProvider, GroupStorageProviderModel> implements GroupProvider {
@ -71,6 +72,15 @@ public class GroupStorageManager extends AbstractStorageManager<GroupStorageProv
return Stream.concat(local, ext);
}
@Override
public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
Stream<GroupModel> local = localStorage().searchGroupsByAttributes(realm, attributes, firstResult, maxResults);
Stream<GroupModel> ext = flatMapEnabledStorageProvidersWithTimeout(realm, GroupProvider.class,
p -> p.searchGroupsByAttributes(realm, attributes, firstResult, maxResults));
return Stream.concat(local, ext);
}
/* GROUP PROVIDER METHODS - provided only by local storage (e.g. not supported by storage providers) */
@Override

View file

@ -65,6 +65,7 @@ public class IckleQueryMapModelCriteriaBuilder<E extends AbstractHotRodEntity, M
INFINISPAN_NAME_OVERRIDES.put(GroupModel.SearchableFields.PARENT_ID, "parentId");
INFINISPAN_NAME_OVERRIDES.put(GroupModel.SearchableFields.ASSIGNED_ROLE, "grantedRoles");
INFINISPAN_NAME_OVERRIDES.put(GroupModel.SearchableFields.ATTRIBUTE, "attributes");
INFINISPAN_NAME_OVERRIDES.put(RoleModel.SearchableFields.IS_CLIENT_ROLE, "clientRole");

View file

@ -21,6 +21,7 @@ import org.keycloak.authorization.model.Policy;
import org.keycloak.events.Event;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.models.ClientModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.map.storage.CriterionNotSupportedException;
@ -61,6 +62,7 @@ public class IckleQueryWhereClauses {
static {
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(ClientModel.SearchableFields.ATTRIBUTE, IckleQueryWhereClauses::whereClauseForAttributes);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.ATTRIBUTE, IckleQueryWhereClauses::whereClauseForAttributes);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(GroupModel.SearchableFields.ATTRIBUTE, IckleQueryWhereClauses::whereClauseForAttributes);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.IDP_AND_USER, IckleQueryWhereClauses::whereClauseForUserIdpAlias);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.CONSENT_CLIENT_FEDERATION_LINK, IckleQueryWhereClauses::whereClauseForConsentClientFederationLink);
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.USERNAME_CASE_INSENSITIVE, IckleQueryWhereClauses::whereClauseForUsernameCaseInsensitive);

View file

@ -26,7 +26,7 @@ import org.keycloak.models.map.annotations.IgnoreForEntityImplementationGenerato
import org.keycloak.models.map.group.MapGroupEntity;
import org.keycloak.models.map.storage.hotRod.common.AbstractHotRodEntity;
import org.keycloak.models.map.storage.hotRod.common.CommonPrimitivesProtoSchemaInitializer;
import org.keycloak.models.map.storage.hotRod.common.HotRodAttributeEntityNonIndexed;
import org.keycloak.models.map.storage.hotRod.common.HotRodAttributeEntity;
import org.keycloak.models.map.storage.hotRod.common.UpdatableHotRodEntityDelegateImpl;
import java.util.Objects;
@ -109,8 +109,9 @@ public class HotRodGroupEntity extends AbstractHotRodEntity {
@ProtoField(number = 6)
public String parentId;
@ProtoDoc("@Field(index = Index.YES, store = Store.YES)")
@ProtoField(number = 7)
public Set<HotRodAttributeEntityNonIndexed> attributes;
public Set<HotRodAttributeEntity> attributes;
@ProtoDoc("@Field(index = Index.YES, store = Store.YES)")
@ProtoField(number = 8)

View file

@ -21,8 +21,11 @@ import java.util.Set;
import java.util.UUID;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
import org.keycloak.models.GroupModel;
import org.keycloak.models.map.storage.CriterionNotSupportedException;
import org.keycloak.models.map.storage.jpa.group.entity.JpaGroupAttributeEntity;
import org.keycloak.models.map.storage.jpa.group.entity.JpaGroupEntity;
import org.keycloak.models.map.storage.jpa.JpaModelCriteriaBuilder;
import org.keycloak.models.map.storage.jpa.JpaPredicateFunction;
@ -71,6 +74,16 @@ public class JpaGroupModelCriteriaBuilder extends JpaModelCriteriaBuilder<JpaGro
cb.function("->", JsonbType.class, root.get("metadata"), cb.literal("fGrantedRoles")),
cb.literal(convertToJson(value[0]))))
);
} else if (modelField == GroupModel.SearchableFields.ATTRIBUTE) {
validateValue(value, modelField, op, String.class, String.class);
return new JpaGroupModelCriteriaBuilder((cb, query, root) -> {
Join<JpaGroupEntity, JpaGroupAttributeEntity> join = root.join("attributes", JoinType.LEFT);
return cb.and(
cb.equal(join.get("name"), value[0]),
cb.equal(join.get("value"), value[1])
);
});
} else {
throw new CriterionNotSupportedException(modelField, op);
}

View file

@ -33,6 +33,7 @@ import org.keycloak.models.map.storage.QueryParameters;
import org.keycloak.models.map.storage.criteria.DefaultModelCriteria;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.UnaryOperator;
@ -186,6 +187,18 @@ public class MapGroupProvider implements GroupProvider {
}).sorted(GroupModel.COMPARE_BY_NAME).distinct();
}
@Override
public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
DefaultModelCriteria<GroupModel> mcb = criteria();
mcb = mcb.compare(GroupModel.SearchableFields.REALM_ID, Operator.EQ, realm.getId());
for (Map.Entry<String, String> entry : attributes.entrySet()) {
mcb = mcb.compare(GroupModel.SearchableFields.ATTRIBUTE, Operator.EQ, entry.getKey(), entry.getValue());
}
return tx.read(withCriteria(mcb).pagination(firstResult, maxResults, SearchableFields.NAME))
.map(entityToAdapterFunc(realm));
}
@Override
public GroupModel createGroup(RealmModel realm, String id, String name, GroupModel toParent) {
LOG.tracef("createGroup(%s, %s, %s, %s)%s", realm, id, name, toParent, getShortStackTrace());

View file

@ -397,6 +397,11 @@ public class MapRealmProvider implements RealmProvider {
return session.groups().searchForGroupByNameStream(realm, search, firstResult, maxResults);
}
@Override
public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
return session.groups().searchGroupsByAttributes(realm, attributes, firstResult, maxResults);
}
@Override
@Deprecated
public RoleModel addRealmRole(RealmModel realm, String id, String name) {

View file

@ -123,6 +123,7 @@ public class MapFieldPredicates {
put(GROUP_PREDICATES, GroupModel.SearchableFields.NAME, MapGroupEntity::getName);
put(GROUP_PREDICATES, GroupModel.SearchableFields.PARENT_ID, MapGroupEntity::getParentId);
put(GROUP_PREDICATES, GroupModel.SearchableFields.ASSIGNED_ROLE, MapFieldPredicates::checkGrantedGroupRole);
put(GROUP_PREDICATES, GroupModel.SearchableFields.ATTRIBUTE, MapFieldPredicates::checkGroupAttributes);
put(ROLE_PREDICATES, RoleModel.SearchableFields.REALM_ID, MapRoleEntity::getRealmId);
put(ROLE_PREDICATES, RoleModel.SearchableFields.CLIENT_ID, MapRoleEntity::getClientId);
@ -370,6 +371,28 @@ public class MapFieldPredicates {
return mcb.fieldCompare(Boolean.TRUE::equals, getter);
}
private static MapModelCriteriaBuilder<Object, MapGroupEntity, GroupModel> checkGroupAttributes(MapModelCriteriaBuilder<Object, MapGroupEntity, GroupModel> mcb, Operator op, Object[] values) {
if (values == null || values.length != 2) {
throw new CriterionNotSupportedException(GroupModel.SearchableFields.ATTRIBUTE, op, "Invalid arguments, expected attribute_name-value pair, got: " + Arrays.toString(values));
}
final Object attrName = values[0];
if (! (attrName instanceof String)) {
throw new CriterionNotSupportedException(GroupModel.SearchableFields.ATTRIBUTE, op, "Invalid arguments, expected (String attribute_name), got: " + Arrays.toString(values));
}
String attrNameS = (String) attrName;
Object[] realValues = new Object[values.length - 1];
System.arraycopy(values, 1, realValues, 0, values.length - 1);
Predicate<Object> valueComparator = CriteriaOperator.predicateFor(op, realValues);
Function<MapGroupEntity, ?> getter = ue -> {
final List<String> attrs = ue.getAttribute(attrNameS);
return attrs != null && attrs.stream().anyMatch(valueComparator);
};
return mcb.fieldCompare(Boolean.TRUE::equals, getter);
}
private static MapModelCriteriaBuilder<Object, MapRoleEntity, RoleModel> checkCompositeRoles(MapModelCriteriaBuilder<Object, MapRoleEntity, RoleModel> mcb, Operator op, Object[] values) {
String roleIdS = ensureEqSingleValue(RoleModel.SearchableFields.COMPOSITE_ROLE, "composite_role_id", op, values);
Function<MapRoleEntity, ?> getter;

View file

@ -45,6 +45,7 @@ import org.keycloak.representations.idm.*;
import org.keycloak.representations.idm.authorization.*;
import org.keycloak.storage.StorageId;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StreamsUtil;
import org.keycloak.utils.StringUtil;
import java.io.IOException;
@ -158,6 +159,24 @@ public class ModelToRepresentation {
return rep;
}
public static Stream<GroupRepresentation> searchGroupsByAttributes(KeycloakSession session, RealmModel realm, boolean full, Map<String,String> attributes, Integer first, Integer max) {
return session.groups().searchGroupsByAttributes(realm, attributes, first, max)
// We need to return whole group hierarchy when any child group fulfills the attribute search,
// therefore for each group from the result, we need to find root group
.map(group -> {
while (Objects.nonNull(group.getParentId())) {
group = group.getParent();
}
return group;
})
// More child groups of one root can fulfill the search, so we need to filter duplicates
.filter(StreamsUtil.distinctByKey(GroupModel::getId))
// and then turn the result into GroupRepresentations creating whole hierarchy of child groups for each root group
.map(g -> toGroupHierarchy(g, full, attributes));
}
public static Stream<GroupRepresentation> searchForGroupByName(RealmModel realm, boolean full, String search, Integer first, Integer max) {
return realm.searchForGroupByNameStream(search, first, max)
.map(g -> toGroupHierarchy(g, full, search));
@ -189,7 +208,7 @@ public class ModelToRepresentation {
}
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full) {
return toGroupHierarchy(group, full, null);
return toGroupHierarchy(group, full, (String) null);
}
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full, String search) {
@ -201,6 +220,14 @@ public class ModelToRepresentation {
return rep;
}
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full, Map<String,String> attributes) {
GroupRepresentation rep = toRepresentation(group, full);
List<GroupRepresentation> subGroups = group.getSubGroupsStream()
.map(subGroup -> toGroupHierarchy(subGroup, full, attributes)).collect(Collectors.toList());
rep.setSubGroups(subGroups);
return rep;
}
private static boolean groupMatchesSearchOrIsPathElement(GroupModel group, String search) {
if (StringUtil.isBlank(search)) {
return true;

View file

@ -44,6 +44,12 @@ public interface GroupModel extends RoleMapperModel {
* A role can be checked for belonging only via EQ operator. Role is referred by their ID
*/
public static final SearchableModelField<GroupModel> ASSIGNED_ROLE = new SearchableModelField<>("assignedRole", String.class);
/**
* Search for attribute value. The parameters is a pair {@code (attribute_name, values...)} where {@code attribute_name}
* is always checked for equality, and the value is checked per the operator.
*/
public static final SearchableModelField<GroupModel> ATTRIBUTE = new SearchableModelField<>("attribute", String[].class);
}
interface GroupRemovedEvent extends ProviderEvent {

View file

@ -20,6 +20,7 @@ import org.keycloak.models.GroupModel;
import org.keycloak.models.RealmModel;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -64,5 +65,15 @@ public interface GroupLookupProvider {
*/
Stream<GroupModel> searchForGroupByNameStream(RealmModel realm, String search, Integer firstResult, Integer maxResults);
/**
* Returns the groups filtered by attribute names and attribute values for the given realm.
*
* @param realm Realm.
* @param attributes name-value pairs that are compared to group attributes.
* @param firstResult First result to return. Ignored if negative or {@code null}.
* @param maxResults Maximum number of results to return. Ignored if negative or {@code null}.
* @return Stream of groups with attributes matching all searched attributes. Never returns {@code null}.
*/
Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults);
}

View file

@ -30,6 +30,7 @@ import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.utils.SearchQueryUtils;
import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
@ -75,12 +76,16 @@ public class GroupsResource {
@NoCache
@Produces(MediaType.APPLICATION_JSON)
public Stream<GroupRepresentation> getGroups(@QueryParam("search") String search,
@QueryParam("q") String searchQuery,
@QueryParam("first") Integer firstResult,
@QueryParam("max") Integer maxResults,
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) {
auth.groups().requireList();
if (Objects.nonNull(search)) {
if (Objects.nonNull(searchQuery)) {
Map<String, String> attributes = SearchQueryUtils.getFields(searchQuery);
return ModelToRepresentation.searchGroupsByAttributes(session, realm, !briefRepresentation, attributes, firstResult, maxResults);
} else if (Objects.nonNull(search)) {
return ModelToRepresentation.searchForGroupByName(realm, !briefRepresentation, search.trim(), firstResult, maxResults);
} else if(Objects.nonNull(firstResult) && Objects.nonNull(maxResults)) {
return ModelToRepresentation.toGroupHierarchy(realm, !briefRepresentation, firstResult, maxResults);

View file

@ -67,6 +67,18 @@ public class HardcodedGroupStorageProvider implements GroupStorageProvider {
return Stream.empty();
}
@Override
public Stream<GroupModel> searchGroupsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
if (Boolean.parseBoolean(component.getConfig().getFirst(HardcodedGroupStorageProviderFactory.DELAYED_SEARCH))) try {
Thread.sleep(5000l);
} catch (InterruptedException ex) {
Logger.getLogger(HardcodedGroupStorageProvider.class).warn(ex.getCause());
return Stream.empty();
}
return Stream.empty();
}
public class HardcodedGroupAdapter implements GroupModel.Streams {

View file

@ -17,18 +17,25 @@
package org.keycloak.testsuite.admin.group;
import javax.ws.rs.core.Response;
import org.junit.Rule;
import org.keycloak.OAuth2Constants;
import org.keycloak.RSATokenVerifier;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.util.PemUtils;
import org.keycloak.events.Details;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.util.AdminEventPaths;
import org.keycloak.testsuite.util.AssertAdminEvents;
import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse;
@ -91,4 +98,32 @@ public abstract class AbstractGroupTest extends AbstractKeycloakTest {
testRealms.add(result);
return result;
}
GroupRepresentation createGroup(RealmResource realm, GroupRepresentation group) {
try (Response response = realm.groups().add(group)) {
String groupId = ApiUtil.getCreatedId(response);
getCleanup().addGroupId(groupId);
assertAdminEvents.assertEvent(testRealmId, OperationType.CREATE, AdminEventPaths.groupPath(groupId), group, ResourceType.GROUP);
// Set ID to the original rep
group.setId(groupId);
return group;
}
}
void addSubGroup(RealmResource realm, GroupRepresentation parent, GroupRepresentation child) {
Response response = realm.groups().add(child);
child.setId(ApiUtil.getCreatedId(response));
response = realm.groups().group(parent.getId()).subGroup(child);
response.close();
}
RoleRepresentation createRealmRole(RealmResource realm, RoleRepresentation role) {
realm.roles().create(role);
RoleRepresentation created = realm.roles().get(role.getName()).toRepresentation();
getCleanup().addRoleId(created.getId());
return created;
}
}

View file

@ -0,0 +1,227 @@
package org.keycloak.testsuite.admin.group;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.equalTo;
import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import org.hamcrest.Matchers;
import org.jboss.arquillian.container.test.api.ContainerController;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.GroupResource;
import org.keycloak.admin.client.resource.GroupsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.models.GroupProvider;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
import org.keycloak.testsuite.arquillian.containers.KeycloakQuarkusServerDeployableContainer;
import org.keycloak.testsuite.updaters.Creator;
import org.wildfly.extras.creaper.core.online.OnlineManagementClient;
import org.wildfly.extras.creaper.core.online.operations.admin.Administration;
public class GroupSearchTest extends AbstractGroupTest {
@ArquillianResource
protected ContainerController controller;
private static final String GROUP1 = "group1";
private static final String GROUP2 = "group2";
private static final String GROUP3 = "group3";
private static final String PARENT_GROUP = "parentGroup";
private static final String CHILD_GROUP = "childGroup";
private static final String ATTR_ORG_NAME = "org";
private static final String ATTR_ORG_VAL = "Test_\"organisation\"";
private static final String ATTR_URL_NAME = "url";
private static final String ATTR_URL_VAL = "https://foo.bar/clflds";
private static final String ATTR_FILTERED_NAME = "filtered";
private static final String ATTR_FILTERED_VAL = "does_not_matter";
private static final String ATTR_QUOTES_NAME = "test \"123\"";
private static final String ATTR_QUOTES_NAME_ESCAPED = "\"test \\\"123\\\"\"";
private static final String ATTR_QUOTES_VAL = "field=\"blah blah\"";
private static final String ATTR_QUOTES_VAL_ESCAPED = "\"field=\\\"blah blah\\\"\"";
private static final String SEARCHABLE_ATTRS_PROP = "keycloak.group.searchableAttributes";
GroupRepresentation group1;
GroupRepresentation group2;
GroupRepresentation group3;
GroupRepresentation parentGroup;
GroupRepresentation childGroup;
@Before
public void init() {
group1 = new GroupRepresentation();
group2 = new GroupRepresentation();
group3 = new GroupRepresentation();
parentGroup = new GroupRepresentation();
childGroup = new GroupRepresentation();
group1.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList(ATTR_ORG_VAL));
put(ATTR_URL_NAME, Collections.singletonList(ATTR_URL_VAL));
}});
group2.setAttributes(new HashMap<>() {{
put(ATTR_FILTERED_NAME, Collections.singletonList(ATTR_FILTERED_VAL));
put(ATTR_URL_NAME, Collections.singletonList(ATTR_URL_VAL));
}});
group3.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList("fake group"));
put(ATTR_QUOTES_NAME, Collections.singletonList(ATTR_QUOTES_VAL));
}});
childGroup.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList("parentOrg"));
}});
childGroup.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList("childOrg"));
}});
group1.setName(GROUP1);
group2.setName(GROUP2);
group3.setName(GROUP3);
parentGroup.setName(PARENT_GROUP);
childGroup.setName(CHILD_GROUP);
}
public RealmResource testRealmResource() {
return adminClient.realm(TEST);
}
@Test
public void testQuerySearch() throws Exception {
configureSearchableAttributes(ATTR_URL_NAME, ATTR_ORG_NAME, ATTR_QUOTES_NAME);
try (Creator<GroupResource> groupCreator1 = Creator.create(testRealmResource(), group1);
Creator<GroupResource> groupCreator2 = Creator.create(testRealmResource(), group2);
Creator<GroupResource> groupCreator3 = Creator.create(testRealmResource(), group3)) {
search(String.format("%s:%s", ATTR_ORG_NAME, ATTR_ORG_VAL), GROUP1);
search(String.format("%s:%s", ATTR_URL_NAME, ATTR_URL_VAL), GROUP1, GROUP2);
search(String.format("%s:%s %s:%s", ATTR_ORG_NAME, ATTR_ORG_VAL, ATTR_URL_NAME, ATTR_URL_VAL),
GROUP1);
search(String.format("%s:%s %s:%s", ATTR_ORG_NAME, "wrong val", ATTR_URL_NAME, ATTR_URL_VAL));
search(String.format("%s:%s", ATTR_QUOTES_NAME_ESCAPED, ATTR_QUOTES_VAL_ESCAPED), GROUP3);
// "filtered" attribute won't take effect when JPA is used
String[] expectedRes = isLegacyJpaStore() ? new String[]{GROUP1, GROUP2} : new String[]{GROUP2};
search(String.format("%s:%s %s:%s", ATTR_URL_NAME, ATTR_URL_VAL, ATTR_FILTERED_NAME, ATTR_FILTERED_VAL), expectedRes);
} finally {
resetSearchableAttributes();
}
}
@Test
public void testNestedGroupQuerySearch() throws Exception {
configureSearchableAttributes(ATTR_URL_NAME, ATTR_ORG_NAME, ATTR_QUOTES_NAME);
try (Creator<GroupResource> parentGroupCreator = Creator.create(testRealmResource(), parentGroup)) {
parentGroupCreator.resource().subGroup(childGroup);
// query for the child group by org name
GroupsResource search = testRealmResource().groups();
String searchQuery = String.format("%s:%s", ATTR_ORG_NAME, "childOrg");
List<GroupRepresentation> found = search.query(searchQuery);
assertThat(found.size(), is(1));
assertThat(found.get(0).getName(), is(equalTo(PARENT_GROUP)));
List<GroupRepresentation> subGroups = found.get(0).getSubGroups();
assertThat(subGroups.size(), is(1));
assertThat(subGroups.get(0).getName(), is(equalTo(CHILD_GROUP)));
} finally {
resetSearchableAttributes();
}
}
private void search(String searchQuery, String... expectedGroupIds) {
GroupsResource search = testRealmResource().groups();
List<String> found = search.query(searchQuery).stream()
.map(GroupRepresentation::getName)
.collect(Collectors.toList());
assertThat(found, containsInAnyOrder(expectedGroupIds));
}
void configureSearchableAttributes(String... searchableAttributes) throws Exception {
log.infov("Configuring searchableAttributes");
if (suiteContext.getAuthServerInfo().isUndertow()) {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
System.setProperty(SEARCHABLE_ATTRS_PROP, String.join(",", searchableAttributes));
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else if (suiteContext.getAuthServerInfo().isJBossBased()) {
searchableAttributes = Arrays.stream(searchableAttributes).map(a -> a.replace("\"", "\\\\\\\"")).toArray(String[]::new);
String s = "\\\"" + String.join("\\\",\\\"", searchableAttributes) + "\\\"";
executeCli("/subsystem=keycloak-server/spi=group:add()",
"/subsystem=keycloak-server/spi=group/provider=jpa/:add(properties={searchableAttributes => \"[" + s + "]\"},enabled=true)");
} else if (suiteContext.getAuthServerInfo().isQuarkus()) {
searchableAttributes = Arrays.stream(searchableAttributes)
.map(a -> a.replace(" ", "\\ ").replace("\"", "\\\\\\\""))
.toArray(String[]::new);
String s = String.join(",", searchableAttributes);
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
KeycloakQuarkusServerDeployableContainer container = (KeycloakQuarkusServerDeployableContainer) suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
container.setAdditionalBuildArgs(
Collections.singletonList("--spi-group-jpa-searchable-attributes=\"" + s + "\""));
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else {
throw new RuntimeException("Don't know how to config");
}
reconnectAdminClient();
}
void resetSearchableAttributes() throws Exception {
log.info("Reset searchableAttributes");
if (suiteContext.getAuthServerInfo().isUndertow()) {
controller.stop(suiteContext.getAuthServerInfo().getQualifier());
System.clearProperty(SEARCHABLE_ATTRS_PROP);
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else if (suiteContext.getAuthServerInfo().isJBossBased()) {
executeCli("/subsystem=keycloak-server/spi=group:remove");
} else if (suiteContext.getAuthServerInfo().isQuarkus()) {
KeycloakQuarkusServerDeployableContainer container = (KeycloakQuarkusServerDeployableContainer) suiteContext.getAuthServerInfo().getArquillianContainer().getDeployableContainer();
container.setAdditionalBuildArgs(Collections.emptyList());
container.restartServer();
} else {
throw new RuntimeException("Don't know how to config");
}
reconnectAdminClient();
}
private void executeCli(String... commands) throws Exception {
OnlineManagementClient client = AuthServerTestEnricher.getManagementClient();
Administration administration = new Administration(client);
log.debug("Running CLI commands:");
for (String c : commands) {
log.debug(c);
client.execute(c).assertSuccess();
}
log.debug("Done");
administration.reload();
client.close();
}
private boolean isLegacyJpaStore() {
return keycloakUsingProviderWithId(GroupProvider.class, "jpa");
}
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
loadTestRealm(testRealmReps);
}
}

View file

@ -157,27 +157,6 @@ public class GroupTest extends AbstractGroupTest {
assertAdminEvents.assertEvent(testRealmId, OperationType.DELETE, AdminEventPaths.clientResourcePath(clientUuid), ResourceType.CLIENT);
}
private GroupRepresentation createGroup(RealmResource realm, GroupRepresentation group) {
try (Response response = realm.groups().add(group)) {
String groupId = ApiUtil.getCreatedId(response);
getCleanup().addGroupId(groupId);
assertAdminEvents.assertEvent(testRealmId, OperationType.CREATE, AdminEventPaths.groupPath(groupId), group, ResourceType.GROUP);
// Set ID to the original rep
group.setId(groupId);
return group;
}
}
private RoleRepresentation createRealmRole(RealmResource realm, RoleRepresentation role) {
realm.roles().create(role);
RoleRepresentation created = realm.roles().get(role.getName()).toRepresentation();
getCleanup().addRoleId(created.getId());
return created;
}
@Test
public void doNotAllowSameGroupNameAtSameLevel() throws Exception {
RealmResource realm = adminClient.realms().realm("test");
@ -245,13 +224,6 @@ public class GroupTest extends AbstractGroupTest {
assertEquals("HTTP 409 Conflict", e.getMessage());
}
}
private void addSubGroup(RealmResource realm, GroupRepresentation parent, GroupRepresentation child) {
Response response = realm.groups().add(child);
child.setId(ApiUtil.getCreatedId(response));
response = realm.groups().group(parent.getId()).subGroup(child);
response.close();
}
@Test
public void allowSameGroupNameAtDifferentLevel() throws Exception {

View file

@ -0,0 +1,59 @@
package org.keycloak.testsuite.util.cli;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
public class GroupCommands {
public static class Create extends AbstractCommand {
private String groupPrefix;
private String realmName;
@Override
public String getName() {
return "createGroups";
}
@Override
protected void doRunCommand(KeycloakSession session) {
groupPrefix = getArg(0);
realmName = getArg(1);
int first = getIntArg(2);
int count = getIntArg(3);
int batchCount = getIntArg(4);
BatchTaskRunner.runInBatches(first, count, batchCount, session.getKeycloakSessionFactory(), this::createGroupsInBatch);
log.infof("Command finished. All groups from %s to %s created", groupPrefix + first, groupPrefix
+ (first + count - 1));
}
private void createGroupsInBatch(KeycloakSession session, int first, int count) {
RealmModel realm = session.realms().getRealmByName(realmName);
if (realm == null) {
log.errorf("Unknown realm: %s", realmName);
throw new HandledException();
}
int last = first + count;
for (int counter = first; counter < last; counter++) {
String groupName = groupPrefix + counter;
GroupModel group = session.groups().createGroup(realm, groupName);
group.setSingleAttribute("test-attribute", groupName + "_testAttribute");
}
log.infof("groups from %s to %s created", groupPrefix + first, groupPrefix + (last - 1));
}
@Override
public String printUsage() {
return super.printUsage() + " <group-prefix> <realm-name> <starting-group-offset> <total-count> <batch-size>. " +
"\n'total-count' refers to total count of newly created groups. 'batch-size' refers to number of created groups in each transaction. 'starting-group-offset' refers to starting group offset." +
"\nFor example if 'starting-group-offset' is 15 and total-count is 10 and group-prefix is 'test', it will create groups test15, test16, test17, ... , test24" +
"Example usage: " + super.printUsage() + " test demo 0 500 100";
}
}
}

View file

@ -60,6 +60,7 @@ public class TestsuiteCLI {
UserCommands.Remove.class,
UserCommands.Count.class,
UserCommands.GetUser.class,
GroupCommands.Create.class,
SyncDummyFederationProviderCommand.class,
RoleCommands.CreateRoles.class,
CacheCommands.ListCachesCommand.class,