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:
parent
0a832fc744
commit
55a660f50b
29 changed files with 587 additions and 39 deletions
|
@ -124,4 +124,7 @@ public interface GroupsResource {
|
||||||
@Path("{id}")
|
@Path("{id}")
|
||||||
GroupResource group(@PathParam("id") String id);
|
GroupResource group(@PathParam("id") String id);
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
List<GroupRepresentation> query(@QueryParam("q") String searchQuery);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1028,6 +1028,11 @@ public class RealmCacheSession implements CacheRealmProvider {
|
||||||
return getGroupDelegate().searchForGroupByNameStream(realm, search, first, max);
|
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
|
@Override
|
||||||
public boolean removeGroup(RealmModel realm, GroupModel group) {
|
public boolean removeGroup(RealmModel realm, GroupModel group) {
|
||||||
invalidateGroup(group.getId(), realm.getId(), true);
|
invalidateGroup(group.getId(), realm.getId(), true);
|
||||||
|
|
|
@ -70,7 +70,7 @@ public class JpaClientProviderFactory implements ClientProviderFactory {
|
||||||
@Override
|
@Override
|
||||||
public ClientProvider create(KeycloakSession session) {
|
public ClientProvider create(KeycloakSession session) {
|
||||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||||
return new JpaRealmProvider(session, em, clientSearchableAttributes);
|
return new JpaRealmProvider(session, em, clientSearchableAttributes, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -46,7 +46,7 @@ public class JpaClientScopeProviderFactory implements ClientScopeProviderFactory
|
||||||
@Override
|
@Override
|
||||||
public ClientScopeProvider create(KeycloakSession session) {
|
public ClientScopeProvider create(KeycloakSession session) {
|
||||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||||
return new JpaRealmProvider(session, em, null);
|
return new JpaRealmProvider(session, em, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -45,7 +45,7 @@ public class JpaDeploymentStateProviderFactory implements DeploymentStateProvide
|
||||||
@Override
|
@Override
|
||||||
public DeploymentStateProvider create(KeycloakSession session) {
|
public DeploymentStateProvider create(KeycloakSession session) {
|
||||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||||
return new JpaRealmProvider(session, em, null);
|
return new JpaRealmProvider(session, em, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -25,13 +25,31 @@ import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.KeycloakSessionFactory;
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
|
|
||||||
import javax.persistence.EntityManager;
|
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_ID;
|
||||||
import static org.keycloak.models.jpa.JpaRealmProviderFactory.PROVIDER_PRIORITY;
|
import static org.keycloak.models.jpa.JpaRealmProviderFactory.PROVIDER_PRIORITY;
|
||||||
|
|
||||||
public class JpaGroupProviderFactory implements GroupProviderFactory {
|
public class JpaGroupProviderFactory implements GroupProviderFactory {
|
||||||
|
|
||||||
|
private Set<String> groupSearchableAttributes = null;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void init(Config.Scope config) {
|
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
|
@Override
|
||||||
|
@ -47,7 +65,7 @@ public class JpaGroupProviderFactory implements GroupProviderFactory {
|
||||||
@Override
|
@Override
|
||||||
public GroupProvider create(KeycloakSession session) {
|
public GroupProvider create(KeycloakSession session) {
|
||||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||||
return new JpaRealmProvider(session, em, null);
|
return new JpaRealmProvider(session, em, null, groupSearchableAttributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -65,6 +65,7 @@ import org.keycloak.models.jpa.entities.ClientAttributeEntity;
|
||||||
import org.keycloak.models.jpa.entities.ClientEntity;
|
import org.keycloak.models.jpa.entities.ClientEntity;
|
||||||
import org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity;
|
import org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity;
|
||||||
import org.keycloak.models.jpa.entities.ClientScopeEntity;
|
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.GroupEntity;
|
||||||
import org.keycloak.models.jpa.entities.RealmEntity;
|
import org.keycloak.models.jpa.entities.RealmEntity;
|
||||||
import org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity;
|
import org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity;
|
||||||
|
@ -81,11 +82,13 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
|
||||||
private final KeycloakSession session;
|
private final KeycloakSession session;
|
||||||
protected EntityManager em;
|
protected EntityManager em;
|
||||||
private Set<String> clientSearchableAttributes;
|
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.session = session;
|
||||||
this.em = em;
|
this.em = em;
|
||||||
this.clientSearchableAttributes = clientSearchableAttributes;
|
this.clientSearchableAttributes = clientSearchableAttributes;
|
||||||
|
this.groupSearchableAttributes = groupSearchableAttributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -949,6 +952,43 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
|
||||||
}).sorted(GroupModel.COMPARE_BY_NAME).distinct());
|
}).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
|
@Override
|
||||||
public void removeExpiredClientInitialAccess() {
|
public void removeExpiredClientInitialAccess() {
|
||||||
int currentTime = Time.currentTime();
|
int currentTime = Time.currentTime();
|
||||||
|
|
|
@ -62,7 +62,7 @@ public class JpaRealmProviderFactory implements RealmProviderFactory, ProviderEv
|
||||||
@Override
|
@Override
|
||||||
public JpaRealmProvider create(KeycloakSession session) {
|
public JpaRealmProvider create(KeycloakSession session) {
|
||||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||||
return new JpaRealmProvider(session, em, null);
|
return new JpaRealmProvider(session, em, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -46,7 +46,7 @@ public class JpaRoleProviderFactory implements RoleProviderFactory {
|
||||||
@Override
|
@Override
|
||||||
public RoleProvider create(KeycloakSession session) {
|
public RoleProvider create(KeycloakSession session) {
|
||||||
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||||
return new JpaRealmProvider(session, em, null);
|
return new JpaRealmProvider(session, em, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -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>
|
|
@ -74,5 +74,6 @@
|
||||||
<include file="META-INF/jpa-changelog-17.0.0.xml"/>
|
<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-18.0.0.xml"/>
|
||||||
<include file="META-INF/jpa-changelog-19.0.0.xml"/>
|
<include file="META-INF/jpa-changelog-19.0.0.xml"/>
|
||||||
|
<include file="META-INF/jpa-changelog-20.0.0.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
@ -26,6 +26,7 @@ import org.keycloak.storage.group.GroupStorageProvider;
|
||||||
import org.keycloak.storage.group.GroupStorageProviderFactory;
|
import org.keycloak.storage.group.GroupStorageProviderFactory;
|
||||||
import org.keycloak.storage.group.GroupStorageProviderModel;
|
import org.keycloak.storage.group.GroupStorageProviderModel;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public class GroupStorageManager extends AbstractStorageManager<GroupStorageProvider, GroupStorageProviderModel> implements GroupProvider {
|
public class GroupStorageManager extends AbstractStorageManager<GroupStorageProvider, GroupStorageProviderModel> implements GroupProvider {
|
||||||
|
@ -71,6 +72,15 @@ public class GroupStorageManager extends AbstractStorageManager<GroupStorageProv
|
||||||
return Stream.concat(local, ext);
|
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) */
|
/* GROUP PROVIDER METHODS - provided only by local storage (e.g. not supported by storage providers) */
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -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.PARENT_ID, "parentId");
|
||||||
INFINISPAN_NAME_OVERRIDES.put(GroupModel.SearchableFields.ASSIGNED_ROLE, "grantedRoles");
|
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");
|
INFINISPAN_NAME_OVERRIDES.put(RoleModel.SearchableFields.IS_CLIENT_ROLE, "clientRole");
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import org.keycloak.authorization.model.Policy;
|
||||||
import org.keycloak.events.Event;
|
import org.keycloak.events.Event;
|
||||||
import org.keycloak.events.admin.AdminEvent;
|
import org.keycloak.events.admin.AdminEvent;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
|
import org.keycloak.models.GroupModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.models.map.storage.CriterionNotSupportedException;
|
import org.keycloak.models.map.storage.CriterionNotSupportedException;
|
||||||
|
@ -61,6 +62,7 @@ public class IckleQueryWhereClauses {
|
||||||
static {
|
static {
|
||||||
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(ClientModel.SearchableFields.ATTRIBUTE, IckleQueryWhereClauses::whereClauseForAttributes);
|
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(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.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.CONSENT_CLIENT_FEDERATION_LINK, IckleQueryWhereClauses::whereClauseForConsentClientFederationLink);
|
||||||
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.USERNAME_CASE_INSENSITIVE, IckleQueryWhereClauses::whereClauseForUsernameCaseInsensitive);
|
WHERE_CLAUSE_PRODUCER_OVERRIDES.put(UserModel.SearchableFields.USERNAME_CASE_INSENSITIVE, IckleQueryWhereClauses::whereClauseForUsernameCaseInsensitive);
|
||||||
|
|
|
@ -26,7 +26,7 @@ import org.keycloak.models.map.annotations.IgnoreForEntityImplementationGenerato
|
||||||
import org.keycloak.models.map.group.MapGroupEntity;
|
import org.keycloak.models.map.group.MapGroupEntity;
|
||||||
import org.keycloak.models.map.storage.hotRod.common.AbstractHotRodEntity;
|
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.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 org.keycloak.models.map.storage.hotRod.common.UpdatableHotRodEntityDelegateImpl;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
@ -109,8 +109,9 @@ public class HotRodGroupEntity extends AbstractHotRodEntity {
|
||||||
@ProtoField(number = 6)
|
@ProtoField(number = 6)
|
||||||
public String parentId;
|
public String parentId;
|
||||||
|
|
||||||
|
@ProtoDoc("@Field(index = Index.YES, store = Store.YES)")
|
||||||
@ProtoField(number = 7)
|
@ProtoField(number = 7)
|
||||||
public Set<HotRodAttributeEntityNonIndexed> attributes;
|
public Set<HotRodAttributeEntity> attributes;
|
||||||
|
|
||||||
@ProtoDoc("@Field(index = Index.YES, store = Store.YES)")
|
@ProtoDoc("@Field(index = Index.YES, store = Store.YES)")
|
||||||
@ProtoField(number = 8)
|
@ProtoField(number = 8)
|
||||||
|
|
|
@ -21,8 +21,11 @@ import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import javax.persistence.criteria.CriteriaBuilder;
|
import javax.persistence.criteria.CriteriaBuilder;
|
||||||
|
|
||||||
|
import javax.persistence.criteria.Join;
|
||||||
|
import javax.persistence.criteria.JoinType;
|
||||||
import org.keycloak.models.GroupModel;
|
import org.keycloak.models.GroupModel;
|
||||||
import org.keycloak.models.map.storage.CriterionNotSupportedException;
|
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.group.entity.JpaGroupEntity;
|
||||||
import org.keycloak.models.map.storage.jpa.JpaModelCriteriaBuilder;
|
import org.keycloak.models.map.storage.jpa.JpaModelCriteriaBuilder;
|
||||||
import org.keycloak.models.map.storage.jpa.JpaPredicateFunction;
|
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.function("->", JsonbType.class, root.get("metadata"), cb.literal("fGrantedRoles")),
|
||||||
cb.literal(convertToJson(value[0]))))
|
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 {
|
} else {
|
||||||
throw new CriterionNotSupportedException(modelField, op);
|
throw new CriterionNotSupportedException(modelField, op);
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ import org.keycloak.models.map.storage.QueryParameters;
|
||||||
|
|
||||||
import org.keycloak.models.map.storage.criteria.DefaultModelCriteria;
|
import org.keycloak.models.map.storage.criteria.DefaultModelCriteria;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.function.UnaryOperator;
|
import java.util.function.UnaryOperator;
|
||||||
|
@ -186,6 +187,18 @@ public class MapGroupProvider implements GroupProvider {
|
||||||
}).sorted(GroupModel.COMPARE_BY_NAME).distinct();
|
}).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
|
@Override
|
||||||
public GroupModel createGroup(RealmModel realm, String id, String name, GroupModel toParent) {
|
public GroupModel createGroup(RealmModel realm, String id, String name, GroupModel toParent) {
|
||||||
LOG.tracef("createGroup(%s, %s, %s, %s)%s", realm, id, name, toParent, getShortStackTrace());
|
LOG.tracef("createGroup(%s, %s, %s, %s)%s", realm, id, name, toParent, getShortStackTrace());
|
||||||
|
|
|
@ -397,6 +397,11 @@ public class MapRealmProvider implements RealmProvider {
|
||||||
return session.groups().searchForGroupByNameStream(realm, search, firstResult, maxResults);
|
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
|
@Override
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public RoleModel addRealmRole(RealmModel realm, String id, String name) {
|
public RoleModel addRealmRole(RealmModel realm, String id, String name) {
|
||||||
|
|
|
@ -123,6 +123,7 @@ public class MapFieldPredicates {
|
||||||
put(GROUP_PREDICATES, GroupModel.SearchableFields.NAME, MapGroupEntity::getName);
|
put(GROUP_PREDICATES, GroupModel.SearchableFields.NAME, MapGroupEntity::getName);
|
||||||
put(GROUP_PREDICATES, GroupModel.SearchableFields.PARENT_ID, MapGroupEntity::getParentId);
|
put(GROUP_PREDICATES, GroupModel.SearchableFields.PARENT_ID, MapGroupEntity::getParentId);
|
||||||
put(GROUP_PREDICATES, GroupModel.SearchableFields.ASSIGNED_ROLE, MapFieldPredicates::checkGrantedGroupRole);
|
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.REALM_ID, MapRoleEntity::getRealmId);
|
||||||
put(ROLE_PREDICATES, RoleModel.SearchableFields.CLIENT_ID, MapRoleEntity::getClientId);
|
put(ROLE_PREDICATES, RoleModel.SearchableFields.CLIENT_ID, MapRoleEntity::getClientId);
|
||||||
|
@ -370,6 +371,28 @@ public class MapFieldPredicates {
|
||||||
return mcb.fieldCompare(Boolean.TRUE::equals, getter);
|
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) {
|
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);
|
String roleIdS = ensureEqSingleValue(RoleModel.SearchableFields.COMPOSITE_ROLE, "composite_role_id", op, values);
|
||||||
Function<MapRoleEntity, ?> getter;
|
Function<MapRoleEntity, ?> getter;
|
||||||
|
|
|
@ -45,6 +45,7 @@ import org.keycloak.representations.idm.*;
|
||||||
import org.keycloak.representations.idm.authorization.*;
|
import org.keycloak.representations.idm.authorization.*;
|
||||||
import org.keycloak.storage.StorageId;
|
import org.keycloak.storage.StorageId;
|
||||||
import org.keycloak.util.JsonSerialization;
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
import org.keycloak.utils.StreamsUtil;
|
||||||
import org.keycloak.utils.StringUtil;
|
import org.keycloak.utils.StringUtil;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -158,6 +159,24 @@ public class ModelToRepresentation {
|
||||||
return rep;
|
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) {
|
public static Stream<GroupRepresentation> searchForGroupByName(RealmModel realm, boolean full, String search, Integer first, Integer max) {
|
||||||
return realm.searchForGroupByNameStream(search, first, max)
|
return realm.searchForGroupByNameStream(search, first, max)
|
||||||
.map(g -> toGroupHierarchy(g, full, search));
|
.map(g -> toGroupHierarchy(g, full, search));
|
||||||
|
@ -189,7 +208,7 @@ public class ModelToRepresentation {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full) {
|
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) {
|
public static GroupRepresentation toGroupHierarchy(GroupModel group, boolean full, String search) {
|
||||||
|
@ -201,6 +220,14 @@ public class ModelToRepresentation {
|
||||||
return rep;
|
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) {
|
private static boolean groupMatchesSearchOrIsPathElement(GroupModel group, String search) {
|
||||||
if (StringUtil.isBlank(search)) {
|
if (StringUtil.isBlank(search)) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -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
|
* 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);
|
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 {
|
interface GroupRemovedEvent extends ProviderEvent {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import org.keycloak.models.GroupModel;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
@ -64,5 +65,15 @@ public interface GroupLookupProvider {
|
||||||
*/
|
*/
|
||||||
Stream<GroupModel> searchForGroupByNameStream(RealmModel realm, String search, Integer firstResult, Integer maxResults);
|
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import org.keycloak.models.utils.ModelToRepresentation;
|
||||||
import org.keycloak.representations.idm.GroupRepresentation;
|
import org.keycloak.representations.idm.GroupRepresentation;
|
||||||
import org.keycloak.services.ErrorResponse;
|
import org.keycloak.services.ErrorResponse;
|
||||||
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
|
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
|
||||||
|
import org.keycloak.utils.SearchQueryUtils;
|
||||||
|
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
import javax.ws.rs.DefaultValue;
|
import javax.ws.rs.DefaultValue;
|
||||||
|
@ -75,12 +76,16 @@ public class GroupsResource {
|
||||||
@NoCache
|
@NoCache
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public Stream<GroupRepresentation> getGroups(@QueryParam("search") String search,
|
public Stream<GroupRepresentation> getGroups(@QueryParam("search") String search,
|
||||||
|
@QueryParam("q") String searchQuery,
|
||||||
@QueryParam("first") Integer firstResult,
|
@QueryParam("first") Integer firstResult,
|
||||||
@QueryParam("max") Integer maxResults,
|
@QueryParam("max") Integer maxResults,
|
||||||
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) {
|
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) {
|
||||||
auth.groups().requireList();
|
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);
|
return ModelToRepresentation.searchForGroupByName(realm, !briefRepresentation, search.trim(), firstResult, maxResults);
|
||||||
} else if(Objects.nonNull(firstResult) && Objects.nonNull(maxResults)) {
|
} else if(Objects.nonNull(firstResult) && Objects.nonNull(maxResults)) {
|
||||||
return ModelToRepresentation.toGroupHierarchy(realm, !briefRepresentation, firstResult, maxResults);
|
return ModelToRepresentation.toGroupHierarchy(realm, !briefRepresentation, firstResult, maxResults);
|
||||||
|
|
|
@ -67,6 +67,18 @@ public class HardcodedGroupStorageProvider implements GroupStorageProvider {
|
||||||
return Stream.empty();
|
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 {
|
public class HardcodedGroupAdapter implements GroupModel.Streams {
|
||||||
|
|
||||||
|
|
|
@ -17,18 +17,25 @@
|
||||||
|
|
||||||
package org.keycloak.testsuite.admin.group;
|
package org.keycloak.testsuite.admin.group;
|
||||||
|
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.keycloak.OAuth2Constants;
|
import org.keycloak.OAuth2Constants;
|
||||||
import org.keycloak.RSATokenVerifier;
|
import org.keycloak.RSATokenVerifier;
|
||||||
|
import org.keycloak.admin.client.resource.RealmResource;
|
||||||
import org.keycloak.common.util.PemUtils;
|
import org.keycloak.common.util.PemUtils;
|
||||||
import org.keycloak.events.Details;
|
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.jose.jws.JWSInput;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.representations.RefreshToken;
|
import org.keycloak.representations.RefreshToken;
|
||||||
|
import org.keycloak.representations.idm.GroupRepresentation;
|
||||||
import org.keycloak.representations.idm.RealmRepresentation;
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
|
import org.keycloak.representations.idm.RoleRepresentation;
|
||||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||||
import org.keycloak.testsuite.AssertEvents;
|
import org.keycloak.testsuite.AssertEvents;
|
||||||
import org.keycloak.testsuite.admin.ApiUtil;
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
|
import org.keycloak.testsuite.util.AdminEventPaths;
|
||||||
import org.keycloak.testsuite.util.AssertAdminEvents;
|
import org.keycloak.testsuite.util.AssertAdminEvents;
|
||||||
import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse;
|
import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse;
|
||||||
|
|
||||||
|
@ -91,4 +98,32 @@ public abstract class AbstractGroupTest extends AbstractKeycloakTest {
|
||||||
testRealms.add(result);
|
testRealms.add(result);
|
||||||
return 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -157,27 +157,6 @@ public class GroupTest extends AbstractGroupTest {
|
||||||
assertAdminEvents.assertEvent(testRealmId, OperationType.DELETE, AdminEventPaths.clientResourcePath(clientUuid), ResourceType.CLIENT);
|
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
|
@Test
|
||||||
public void doNotAllowSameGroupNameAtSameLevel() throws Exception {
|
public void doNotAllowSameGroupNameAtSameLevel() throws Exception {
|
||||||
RealmResource realm = adminClient.realms().realm("test");
|
RealmResource realm = adminClient.realms().realm("test");
|
||||||
|
@ -245,13 +224,6 @@ public class GroupTest extends AbstractGroupTest {
|
||||||
assertEquals("HTTP 409 Conflict", e.getMessage());
|
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
|
@Test
|
||||||
public void allowSameGroupNameAtDifferentLevel() throws Exception {
|
public void allowSameGroupNameAtDifferentLevel() throws Exception {
|
||||||
|
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -60,6 +60,7 @@ public class TestsuiteCLI {
|
||||||
UserCommands.Remove.class,
|
UserCommands.Remove.class,
|
||||||
UserCommands.Count.class,
|
UserCommands.Count.class,
|
||||||
UserCommands.GetUser.class,
|
UserCommands.GetUser.class,
|
||||||
|
GroupCommands.Create.class,
|
||||||
SyncDummyFederationProviderCommand.class,
|
SyncDummyFederationProviderCommand.class,
|
||||||
RoleCommands.CreateRoles.class,
|
RoleCommands.CreateRoles.class,
|
||||||
CacheCommands.ListCachesCommand.class,
|
CacheCommands.ListCachesCommand.class,
|
||||||
|
|
Loading…
Reference in a new issue