KEYCLOAK-17084 KEYCLOAK-17434 Support querying clients by client attributes

This commit is contained in:
Václav Muzikář 2021-03-11 17:22:44 +01:00 committed by Hynek Mlnařík
parent 62e17f3be7
commit 62e6883524
26 changed files with 566 additions and 24 deletions

View file

@ -62,4 +62,8 @@ public interface ClientsResource {
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
List<ClientRepresentation> findByClientId(@QueryParam("clientId") String clientId); List<ClientRepresentation> findByClientId(@QueryParam("clientId") String clientId);
@GET
@Produces(MediaType.APPLICATION_JSON)
List<ClientRepresentation> query(@QueryParam("q") String searchQuery);
} }

View file

@ -819,6 +819,11 @@ public class RealmAdapter implements CachedRealmModel {
return cacheSession.searchClientsByClientIdStream(this, clientId, firstResult, maxResults); 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 @Override
public Stream<ClientModel> getClientsStream(Integer firstResult, Integer maxResults) { public Stream<ClientModel> getClientsStream(Integer firstResult, Integer maxResults) {
return cacheSession.getClientsStream(this, firstResult, maxResults); return cacheSession.getClientsStream(this, firstResult, maxResults);

View file

@ -1161,6 +1161,11 @@ public class RealmCacheSession implements CacheRealmProvider {
return getClientDelegate().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults); 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 @Override
public ClientModel getClientByClientId(RealmModel realm, String clientId) { public ClientModel getClientByClientId(RealmModel realm, String clientId) {
String cacheKey = getClientByClientIdCacheKey(clientId, realm.getId()); String cacheKey = getClientByClientIdCacheKey(clientId, realm.getId());

View file

@ -25,13 +25,30 @@ 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 JpaClientProviderFactory implements ClientProviderFactory { public class JpaClientProviderFactory implements ClientProviderFactory {
private Set<String> clientSearchableAttributes = 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.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 @Override
@ -47,7 +64,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); return new JpaRealmProvider(session, em, clientSearchableAttributes);
} }
@Override @Override

View file

@ -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); return new JpaRealmProvider(session, em, null);
} }
@Override @Override

View file

@ -47,7 +47,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); return new JpaRealmProvider(session, em, null);
} }
@Override @Override

View file

@ -21,6 +21,7 @@ import static org.keycloak.common.util.StackUtil.getShortStackTrace;
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing; import static org.keycloak.utils.StreamsUtil.closing;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -34,7 +35,11 @@ import javax.persistence.LockModeType;
import javax.persistence.TypedQuery; import javax.persistence.TypedQuery;
import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaDelete; 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 javax.persistence.criteria.Root;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.connections.jpa.util.JpaUtils; 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.RoleModel;
import org.keycloak.models.RoleProvider; import org.keycloak.models.RoleProvider;
import org.keycloak.models.ServerInfoProvider; 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.ClientEntity;
import org.keycloak.models.jpa.entities.ClientInitialAccessEntity; import org.keycloak.models.jpa.entities.ClientInitialAccessEntity;
import org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity; 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); protected static final Logger logger = Logger.getLogger(JpaRealmProvider.class);
private final KeycloakSession session; private final KeycloakSession session;
protected EntityManager em; 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.session = session;
this.em = em; this.em = em;
this.clientSearchableAttributes = clientSearchableAttributes;
} }
@Override @Override
@ -685,6 +693,39 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
return closing(results.map(c -> session.clients().getClientById(realm, c))); 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 @Override
public void removeClients(RealmModel realm) { public void removeClients(RealmModel realm) {
TypedQuery<String> query = em.createNamedQuery("getClientIdsByRealm", String.class); TypedQuery<String> query = em.createNamedQuery("getClientIdsByRealm", String.class);
@ -963,4 +1004,8 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
return false; return false;
} }
} }
public Set<String> getClientSearchableAttributes() {
return clientSearchableAttributes;
}
} }

View file

@ -61,7 +61,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); return new JpaRealmProvider(session, em, null);
} }
@Override @Override

View file

@ -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); return new JpaRealmProvider(session, em, null);
} }
@Override @Override

View file

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

View file

@ -783,6 +783,11 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
return session.clients().searchClientsByClientIdStream(this, clientId, firstResult, maxResults); 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."; private static final String BROWSER_HEADER_PREFIX = "_browser_header.";
@Override @Override

