From 62e6883524885ef56b4675387b10d8db2534273b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Muzik=C3=A1=C5=99?= Date: Thu, 11 Mar 2021 17:22:44 +0100 Subject: [PATCH] KEYCLOAK-17084 KEYCLOAK-17434 Support querying clients by client attributes --- .../client/resource/ClientsResource.java | 4 + .../models/cache/infinispan/RealmAdapter.java | 5 + .../cache/infinispan/RealmCacheSession.java | 5 + .../models/jpa/JpaClientProviderFactory.java | 19 +- .../jpa/JpaClientScopeProviderFactory.java | 2 +- .../models/jpa/JpaGroupProviderFactory.java | 2 +- .../keycloak/models/jpa/JpaRealmProvider.java | 47 ++++- .../models/jpa/JpaRealmProviderFactory.java | 2 +- .../models/jpa/JpaRoleProviderFactory.java | 2 +- .../jpa/JpaServerInfoProviderFactory.java | 2 +- .../org/keycloak/models/jpa/RealmAdapter.java | 5 + .../models/map/client/MapClientProvider.java | 15 ++ .../models/map/realm/MapRealmAdapter.java | 5 + .../models/map/realm/MapRealmProvider.java | 6 + .../map/storage/MapFieldPredicates.java | 19 +- .../java/org/keycloak/models/ClientModel.java | 6 + .../java/org/keycloak/models/RealmModel.java | 4 +- .../storage/client/ClientLookupProvider.java | 2 + .../resources/admin/ClientsResource.java | 13 +- .../storage/ClientStorageManager.java | 64 ++++-- .../OpenshiftClientStorageProvider.java | 7 + .../org/keycloak/utils/SearchQueryUtils.java | 54 +++++ .../keycloak/utils/SearchQueryUtilsTest.java | 78 +++++++ .../HardcodedClientStorageProvider.java | 5 + .../admin/client/ClientSearchTest.java | 199 ++++++++++++++++++ .../federation/storage/ClientStorageTest.java | 18 ++ 26 files changed, 566 insertions(+), 24 deletions(-) create mode 100644 services/src/main/java/org/keycloak/utils/SearchQueryUtils.java create mode 100644 services/src/test/java/org/keycloak/utils/SearchQueryUtilsTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ClientSearchTest.java diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientsResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientsResource.java index 4e65111f3b..8cec12eef5 100755 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientsResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientsResource.java @@ -62,4 +62,8 @@ public interface ClientsResource { @Produces(MediaType.APPLICATION_JSON) List findByClientId(@QueryParam("clientId") String clientId); + @GET + @Produces(MediaType.APPLICATION_JSON) + List query(@QueryParam("q") String searchQuery); + } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index b5f53821b4..07632a881d 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -819,6 +819,11 @@ public class RealmAdapter implements CachedRealmModel { return cacheSession.searchClientsByClientIdStream(this, clientId, firstResult, maxResults); } + @Override + public Stream searchClientByAttributes(Map attributes, Integer firstResult, Integer maxResults) { + return cacheSession.searchClientsByAttributes(this, attributes, firstResult, maxResults); + } + @Override public Stream getClientsStream(Integer firstResult, Integer maxResults) { return cacheSession.getClientsStream(this, firstResult, maxResults); 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 583d238d98..48a3cd5aaf 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 @@ -1161,6 +1161,11 @@ public class RealmCacheSession implements CacheRealmProvider { return getClientDelegate().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults); } + @Override + public Stream searchClientsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + return getClientDelegate().searchClientsByAttributes(realm, attributes, firstResult, maxResults); + } + @Override public ClientModel getClientByClientId(RealmModel realm, String clientId) { String cacheKey = getClientByClientIdCacheKey(clientId, realm.getId()); 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 775bea54dd..f6967aa9d0 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 @@ -25,13 +25,30 @@ 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 JpaClientProviderFactory implements ClientProviderFactory { + private Set clientSearchableAttributes = null; + @Override public void init(Config.Scope config) { + String[] searchableAttrsArr = config.getArray("searchableAttributes"); + if (searchableAttrsArr == null) { + String s = System.getProperty("keycloak.client.searchableAttributes"); + searchableAttrsArr = s == null ? null : s.split("\\s*,\\s*"); + } + if (searchableAttrsArr != null) { + clientSearchableAttributes = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(searchableAttrsArr))); + } + else { + clientSearchableAttributes = Collections.emptySet(); + } } @Override @@ -47,7 +64,7 @@ public class JpaClientProviderFactory implements ClientProviderFactory { @Override public ClientProvider create(KeycloakSession session) { EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); - return new JpaRealmProvider(session, em); + return new JpaRealmProvider(session, em, clientSearchableAttributes); } @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 9c4472319c..eb05f9d2d7 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); + return new JpaRealmProvider(session, em, 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 12decf4385..6ec356aa7d 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 @@ -47,7 +47,7 @@ public class JpaGroupProviderFactory implements GroupProviderFactory { @Override public GroupProvider create(KeycloakSession session) { EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); - return new JpaRealmProvider(session, em); + return new JpaRealmProvider(session, em, null); } @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 7e4c9d93e7..a6470a2b14 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 @@ -21,6 +21,7 @@ import static org.keycloak.common.util.StackUtil.getShortStackTrace; import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; import static org.keycloak.utils.StreamsUtil.closing; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,7 +35,11 @@ import javax.persistence.LockModeType; import javax.persistence.TypedQuery; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaDelete; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Join; +import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; + import org.jboss.logging.Logger; import org.keycloak.common.util.Time; import org.keycloak.connections.jpa.util.JpaUtils; @@ -55,6 +60,7 @@ import org.keycloak.models.RoleContainerModel.RoleRemovedEvent; import org.keycloak.models.RoleModel; import org.keycloak.models.RoleProvider; import org.keycloak.models.ServerInfoProvider; +import org.keycloak.models.jpa.entities.ClientAttributeEntity; import org.keycloak.models.jpa.entities.ClientEntity; import org.keycloak.models.jpa.entities.ClientInitialAccessEntity; import org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity; @@ -74,10 +80,12 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc protected static final Logger logger = Logger.getLogger(JpaRealmProvider.class); private final KeycloakSession session; protected EntityManager em; + private Set clientSearchableAttributes; - public JpaRealmProvider(KeycloakSession session, EntityManager em) { + public JpaRealmProvider(KeycloakSession session, EntityManager em, Set clientSearchableAttributes) { this.session = session; this.em = em; + this.clientSearchableAttributes = clientSearchableAttributes; } @Override @@ -685,6 +693,39 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc return closing(results.map(c -> session.clients().getClientById(realm, c))); } + @Override + public Stream searchClientsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + Map filteredAttributes = clientSearchableAttributes == null ? attributes : + attributes.entrySet().stream().filter(m -> clientSearchableAttributes.contains(m.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery queryBuilder = builder.createQuery(ClientEntity.class); + Root root = queryBuilder.from(ClientEntity.class); + + List predicates = new ArrayList<>(); + + predicates.add(builder.equal(root.get("realmId"), realm.getId())); + + for (Map.Entry entry : filteredAttributes.entrySet()) { + String key = entry.getKey(); + 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("clientId"))); + + TypedQuery query = em.createQuery(queryBuilder); + return closing(paginateQuery(query, firstResult, maxResults).getResultStream()) + .map(c -> session.clients().getClientById(realm, c.getId())); + } + @Override public void removeClients(RealmModel realm) { TypedQuery query = em.createNamedQuery("getClientIdsByRealm", String.class); @@ -963,4 +1004,8 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc return false; } } + + public Set getClientSearchableAttributes() { + return clientSearchableAttributes; + } } 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 ed8caee925..5afe3469cf 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 @@ -61,7 +61,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); + return new JpaRealmProvider(session, em, 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 a739ce8d3f..eb8f760f33 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); + return new JpaRealmProvider(session, em, null); } @Override diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaServerInfoProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaServerInfoProviderFactory.java index 53e1e03302..8c61cfdb32 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaServerInfoProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaServerInfoProviderFactory.java @@ -45,7 +45,7 @@ public class JpaServerInfoProviderFactory implements ServerInfoProviderFactory { @Override public ServerInfoProvider create(KeycloakSession session) { EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); - return new JpaRealmProvider(session, em); + return new JpaRealmProvider(session, em, null); } @Override diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index 1425441056..aa1538d08a 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -783,6 +783,11 @@ public class RealmAdapter implements RealmModel, JpaModel { return session.clients().searchClientsByClientIdStream(this, clientId, firstResult, maxResults); } + @Override + public Stream searchClientByAttributes(Map attributes, Integer firstResult, Integer maxResults) { + return session.clients().searchClientsByAttributes(this, attributes, firstResult, maxResults); + } + private static final String BROWSER_HEADER_PREFIX = "_browser_header."; @Override diff --git a/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java b/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java index 403d96064b..f74f261691 100644 --- a/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java @@ -270,6 +270,21 @@ public class MapClientProvider implements ClientProvider { return paginatedStream(s, firstResult, maxResults).map(entityToAdapterFunc(realm)); } + @Override + public Stream searchClientsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + ModelCriteriaBuilder mcb = clientStore.createCriteriaBuilder() + .compare(SearchableFields.REALM_ID, Operator.EQ, realm.getId()); + + for (Map.Entry entry : attributes.entrySet()) { + mcb = mcb.compare(SearchableFields.ATTRIBUTE, Operator.EQ, entry.getKey(), entry.getValue()); + } + + Stream> s = tx.getUpdatedNotRemoved(mcb) + .sorted(COMPARE_BY_CLIENT_ID); + + return paginatedStream(s, firstResult, maxResults).map(entityToAdapterFunc(realm)); + } + @Override public void addClientScopes(RealmModel realm, ClientModel client, Set clientScopes, boolean defaultScope) { final String id = client.getId(); diff --git a/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmAdapter.java b/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmAdapter.java index dfa28b0347..7dfa00047b 100644 --- a/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmAdapter.java +++ b/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmAdapter.java @@ -565,6 +565,11 @@ public abstract class MapRealmAdapter extends AbstractRealmModel searchClientByAttributes(Map attributes, Integer firstResult, Integer maxResults) { + return session.clients().searchClientsByAttributes(this, attributes, firstResult, maxResults); + } + @Override public Map getSmtpConfig() { return Collections.unmodifiableMap(entity.getSmtpConfig()); 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 9312040a86..dadf23bb3e 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 @@ -286,6 +286,12 @@ public class MapRealmProvider implements RealmProvider { return session.clients().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults); } + @Override + @Deprecated + public Stream searchClientsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + return session.clients().searchClientsByAttributes(realm, attributes, firstResult, maxResults); + } + @Override @Deprecated public void addClientScopes(RealmModel realm, ClientModel client, Set clientScopes, boolean defaultScope) { diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/MapFieldPredicates.java b/model/map/src/main/java/org/keycloak/models/map/storage/MapFieldPredicates.java index 60c8bf602d..e9d6b26884 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/MapFieldPredicates.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/MapFieldPredicates.java @@ -96,6 +96,7 @@ public class MapFieldPredicates { put(CLIENT_PREDICATES, ClientModel.SearchableFields.REALM_ID, MapClientEntity::getRealmId); put(CLIENT_PREDICATES, ClientModel.SearchableFields.CLIENT_ID, MapClientEntity::getClientId); put(CLIENT_PREDICATES, ClientModel.SearchableFields.SCOPE_MAPPING_ROLE, MapFieldPredicates::checkScopeMappingRole); + put(CLIENT_PREDICATES, ClientModel.SearchableFields.ATTRIBUTE, MapFieldPredicates::checkClientAttributes); put(CLIENT_SCOPE_PREDICATES, ClientScopeModel.SearchableFields.REALM_ID, MapClientScopeEntity::getRealmId); put(CLIENT_SCOPE_PREDICATES, ClientScopeModel.SearchableFields.NAME, MapClientScopeEntity::getName); @@ -214,7 +215,7 @@ public class MapFieldPredicates { SearchableModelField field, UpdatePredicatesFunc function) { map.put(field, function); } - + private static > Function predicateForKeyField(Function extractor) { return entity -> { Object o = extractor.apply(entity); @@ -287,6 +288,22 @@ public class MapFieldPredicates { return mcb.fieldCompare(Boolean.TRUE::equals, getter); } + private static MapModelCriteriaBuilder, ClientModel> checkClientAttributes(MapModelCriteriaBuilder, ClientModel> mcb, Operator op, Object[] values) { + if (values == null || values.length != 2) { + throw new CriterionNotSupportedException(ClientModel.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(ClientModel.SearchableFields.ATTRIBUTE, op, "Invalid arguments, expected (String attribute_name), got: " + Arrays.toString(values)); + } + String attrNameS = (String) attrName; + Function, ?> getter = ue -> ue.getAttribute(attrNameS); + Object[] realValue = {values[1]}; + + return mcb.fieldCompare(op, getter, realValue); + } + private static MapModelCriteriaBuilder, UserModel> checkGrantedUserRole(MapModelCriteriaBuilder, UserModel> mcb, Operator op, Object[] values) { String roleIdS = ensureEqSingleValue(UserModel.SearchableFields.ASSIGNED_ROLE, "role_id", op, values); Function, ?> getter; diff --git a/server-spi/src/main/java/org/keycloak/models/ClientModel.java b/server-spi/src/main/java/org/keycloak/models/ClientModel.java index eeb1b83c6a..0c39b8ba99 100755 --- a/server-spi/src/main/java/org/keycloak/models/ClientModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientModel.java @@ -42,6 +42,12 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot public static final SearchableModelField REALM_ID = new SearchableModelField<>("realmId", String.class); public static final SearchableModelField CLIENT_ID = new SearchableModelField<>("clientId", String.class); public static final SearchableModelField SCOPE_MAPPING_ROLE = new SearchableModelField<>("scopeMappingRole", 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 ClientCreationEvent extends ProviderEvent { diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java index 5148ad6d53..c2c0e0d024 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -411,7 +411,9 @@ public interface RealmModel extends RoleContainerModel { * @return Stream of {@link ClientModel}. Never returns {@code null}. */ Stream searchClientByClientIdStream(String clientId, Integer firstResult, Integer maxResults); - + + Stream searchClientByAttributes(Map attributes, Integer firstResult, Integer maxResults); + void updateRequiredCredentials(Set creds); Map getBrowserSecurityHeaders(); diff --git a/server-spi/src/main/java/org/keycloak/storage/client/ClientLookupProvider.java b/server-spi/src/main/java/org/keycloak/storage/client/ClientLookupProvider.java index ca66bbd1a9..bf4819237e 100644 --- a/server-spi/src/main/java/org/keycloak/storage/client/ClientLookupProvider.java +++ b/server-spi/src/main/java/org/keycloak/storage/client/ClientLookupProvider.java @@ -95,6 +95,8 @@ public interface ClientLookupProvider { */ Stream searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults); + Stream searchClientsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults); + /** * Return all default scopes (if {@code defaultScope} is {@code true}) or all optional scopes (if {@code defaultScope} is {@code false}) linked with the client * diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientsResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientsResource.java index 6a8a0c3173..1eec7ee52f 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientsResource.java @@ -40,6 +40,7 @@ import org.keycloak.services.clientpolicy.context.AdminClientRegisteredContext; import org.keycloak.services.managers.ClientManager; import org.keycloak.services.managers.RealmManager; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; +import org.keycloak.utils.SearchQueryUtils; import org.keycloak.validation.ValidationUtil; import javax.ws.rs.Consumes; @@ -54,6 +55,7 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.util.Map; import java.util.Objects; import java.util.stream.Stream; @@ -100,16 +102,23 @@ public class ClientsResource { public Stream getClients(@QueryParam("clientId") String clientId, @QueryParam("viewableOnly") @DefaultValue("false") boolean viewableOnly, @QueryParam("search") @DefaultValue("false") boolean search, + @QueryParam("q") String searchQuery, @QueryParam("first") Integer firstResult, @QueryParam("max") Integer maxResults) { boolean canView = auth.clients().canView(); Stream clientModels = Stream.empty(); - if (clientId == null || clientId.trim().equals("")) { + if (searchQuery != null) { + auth.clients().requireList(); + Map attributes = SearchQueryUtils.getFields(searchQuery); + clientModels = canView + ? realm.searchClientByAttributes(attributes, firstResult, maxResults) + : realm.searchClientByAttributes(attributes, -1, -1); + } else if (clientId == null || clientId.trim().equals("")) { + auth.clients().requireList(); clientModels = canView ? realm.getClientsStream(firstResult, maxResults) : realm.getClientsStream(); - auth.clients().requireList(); } else if (search) { clientModels = canView ? realm.searchClientByClientIdStream(clientId, firstResult, maxResults) diff --git a/services/src/main/java/org/keycloak/storage/ClientStorageManager.java b/services/src/main/java/org/keycloak/storage/ClientStorageManager.java index d6c258cea3..94a4b79d1b 100644 --- a/services/src/main/java/org/keycloak/storage/ClientStorageManager.java +++ b/services/src/main/java/org/keycloak/storage/ClientStorageManager.java @@ -32,10 +32,14 @@ import org.keycloak.storage.client.ClientStorageProviderModel; import org.keycloak.utils.ServicesUtils; import java.util.Objects; +import java.util.function.Function; import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.keycloak.models.ClientScopeModel; +import static org.keycloak.utils.StreamsUtil.paginatedStream; + /** * @author Bill Burke * @version $Revision: 1 $ @@ -114,6 +118,10 @@ public class ClientStorageManager implements ClientProvider { .map(model -> type.cast(getStorageProviderInstance(session, model, getClientStorageProviderFactory(model, session)))); } + public static boolean hasEnabledStorageProviders(KeycloakSession session, RealmModel realm, Class type) { + return getStorageProviders(realm, session, type).anyMatch(ClientStorageProviderModel::isEnabled); + } + public ClientStorageManager(KeycloakSession session, long clientStorageProviderTimeout) { this.session = session; @@ -145,22 +153,52 @@ public class ClientStorageManager implements ClientProvider { .orElse(null); } - /** - * Obtaining clients from an external client storage is time-bounded. In case the external client storage - * isn't available at least clients from a local storage are returned. For this purpose - * the {@link org.keycloak.services.DefaultKeycloakSessionFactory#getClientStorageProviderTimeout()} property is used. - * Default value is 3000 milliseconds and it's configurable. - * See {@link org.keycloak.services.DefaultKeycloakSessionFactory} for details. - */ @Override public Stream searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults) { - Stream local = session.clientLocalStorage().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults); - Stream ext = getEnabledStorageProviders(session, realm, ClientLookupProvider.class) - .flatMap(ServicesUtils.timeBound(session, - clientStorageProviderTimeout, - p -> ((ClientLookupProvider) p).searchClientsByClientIdStream(realm, clientId, firstResult, maxResults))); + return query((p, f, m) -> p.searchClientsByClientIdStream(realm, clientId, f, m), realm, firstResult, maxResults); + } - return Stream.concat(local, ext); + @Override + public Stream searchClientsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + return query((p, f, m) -> p.searchClientsByAttributes(realm, attributes, f, m), realm, firstResult, maxResults); + } + + @FunctionalInterface + interface PaginatedQuery { + Stream query(ClientLookupProvider provider, Integer firstResult, Integer maxResults); + } + + protected Stream query(PaginatedQuery paginatedQuery, RealmModel realm, Integer firstResult, Integer maxResults) { + if (maxResults != null && maxResults == 0) return Stream.empty(); + + // when there are external providers involved, we can't do pagination at the lower data layer as we don't know + // how many results there will be; i.e. we need to query the clients without paginating them and perform pagination + // later at this level + if (hasEnabledStorageProviders(session, realm, ClientLookupProvider.class)) { + Stream providersStream = Stream.concat(Stream.of(session.clientLocalStorage()), getEnabledStorageProviders(session, realm, ClientLookupProvider.class)); + + /* + Obtaining clients from an external client storage is time-bounded. In case the external client storage + isn't available at least clients from a local storage are returned, otherwise both storages are used. For this purpose + the {@link org.keycloak.services.DefaultKeycloakSessionFactory#getClientStorageProviderTimeout()} property is used. + Default value is 3000 milliseconds and it's configurable. + See {@link org.keycloak.services.DefaultKeycloakSessionFactory} for details. + */ + Function> performQueryWithTimeBound = (p) -> { + if (p instanceof ClientStorageProvider) { + return ServicesUtils.timeBound(session, clientStorageProviderTimeout, p2 -> paginatedQuery.query((ClientLookupProvider) p2, null, null)).apply(p); + } + else { + return paginatedQuery.query(p, null, null); + } + }; + + Stream res = providersStream.flatMap(performQueryWithTimeBound); + return paginatedStream(res, firstResult, maxResults); + } + else { + return paginatedQuery.query(session.clientLocalStorage(), firstResult, maxResults); + } } @Override diff --git a/services/src/main/java/org/keycloak/storage/openshift/OpenshiftClientStorageProvider.java b/services/src/main/java/org/keycloak/storage/openshift/OpenshiftClientStorageProvider.java index ac93a033d1..b1d406eca9 100644 --- a/services/src/main/java/org/keycloak/storage/openshift/OpenshiftClientStorageProvider.java +++ b/services/src/main/java/org/keycloak/storage/openshift/OpenshiftClientStorageProvider.java @@ -29,6 +29,7 @@ import org.keycloak.storage.StorageId; import org.keycloak.storage.client.ClientStorageProvider; import org.keycloak.storage.client.ClientStorageProviderModel; +import java.util.Map; import java.util.regex.Matcher; import java.util.stream.Stream; @@ -83,6 +84,12 @@ public class OpenshiftClientStorageProvider implements ClientStorageProvider { return Stream.of(getClientByClientId(realm, clientId)); } + @Override + public Stream searchClientsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + // TODO not sure if we support searching clients for this provider + return Stream.empty(); + } + @Override public void close() { diff --git a/services/src/main/java/org/keycloak/utils/SearchQueryUtils.java b/services/src/main/java/org/keycloak/utils/SearchQueryUtils.java new file mode 100644 index 0000000000..d6c8534a9f --- /dev/null +++ b/services/src/main/java/org/keycloak/utils/SearchQueryUtils.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021 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. + */ + +package org.keycloak.utils; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @author Vaclav Muzikar + */ +public class SearchQueryUtils { + public static final Pattern queryPattern = Pattern.compile("\\s*(?:(?[^\"][^: ]+)|\"(?(?:\\\\.|[^\\\\\"])+)\"):(?:(?[^\"][^ ]*)|\"(?(?:\\\\.|[^\\\\\"])+)\")\\s*"); + public static final Pattern escapedCharsPattern = Pattern.compile("\\\\(.)"); + + public static Map getFields(final String query) { + Matcher matcher = queryPattern.matcher(query); + Map ret = new HashMap<>(); + while (matcher.find()) { + String name = matcher.group("name"); + if (name == null) { + name = unescape(matcher.group("nameEsc")); + } + + String value = matcher.group("value"); + if (value == null) { + value = unescape(matcher.group("valueEsc")); + } + + ret.put(name, value); + } + return ret; + } + + public static String unescape(final String escaped) { + return escapedCharsPattern.matcher(escaped).replaceAll("$1"); + } +} diff --git a/services/src/test/java/org/keycloak/utils/SearchQueryUtilsTest.java b/services/src/test/java/org/keycloak/utils/SearchQueryUtilsTest.java new file mode 100644 index 0000000000..d745324c89 --- /dev/null +++ b/services/src/test/java/org/keycloak/utils/SearchQueryUtilsTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2021 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. + */ + +package org.keycloak.utils; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +/** + * @author Vaclav Muzikar + */ +public class SearchQueryUtilsTest { + @Test + public void testGetFields() { + testParseQuery(" key1:val1 nokey key2:\"val 2\" key3:val3 ", + "key1", "val1", + "key2", "val 2", + "key3", "val3"); + + testParseQuery(" key1:val1 ", + "key1", "val1"); + + testParseQuery(" key1:\"val1\" ", + "key1", "val1"); + + testParseQuery("key1:val=\"123456\"", + "key1", "val=\"123456\""); + + testParseQuery("key1:\"val=\\\"12 34 56\\\"\"", + "key1", "val=\"12 34 56\""); + + testParseQuery(" \"key 1\":val1", + "key 1", "val1"); + + testParseQuery("\"key \\\"1\\\"\":val1", + "key \"1\"", "val1"); + + testParseQuery("\"key \\\"1\\\"\":\"val \\\"1\\\"\"", + "key \"1\"", "val \"1\""); + + testParseQuery("key\"1\":val1", + "key\"1\"", "val1"); + } + + private void testParseQuery(String query, String... expectedStr) { + Map expected = new HashMap<>(); + if (expectedStr != null) { + if (expectedStr.length % 2 != 0) { + throw new IllegalArgumentException("Expected must be key-value pairs"); + } + for (int i = 0; i < expectedStr.length; i=i+2) { + expected.put(expectedStr[i], expectedStr[i+1]); + } + } + + Map actual = SearchQueryUtils.getFields(query); + + assertEquals(expected, actual); + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedClientStorageProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedClientStorageProvider.java index a90d4f32fc..723c38970b 100755 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedClientStorageProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedClientStorageProvider.java @@ -92,6 +92,11 @@ public class HardcodedClientStorageProvider implements ClientStorageProvider, Cl return Stream.empty(); } + @Override + public Stream searchClientsByAttributes(RealmModel realm, Map attributes, Integer firstResult, Integer maxResults) { + return Stream.empty(); + } + @Override public Map getClientScopes(RealmModel realm, ClientModel client, boolean defaultScope) { if (defaultScope) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ClientSearchTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ClientSearchTest.java new file mode 100644 index 0000000000..a2f8fab6c7 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ClientSearchTest.java @@ -0,0 +1,199 @@ +/* + * Copyright 2021 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. + */ + +package org.keycloak.testsuite.admin.client; + +import org.apache.commons.lang3.ArrayUtils; +import org.jboss.arquillian.container.test.api.ContainerController; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.models.ClientProvider; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.wildfly.extras.creaper.core.online.OnlineManagementClient; +import org.wildfly.extras.creaper.core.online.operations.admin.Administration; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertThat; +import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.QUARKUS; +import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; + +/** + * @author Vaclav Muzikar + */ +@AuthServerContainerExclude({REMOTE, QUARKUS}) +public class ClientSearchTest extends AbstractClientTest { + @ArquillianResource + protected ContainerController controller; + + private static final String CLIENT1 = "client1"; + private static final String CLIENT2 = "client2"; + private static final String CLIENT3 = "client3"; + + private String client1Id; + private String client2Id; + private String client3Id; + + private static final String ATTR_ORG_NAME = "org"; + private static final String ATTR_ORG_VAL = "Přísná_\"organizace\""; + private static final String ATTR_URL_NAME = "url"; + private static final String ATTR_URL_VAL = "https://foo.bar/clflds"; + 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 ATTR_FILTERED_NAME = "filtered"; + private static final String ATTR_FILTERED_VAL = "does_not_matter"; + + private static final String SEARCHABLE_ATTRS_PROP = "keycloak.client.searchableAttributes"; + + @Before + public void init() { + ClientRepresentation client1 = createOidcClientRep(CLIENT1); + ClientRepresentation client2 = createOidcClientRep(CLIENT2); + ClientRepresentation client3 = createOidcClientRep(CLIENT3); + + client1.setAttributes(new HashMap() {{ + put(ATTR_ORG_NAME, ATTR_ORG_VAL); + put(ATTR_URL_NAME, ATTR_URL_VAL); + }}); + + client2.setAttributes(new HashMap() {{ + put(ATTR_URL_NAME, ATTR_URL_VAL); + put(ATTR_FILTERED_NAME, ATTR_FILTERED_VAL); + }}); + + client3.setAttributes(new HashMap() {{ + put(ATTR_ORG_NAME, "fake val"); + put(ATTR_QUOTES_NAME, ATTR_QUOTES_VAL); + }}); + + client1Id = createClient(client1); + client2Id = createClient(client2); + client3Id = createClient(client3); + } + + @After + public void teardown() { + removeClient(client1Id); + removeClient(client2Id); + removeClient(client3Id); + } + + @Test + public void testQuerySearch() throws Exception { + try { + configureSearchableAttributes(ATTR_URL_NAME, ATTR_ORG_NAME, ATTR_QUOTES_NAME); + search(String.format("%s:%s", ATTR_ORG_NAME, ATTR_ORG_VAL), CLIENT1); + search(String.format("%s:%s", ATTR_URL_NAME, ATTR_URL_VAL), CLIENT1, CLIENT2); + search(String.format("%s:%s %s:%s", ATTR_ORG_NAME, ATTR_ORG_VAL, ATTR_URL_NAME, ATTR_URL_VAL), CLIENT1); + 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), CLIENT3); + + // "filtered" attribute won't take effect when JPA is used + String[] expectedRes = isJpaStore() ? new String[]{CLIENT1, CLIENT2} : new String[]{CLIENT2}; + 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 testJpaSearchableAttributesUnset() { + String[] expectedRes = {CLIENT1}; + // JPA store removes all attributes by default, i.e. returns all clients + if (isJpaStore()) { + expectedRes = ArrayUtils.addAll(expectedRes, CLIENT2, CLIENT3, "account", "account-console", "admin-cli", "broker", "realm-management", "security-admin-console"); + } + + search(String.format("%s:%s", ATTR_ORG_NAME, ATTR_ORG_VAL), expectedRes); + } + + private void search(String searchQuery, String... expectedClientIds) { + List found = testRealmResource().clients().query(searchQuery).stream() + .map(ClientRepresentation::getClientId) + .collect(Collectors.toList()); + assertThat(found, containsInAnyOrder(expectedClientIds)); + } + + 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=client:add()", + "/subsystem=keycloak-server/spi=client/provider=jpa/:add(properties={searchableAttributes => \"[" + s + "]\"},enabled=true)"); + } 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=client:remove"); + } 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 isJpaStore() { + String providerId = testingClient.server() + .fetchString(s -> s.getKeycloakSessionFactory().getProviderFactory(ClientProvider.class).getId()); + log.info("Detected store: " + providerId); + return "\"jpa\"".equals(providerId); // there are quotes for some reason + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/ClientStorageTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/ClientStorageTest.java index cae291e6c8..c4f74fae3f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/ClientStorageTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/ClientStorageTest.java @@ -151,6 +151,24 @@ public class ClientStorageTest extends AbstractTestRealmKeycloakTest { hasItem("root-url-client")) ); + // test the pagination; the clients from local storage (root-url-client) are fetched first + assertThat(session.clientStorageManager() + .searchClientsByClientIdStream(realm, "client", 0, 1) + .map(ClientModel::getClientId) + .collect(Collectors.toList()), + allOf( + not(hasItem(hardcodedClient)), + hasItem("root-url-client")) + ); + assertThat(session.clientStorageManager() + .searchClientsByClientIdStream(realm, "client", 1, 1) + .map(ClientModel::getClientId) + .collect(Collectors.toList()), + allOf( + hasItem(hardcodedClient), + not(hasItem("root-url-client"))) + ); + //update the provider to simulate delay during the search ComponentModel memoryProvider = realm.getComponent(providerId); memoryProvider.getConfig().putSingle(delayedSearch, Boolean.toString(true));