diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupsResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupsResource.java index 96548f81d0..793e6ccf58 100755 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupsResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupsResource.java @@ -124,4 +124,7 @@ public interface GroupsResource { @Path("{id}") GroupResource group(@PathParam("id") String id); + @GET + @Produces(MediaType.APPLICATION_JSON) + List query(@QueryParam("q") String searchQuery); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java index 5569fe2fff..4579e414e1 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java @@ -1028,6 +1028,11 @@ public class RealmCacheSession implements CacheRealmProvider { return getGroupDelegate().searchForGroupByNameStream(realm, search, first, max); } + @Override + public Stream searchGroupsByAttributes(RealmModel realm, Map 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); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaClientProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaClientProviderFactory.java index b6b43206f0..b6794a3815 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaClientProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaClientProviderFactory.java @@ -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 diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaClientScopeProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaClientScopeProviderFactory.java index eb05f9d2d7..23afcfe9cb 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaClientScopeProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaClientScopeProviderFactory.java @@ -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 diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaDeploymentStateProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaDeploymentStateProviderFactory.java index 94f48b6e25..8d3c51967b 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaDeploymentStateProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaDeploymentStateProviderFactory.java @@ -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 diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaGroupProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaGroupProviderFactory.java index 6ec356aa7d..bed3428641 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaGroupProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaGroupProviderFactory.java @@ -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 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 diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java index 06148a019d..6276c92588 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java @@ -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 clientSearchableAttributes; + private Set groupSearchableAttributes; - public JpaRealmProvider(KeycloakSession session, EntityManager em, Set clientSearchableAttributes) { + public JpaRealmProvider(KeycloakSession session, EntityManager em, Set clientSearchableAttributes, Set 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 searchGroupsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + Map 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 queryBuilder = builder.createQuery(GroupEntity.class); + Root root = queryBuilder.from(GroupEntity.class); + + List predicates = new ArrayList<>(); + + predicates.add(builder.equal(root.get("realm"), realm.getId())); + + for (Map.Entry entry : filteredAttributes.entrySet()) { + String key = entry.getKey(); + if (key == null || key.isEmpty()) { + continue; + } + String value = entry.getValue(); + + Join 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 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(); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProviderFactory.java index db4496bc85..5a5eb00f2b 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProviderFactory.java @@ -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 diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRoleProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRoleProviderFactory.java index eb8f760f33..a7ae5604cb 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRoleProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRoleProviderFactory.java @@ -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 diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-20.0.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-20.0.0.xml new file mode 100644 index 0000000000..dbe5a78f4b --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-20.0.0.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml index 4dc8d77b2a..df618f009f 100755 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml @@ -74,5 +74,6 @@ + diff --git a/model/legacy-private/src/main/java/org/keycloak/storage/GroupStorageManager.java b/model/legacy-private/src/main/java/org/keycloak/storage/GroupStorageManager.java index dfd1959d9d..dd4e3a35c5 100644 --- a/model/legacy-private/src/main/java/org/keycloak/storage/GroupStorageManager.java +++ b/model/legacy-private/src/main/java/org/keycloak/storage/GroupStorageManager.java @@ -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 implements GroupProvider { @@ -71,6 +72,15 @@ public class GroupStorageManager extends AbstractStorageManager searchGroupsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + Stream local = localStorage().searchGroupsByAttributes(realm, attributes, firstResult, maxResults); + Stream 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 diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/IckleQueryMapModelCriteriaBuilder.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/IckleQueryMapModelCriteriaBuilder.java index 9ee7880832..3e16a19674 100644 --- a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/IckleQueryMapModelCriteriaBuilder.java +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/IckleQueryMapModelCriteriaBuilder.java @@ -65,6 +65,7 @@ public class IckleQueryMapModelCriteriaBuilder attributes; + public Set attributes; @ProtoDoc("@Field(index = Index.YES, store = Store.YES)") @ProtoField(number = 8) diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/group/JpaGroupModelCriteriaBuilder.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/group/JpaGroupModelCriteriaBuilder.java index a8a7c8fcf9..c33a6f33d1 100644 --- a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/group/JpaGroupModelCriteriaBuilder.java +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/group/JpaGroupModelCriteriaBuilder.java @@ -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", 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 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); } diff --git a/model/map/src/main/java/org/keycloak/models/map/group/MapGroupProvider.java b/model/map/src/main/java/org/keycloak/models/map/group/MapGroupProvider.java index 27084a9fa6..3127015622 100644 --- a/model/map/src/main/java/org/keycloak/models/map/group/MapGroupProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/group/MapGroupProvider.java @@ -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 searchGroupsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + DefaultModelCriteria mcb = criteria(); + mcb = mcb.compare(GroupModel.SearchableFields.REALM_ID, Operator.EQ, realm.getId()); + for (Map.Entry 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()); diff --git a/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmProvider.java b/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmProvider.java index 669981fd9b..040011e7c1 100644 --- a/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmProvider.java @@ -397,6 +397,11 @@ public class MapRealmProvider implements RealmProvider { return session.groups().searchForGroupByNameStream(realm, search, firstResult, maxResults); } + @Override + public Stream searchGroupsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + return session.groups().searchGroupsByAttributes(realm, attributes, firstResult, maxResults); + } + @Override @Deprecated public RoleModel addRealmRole(RealmModel realm, String id, String name) { diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapFieldPredicates.java b/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapFieldPredicates.java index a80f10c54e..a2d84ef3e1 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapFieldPredicates.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapFieldPredicates.java @@ -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 checkGroupAttributes(MapModelCriteriaBuilder 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 valueComparator = CriteriaOperator.predicateFor(op, realValues); + Function getter = ue -> { + final List attrs = ue.getAttribute(attrNameS); + return attrs != null && attrs.stream().anyMatch(valueComparator); + }; + + return mcb.fieldCompare(Boolean.TRUE::equals, getter); + } + + private static MapModelCriteriaBuilder checkCompositeRoles(MapModelCriteriaBuilder mcb, Operator op, Object[] values) { String roleIdS = ensureEqSingleValue(RoleModel.SearchableFields.COMPOSITE_ROLE, "composite_role_id", op, values); Function getter; diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 5eb035a4f7..079ba5ddab 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -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 searchGroupsByAttributes(KeycloakSession session, RealmModel realm, boolean full, Map 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 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 attributes) { + GroupRepresentation rep = toRepresentation(group, full); + List 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; diff --git a/server-spi/src/main/java/org/keycloak/models/GroupModel.java b/server-spi/src/main/java/org/keycloak/models/GroupModel.java index 25f34c22a5..bd7ccce1af 100755 --- a/server-spi/src/main/java/org/keycloak/models/GroupModel.java +++ b/server-spi/src/main/java/org/keycloak/models/GroupModel.java @@ -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 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 ATTRIBUTE = new SearchableModelField<>("attribute", String[].class); } interface GroupRemovedEvent extends ProviderEvent { diff --git a/server-spi/src/main/java/org/keycloak/storage/group/GroupLookupProvider.java b/server-spi/src/main/java/org/keycloak/storage/group/GroupLookupProvider.java index 33de690bab..fcd544a518 100644 --- a/server-spi/src/main/java/org/keycloak/storage/group/GroupLookupProvider.java +++ b/server-spi/src/main/java/org/keycloak/storage/group/GroupLookupProvider.java @@ -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 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 searchGroupsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java b/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java index 2f98cf3ead..d3fce9ccf5 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java @@ -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 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 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); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedGroupStorageProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedGroupStorageProvider.java index 8e2119b0b3..35fe0b7788 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedGroupStorageProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedGroupStorageProvider.java @@ -67,6 +67,18 @@ public class HardcodedGroupStorageProvider implements GroupStorageProvider { return Stream.empty(); } + @Override + public Stream searchGroupsByAttributes(RealmModel realm, Map 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 { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/AbstractGroupTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/AbstractGroupTest.java index 9f9aa25d6a..4a84a12542 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/AbstractGroupTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/AbstractGroupTest.java @@ -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; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupSearchTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupSearchTest.java new file mode 100644 index 0000000000..df15cd2e35 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupSearchTest.java @@ -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 groupCreator1 = Creator.create(testRealmResource(), group1); + Creator groupCreator2 = Creator.create(testRealmResource(), group2); + Creator 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 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 found = search.query(searchQuery); + + assertThat(found.size(), is(1)); + assertThat(found.get(0).getName(), is(equalTo(PARENT_GROUP))); + + List 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 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 testRealms) { + loadTestRealm(testRealmReps); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupTest.java index 24af198ebd..c4e27eede0 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/group/GroupTest.java @@ -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 { diff --git a/testsuite/utils/src/main/java/org/keycloak/testsuite/util/cli/GroupCommands.java b/testsuite/utils/src/main/java/org/keycloak/testsuite/util/cli/GroupCommands.java new file mode 100644 index 0000000000..d99ede7643 --- /dev/null +++ b/testsuite/utils/src/main/java/org/keycloak/testsuite/util/cli/GroupCommands.java @@ -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() + " . " + + "\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"; + } + + } + +} diff --git a/testsuite/utils/src/main/java/org/keycloak/testsuite/util/cli/TestsuiteCLI.java b/testsuite/utils/src/main/java/org/keycloak/testsuite/util/cli/TestsuiteCLI.java index 751e3975ce..c4811850d8 100644 --- a/testsuite/utils/src/main/java/org/keycloak/testsuite/util/cli/TestsuiteCLI.java +++ b/testsuite/utils/src/main/java/org/keycloak/testsuite/util/cli/TestsuiteCLI.java @@ -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,