KEYCLOAK-17084 KEYCLOAK-17434 Support querying clients by client attributes
This commit is contained in:
parent
62e17f3be7
commit
62e6883524
26 changed files with 566 additions and 24 deletions
|
@ -62,4 +62,8 @@ public interface ClientsResource {
|
|||
@Produces(MediaType.APPLICATION_JSON)
|
||||
List<ClientRepresentation> findByClientId(@QueryParam("clientId") String clientId);
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
List<ClientRepresentation> query(@QueryParam("q") String searchQuery);
|
||||
|
||||
}
|
||||
|
|
|
@ -819,6 +819,11 @@ public class RealmAdapter implements CachedRealmModel {
|
|||
return cacheSession.searchClientsByClientIdStream(this, clientId, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientModel> searchClientByAttributes(Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
return cacheSession.searchClientsByAttributes(this, attributes, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientModel> getClientsStream(Integer firstResult, Integer maxResults) {
|
||||
return cacheSession.getClientsStream(this, firstResult, maxResults);
|
||||
|
|
|
@ -1161,6 +1161,11 @@ public class RealmCacheSession implements CacheRealmProvider {
|
|||
return getClientDelegate().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> 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());
|
||||
|
|
|
@ -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<String> 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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<String> clientSearchableAttributes;
|
||||
|
||||
public JpaRealmProvider(KeycloakSession session, EntityManager em) {
|
||||
public JpaRealmProvider(KeycloakSession session, EntityManager em, Set<String> 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<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
Map<String, String> 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<ClientEntity> queryBuilder = builder.createQuery(ClientEntity.class);
|
||||
Root<ClientEntity> root = queryBuilder.from(ClientEntity.class);
|
||||
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
predicates.add(builder.equal(root.get("realmId"), realm.getId()));
|
||||
|
||||
for (Map.Entry<String, String> entry : filteredAttributes.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String value = entry.getValue();
|
||||
|
||||
Join<ClientEntity, ClientAttributeEntity> 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<ClientEntity> 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<String> query = em.createNamedQuery("getClientIdsByRealm", String.class);
|
||||
|
@ -963,4 +1004,8 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Set<String> getClientSearchableAttributes() {
|
||||
return clientSearchableAttributes;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -783,6 +783,11 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
|
|||
return session.clients().searchClientsByClientIdStream(this, clientId, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientModel> searchClientByAttributes(Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
return session.clients().searchClientsByAttributes(this, attributes, firstResult, maxResults);
|
||||
}
|
||||
|
||||
private static final String BROWSER_HEADER_PREFIX = "_browser_header.";
|
||||
|
||||
@Override
|
||||
|
|
|
@ -270,6 +270,21 @@ public class MapClientProvider<K> implements ClientProvider {
|
|||
return paginatedStream(s, firstResult, maxResults).map(entityToAdapterFunc(realm));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
ModelCriteriaBuilder<ClientModel> mcb = clientStore.createCriteriaBuilder()
|
||||
.compare(SearchableFields.REALM_ID, Operator.EQ, realm.getId());
|
||||
|
||||
for (Map.Entry<String, String> entry : attributes.entrySet()) {
|
||||
mcb = mcb.compare(SearchableFields.ATTRIBUTE, Operator.EQ, entry.getKey(), entry.getValue());
|
||||
}
|
||||
|
||||
Stream<MapClientEntity<K>> 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<ClientScopeModel> clientScopes, boolean defaultScope) {
|
||||
final String id = client.getId();
|
||||
|
|
|
@ -565,6 +565,11 @@ public abstract class MapRealmAdapter<K> extends AbstractRealmModel<MapRealmEnti
|
|||
return session.clients().searchClientsByClientIdStream(this, clientId, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientModel> searchClientByAttributes(Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
return session.clients().searchClientsByAttributes(this, attributes, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getSmtpConfig() {
|
||||
return Collections.unmodifiableMap(entity.getSmtpConfig());
|
||||
|
|
|
@ -286,6 +286,12 @@ public class MapRealmProvider<K> implements RealmProvider {
|
|||
return session.clients().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated
|
||||
public Stream<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
return session.clients().searchClientsByAttributes(realm, attributes, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated
|
||||
public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) {
|
||||
|
|
|
@ -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<M> field, UpdatePredicatesFunc<K, V, M> function) {
|
||||
map.put(field, function);
|
||||
}
|
||||
|
||||
|
||||
private static <V extends AbstractEntity<?>> Function<V, Object> predicateForKeyField(Function<V, Object> 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<Object, MapClientEntity<Object>, ClientModel> checkClientAttributes(MapModelCriteriaBuilder<Object, MapClientEntity<Object>, 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<MapClientEntity<Object>, ?> getter = ue -> ue.getAttribute(attrNameS);
|
||||
Object[] realValue = {values[1]};
|
||||
|
||||
return mcb.fieldCompare(op, getter, realValue);
|
||||
}
|
||||
|
||||
private static MapModelCriteriaBuilder<Object, MapUserEntity<Object>, UserModel> checkGrantedUserRole(MapModelCriteriaBuilder<Object, MapUserEntity<Object>, UserModel> mcb, Operator op, Object[] values) {
|
||||
String roleIdS = ensureEqSingleValue(UserModel.SearchableFields.ASSIGNED_ROLE, "role_id", op, values);
|
||||
Function<MapUserEntity<Object>, ?> getter;
|
||||
|
|
|
@ -42,6 +42,12 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
|
|||
public static final SearchableModelField<ClientModel> REALM_ID = new SearchableModelField<>("realmId", String.class);
|
||||
public static final SearchableModelField<ClientModel> CLIENT_ID = new SearchableModelField<>("clientId", String.class);
|
||||
public static final SearchableModelField<ClientModel> 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<ClientModel> ATTRIBUTE = new SearchableModelField<>("attribute", String[].class);
|
||||
}
|
||||
|
||||
interface ClientCreationEvent extends ProviderEvent {
|
||||
|
|
|
@ -411,7 +411,9 @@ public interface RealmModel extends RoleContainerModel {
|
|||
* @return Stream of {@link ClientModel}. Never returns {@code null}.
|
||||
*/
|
||||
Stream<ClientModel> searchClientByClientIdStream(String clientId, Integer firstResult, Integer maxResults);
|
||||
|
||||
|
||||
Stream<ClientModel> searchClientByAttributes(Map<String, String> attributes, Integer firstResult, Integer maxResults);
|
||||
|
||||
void updateRequiredCredentials(Set<String> creds);
|
||||
|
||||
Map<String, String> getBrowserSecurityHeaders();
|
||||
|
|
|
@ -95,6 +95,8 @@ public interface ClientLookupProvider {
|
|||
*/
|
||||
Stream<ClientModel> searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults);
|
||||
|
||||
Stream<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> 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
|
||||
*
|
||||
|
|
|
@ -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<ClientRepresentation> 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<ClientModel> clientModels = Stream.empty();
|
||||
|
||||
if (clientId == null || clientId.trim().equals("")) {
|
||||
if (searchQuery != null) {
|
||||
auth.clients().requireList();
|
||||
Map<String, String> 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)
|
||||
|
|
|
@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
* @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<ClientModel> searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults) {
|
||||
Stream<ClientModel> local = session.clientLocalStorage().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults);
|
||||
Stream<ClientModel> 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<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
return query((p, f, m) -> p.searchClientsByAttributes(realm, attributes, f, m), realm, firstResult, maxResults);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface PaginatedQuery {
|
||||
Stream<ClientModel> query(ClientLookupProvider provider, Integer firstResult, Integer maxResults);
|
||||
}
|
||||
|
||||
protected Stream<ClientModel> 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<ClientLookupProvider> 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<ClientLookupProvider, Stream<? extends ClientModel>> 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<ClientModel> res = providersStream.flatMap(performQueryWithTimeBound);
|
||||
return paginatedStream(res, firstResult, maxResults);
|
||||
}
|
||||
else {
|
||||
return paginatedQuery.query(session.clientLocalStorage(), firstResult, maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -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<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
// TODO not sure if we support searching clients for this provider
|
||||
return Stream.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
|
|
|
@ -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 <vmuzikar@redhat.com>
|
||||
*/
|
||||
public class SearchQueryUtils {
|
||||
public static final Pattern queryPattern = Pattern.compile("\\s*(?:(?<name>[^\"][^: ]+)|\"(?<nameEsc>(?:\\\\.|[^\\\\\"])+)\"):(?:(?<value>[^\"][^ ]*)|\"(?<valueEsc>(?:\\\\.|[^\\\\\"])+)\")\\s*");
|
||||
public static final Pattern escapedCharsPattern = Pattern.compile("\\\\(.)");
|
||||
|
||||
public static Map<String, String> getFields(final String query) {
|
||||
Matcher matcher = queryPattern.matcher(query);
|
||||
Map<String, String> 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");
|
||||
}
|
||||
}
|
|
@ -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 <vmuzikar@redhat.com>
|
||||
*/
|
||||
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<String, String> 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<String, String> actual = SearchQueryUtils.getFields(query);
|
||||
|
||||
assertEquals(expected, actual);
|
||||
}
|
||||
}
|
|
@ -92,6 +92,11 @@ public class HardcodedClientStorageProvider implements ClientStorageProvider, Cl
|
|||
return Stream.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
return Stream.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, ClientScopeModel> getClientScopes(RealmModel realm, ClientModel client, boolean defaultScope) {
|
||||
if (defaultScope) {
|
||||
|
|
|
@ -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 <vmuzikar@redhat.com>
|
||||
*/
|
||||
@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<String, String>() {{
|
||||
put(ATTR_ORG_NAME, ATTR_ORG_VAL);
|
||||
put(ATTR_URL_NAME, ATTR_URL_VAL);
|
||||
}});
|
||||
|
||||
client2.setAttributes(new HashMap<String, String>() {{
|
||||
put(ATTR_URL_NAME, ATTR_URL_VAL);
|
||||
put(ATTR_FILTERED_NAME, ATTR_FILTERED_VAL);
|
||||
}});
|
||||
|
||||
client3.setAttributes(new HashMap<String, String>() {{
|
||||
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<String> 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
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
|
|
Loading…
Reference in a new issue