View file

@ -270,6 +270,21 @@ public class MapClientProvider<K> implements ClientProvider {
return paginatedStream(s, firstResult, maxResults).map(entityToAdapterFunc(realm)); 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 @Override
public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) { public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) {
final String id = client.getId(); final String id = client.getId();

View file

@ -565,6 +565,11 @@ public abstract class MapRealmAdapter<K> extends AbstractRealmModel<MapRealmEnti
return session.clients().searchClientsByClientIdStream(this, clientId, firstResult, maxResults); 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 @Override
public Map<String, String> getSmtpConfig() { public Map<String, String> getSmtpConfig() {
return Collections.unmodifiableMap(entity.getSmtpConfig()); return Collections.unmodifiableMap(entity.getSmtpConfig());

View file

@ -286,6 +286,12 @@ public class MapRealmProvider<K> implements RealmProvider {
return session.clients().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults); 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 @Override
@Deprecated @Deprecated
public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) { public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) {

View file

@ -96,6 +96,7 @@ public class MapFieldPredicates {
put(CLIENT_PREDICATES, ClientModel.SearchableFields.REALM_ID, MapClientEntity::getRealmId); put(CLIENT_PREDICATES, ClientModel.SearchableFields.REALM_ID, MapClientEntity::getRealmId);
put(CLIENT_PREDICATES, ClientModel.SearchableFields.CLIENT_ID, MapClientEntity::getClientId); put(CLIENT_PREDICATES, ClientModel.SearchableFields.CLIENT_ID, MapClientEntity::getClientId);
put(CLIENT_PREDICATES, ClientModel.SearchableFields.SCOPE_MAPPING_ROLE, MapFieldPredicates::checkScopeMappingRole); 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.REALM_ID, MapClientScopeEntity::getRealmId);
put(CLIENT_SCOPE_PREDICATES, ClientScopeModel.SearchableFields.NAME, MapClientScopeEntity::getName); put(CLIENT_SCOPE_PREDICATES, ClientScopeModel.SearchableFields.NAME, MapClientScopeEntity::getName);
@ -287,6 +288,22 @@ public class MapFieldPredicates {
return mcb.fieldCompare(Boolean.TRUE::equals, getter); 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) { 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); String roleIdS = ensureEqSingleValue(UserModel.SearchableFields.ASSIGNED_ROLE, "role_id", op, values);
Function<MapUserEntity<Object>, ?> getter; Function<MapUserEntity<Object>, ?> getter;

View file

@ -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> REALM_ID = new SearchableModelField<>("realmId", String.class);
public static final SearchableModelField<ClientModel> CLIENT_ID = new SearchableModelField<>("clientId", 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); 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 { interface ClientCreationEvent extends ProviderEvent {

View file

@ -412,6 +412,8 @@ public interface RealmModel extends RoleContainerModel {
*/ */
Stream<ClientModel> searchClientByClientIdStream(String clientId, Integer firstResult, Integer maxResults); 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); void updateRequiredCredentials(Set<String> creds);
Map<String, String> getBrowserSecurityHeaders(); Map<String, String> getBrowserSecurityHeaders();

View file

@ -95,6 +95,8 @@ public interface ClientLookupProvider {
*/ */
Stream<ClientModel> searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults); 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 * Return all default scopes (if {@code defaultScope} is {@code true}) or all optional scopes (if {@code defaultScope} is {@code false}) linked with the client
* *

View file

@ -40,6 +40,7 @@ import org.keycloak.services.clientpolicy.context.AdminClientRegisteredContext;
import org.keycloak.services.managers.ClientManager; import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.managers.RealmManager; import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.utils.SearchQueryUtils;
import org.keycloak.validation.ValidationUtil; import org.keycloak.validation.ValidationUtil;
import javax.ws.rs.Consumes; 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.Context;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -100,16 +102,23 @@ public class ClientsResource {
public Stream<ClientRepresentation> getClients(@QueryParam("clientId") String clientId, public Stream<ClientRepresentation> getClients(@QueryParam("clientId") String clientId,
@QueryParam("viewableOnly") @DefaultValue("false") boolean viewableOnly, @QueryParam("viewableOnly") @DefaultValue("false") boolean viewableOnly,
@QueryParam("search") @DefaultValue("false") boolean search, @QueryParam("search") @DefaultValue("false") boolean search,
@QueryParam("q") String searchQuery,
@QueryParam("first") Integer firstResult, @QueryParam("first") Integer firstResult,
@QueryParam("max") Integer maxResults) { @QueryParam("max") Integer maxResults) {
boolean canView = auth.clients().canView(); boolean canView = auth.clients().canView();
Stream<ClientModel> clientModels = Stream.empty(); 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 clientModels = canView
? realm.getClientsStream(firstResult, maxResults) ? realm.getClientsStream(firstResult, maxResults)
: realm.getClientsStream(); : realm.getClientsStream();
auth.clients().requireList();
} else if (search) { } else if (search) {
clientModels = canView clientModels = canView
? realm.searchClientByClientIdStream(clientId, firstResult, maxResults) ? realm.searchClientByClientIdStream(clientId, firstResult, maxResults)

View file

@ -32,10 +32,14 @@ import org.keycloak.storage.client.ClientStorageProviderModel;
import org.keycloak.utils.ServicesUtils; import org.keycloak.utils.ServicesUtils;
import java.util.Objects; import java.util.Objects;
import java.util.function.Function;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientScopeModel;
import static org.keycloak.utils.StreamsUtil.paginatedStream;
/** /**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $ * @version $Revision: 1 $
@ -114,6 +118,10 @@ public class ClientStorageManager implements ClientProvider {
.map(model -> type.cast(getStorageProviderInstance(session, model, getClientStorageProviderFactory(model, session)))); .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) { public ClientStorageManager(KeycloakSession session, long clientStorageProviderTimeout) {
this.session = session; this.session = session;
@ -145,22 +153,52 @@ public class ClientStorageManager implements ClientProvider {
.orElse(null); .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 @Override
public Stream<ClientModel> searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults) { public Stream<ClientModel> searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults) {
Stream<ClientModel> local = session.clientLocalStorage().searchClientsByClientIdStream(realm, clientId, firstResult, maxResults); return query((p, f, m) -> p.searchClientsByClientIdStream(realm, clientId, f, m), realm, firstResult, maxResults);
Stream<ClientModel> ext = getEnabledStorageProviders(session, realm, ClientLookupProvider.class) }
.flatMap(ServicesUtils.timeBound(session,
clientStorageProviderTimeout,
p -> ((ClientLookupProvider) p).searchClientsByClientIdStream(realm, clientId, 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 @Override

View file

@ -29,6 +29,7 @@ import org.keycloak.storage.StorageId;
import org.keycloak.storage.client.ClientStorageProvider; import org.keycloak.storage.client.ClientStorageProvider;
import org.keycloak.storage.client.ClientStorageProviderModel; import org.keycloak.storage.client.ClientStorageProviderModel;
import java.util.Map;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -83,6 +84,12 @@ public class OpenshiftClientStorageProvider implements ClientStorageProvider {
return Stream.of(getClientByClientId(realm, clientId)); 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 @Override
public void close() { public void close() {

View file

@ -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");
}
}

View file

@ -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);
}
}

View file

@ -92,6 +92,11 @@ public class HardcodedClientStorageProvider implements ClientStorageProvider, Cl
return Stream.empty(); return Stream.empty();
} }
@Override
public Stream<ClientModel> searchClientsByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
return Stream.empty();
}
@Override @Override
public Map<String, ClientScopeModel> getClientScopes(RealmModel realm, ClientModel client, boolean defaultScope) { public Map<String, ClientScopeModel> getClientScopes(RealmModel realm, ClientModel client, boolean defaultScope) {
if (defaultScope) { if (defaultScope) {

View file

@ -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
}
}

View file

@ -151,6 +151,24 @@ public class ClientStorageTest extends AbstractTestRealmKeycloakTest {
hasItem("root-url-client")) 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 //update the provider to simulate delay during the search
ComponentModel memoryProvider = realm.getComponent(providerId); ComponentModel memoryProvider = realm.getComponent(providerId);
memoryProvider.getConfig().putSingle(delayedSearch, Boolean.toString(true)); memoryProvider.getConfig().putSingle(delayedSearch, Boolean.toString(true));