Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions rest/admin-v2/services/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-core</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-model</artifactId>
</dependency>

<!-- used by OAS Filter during OpenAPI spec generation -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import org.keycloak.services.PatchType;
import org.keycloak.services.ServiceException;
import org.keycloak.services.client.ClientService;
import org.keycloak.services.client.DefaultClientService;
import org.keycloak.services.client.ClientServiceFactory;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;


Expand All @@ -38,7 +38,7 @@ public DefaultClientApi(@Nonnull KeycloakSession session,
this.clientId = clientId;
this.realm = realm;
this.permissions = permissions;
this.clientService = new DefaultClientService(session, realm, permissions);
this.clientService = ClientServiceFactory.create(session, realm, permissions);
}

@GET
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import org.keycloak.services.client.ClientService;
import org.keycloak.services.client.ClientService.ClientProjectionOptions;
import org.keycloak.services.client.ClientService.ClientSortAndSliceOptions;
import org.keycloak.services.client.DefaultClientService;
import org.keycloak.services.client.ClientServiceFactory;
import org.keycloak.services.client.query.ClientQueryException;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;

Expand All @@ -40,7 +40,7 @@ public DefaultClientsApi(@Nonnull KeycloakSession session,
this.session = session;
this.realm = realm;
this.permissions = permissions;
this.clientService = new DefaultClientService(session, realm, permissions);
this.clientService = ClientServiceFactory.create(session, realm, permissions);
}

@GET
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ public Comparator<BaseClientRepresentation> getSortComparator() {
.reduce(Comparator::thenComparing)
.orElseThrow();
}

public List<SortOption<ClientField>> getSortOptions() {
return sortOptions;
}
}

record CreateOrUpdateResult(BaseClientRepresentation representation, boolean created) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.keycloak.services.client;

import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;

public final class ClientServiceFactory {

public static final String SCIM_SERVICE_ENABLED_PROPERTY = "kc.admin-v2.client-service.scim.enabled";

private ClientServiceFactory() {
}

public static boolean isScimServiceEnabled() {
return Boolean.parseBoolean(System.getProperty(SCIM_SERVICE_ENABLED_PROPERTY, "false"));
}

public static ClientService create(KeycloakSession session, RealmModel realm, AdminPermissionEvaluator permissions) {
DefaultClientService delegate = new DefaultClientService(session, realm, permissions);
if (isScimServiceEnabled()) {
return new ScimBackedClientService(session, permissions, delegate);
}
return delegate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package org.keycloak.services.client;

import java.io.InputStream;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;

import jakarta.annotation.Nonnull;
import jakarta.ws.rs.core.Response.Status;

import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.mapper.ClientModelMappers;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.scim.filter.ScimFilterException;
import org.keycloak.scim.filter.ScimFilterParser.FilterContext;
import org.keycloak.services.PatchType;
import org.keycloak.services.ServiceException;
import org.keycloak.services.client.query.ClientQueryException;
import org.keycloak.services.client.query.QueryFieldExtractor;
import org.keycloak.services.client.query.QueryParseUtils;
import org.keycloak.services.client.scim.ClientJpaQueryExecutor;
import org.keycloak.services.client.scim.ClientJpaQuerySchema;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
import org.keycloak.utils.StringUtil;

public class ScimBackedClientService implements ClientService {

private static final ClientModelMappers MAPPERS = new ClientModelMappers();

private final KeycloakSession session;
private final AdminPermissionEvaluator permissions;
private final DefaultClientService delegate;

public ScimBackedClientService(@Nonnull KeycloakSession session,
@Nonnull AdminPermissionEvaluator permissions,
@Nonnull DefaultClientService delegate) {
this.session = session;
this.permissions = permissions;
this.delegate = delegate;
}

@Override
public Optional<BaseClientRepresentation> getClient(RealmModel realm, String clientId) throws ServiceException {
return delegate.getClient(realm, clientId);
}

@Override
public Stream<BaseClientRepresentation> getClients(RealmModel realm,
ClientProjectionOptions projectionOptions,
ClientSearchOptions searchOptions,
ClientSortAndSliceOptions sortAndSliceOptions) {
if (!canUseJpaQuery(realm, searchOptions)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually we need to prohibit queries that are not JPA-backed.

Comment thread
edewit marked this conversation as resolved.
return delegate.getClients(realm, projectionOptions, searchOptions, sortAndSliceOptions);
}

permissions.clients().requireList();
validateProjectionFields(projectionOptions);

int offset = sortAndSliceOptions.offset();
int limit = sortAndSliceOptions.limit();

try {
FilterContext filterContext = null;
if (searchOptions != null && !StringUtil.isBlank(searchOptions.query())) {
filterContext = QueryParseUtils.parse(searchOptions.query());
QueryParseUtils.validate(filterContext);
}

Stream<BaseClientRepresentation> stream = ClientJpaQueryExecutor.findClients(
session, realm, filterContext, sortAndSliceOptions.getSortOptions(), offset, limit)
.map(client -> delegate.getMapper(client.getProtocol()).fromModel(client))
.filter(Objects::nonNull);

return applyProjection(stream, projectionOptions);
} catch (ClientQueryException | ScimFilterException e) {
throw new ServiceException(e.getMessage(), Status.BAD_REQUEST);
} catch (ModelException e) {
throw new ServiceException(e.getMessage(), Status.BAD_REQUEST);
}
}

private boolean canViewAll(RealmModel realm) {
return AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm) || permissions.clients().canView();
}

private boolean canUseJpaQuery(RealmModel realm, ClientSearchOptions searchOptions) {
if (!canViewAll(realm)) {
return false;
}
if (searchOptions == null || StringUtil.isBlank(searchOptions.query())) {
return true;
}
try {
var filterContext = QueryParseUtils.parse(searchOptions.query());
Set<String> queryFields = QueryFieldExtractor.extractFields(filterContext);
return ClientJpaQuerySchema.JPA_FIELDS.containsAll(queryFields);
Comment thread
edewit marked this conversation as resolved.
Comment on lines +98 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We touched three-valued nulls in the initial PR, and knew that there may be different handling once switching to JPA. Should be fine to move ahead with this PR and eventually change / remove the ClientQueryEvaluatorTest expectation.

} catch (ClientQueryException e) {
return false;
}
}

private void validateProjectionFields(ClientProjectionOptions projectionOptions) {
projectionOptions.getFields().forEach(field -> {
if (!MAPPERS.isKnownField(field)) {
throw new ServiceException("%s is an unknown field".formatted(field), Status.BAD_REQUEST);
}
});
}

private Stream<BaseClientRepresentation> applyProjection(Stream<BaseClientRepresentation> stream,
ClientProjectionOptions projectionOptions) {
if (projectionOptions.getFields().isEmpty()) {
return stream;
}
return stream.map(rep -> {
MAPPERS.applyProjection(rep, projectionOptions.getFields());
return rep;
});
}

@Override
public Stream<BaseClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
return delegate.deleteClients(realm, searchOptions);
}

@Override
public void deleteClient(RealmModel realm, String clientId) throws ServiceException {
delegate.deleteClient(realm, clientId);
}

@Override
public CreateOrUpdateResult createOrUpdateClient(RealmModel realm, String clientId, BaseClientRepresentation client)
throws ServiceException {
return delegate.createOrUpdateClient(realm, clientId, client);
}

@Override
public BaseClientRepresentation createClient(RealmModel realm, BaseClientRepresentation client) throws ServiceException {
return delegate.createClient(realm, client);
}

@Override
public BaseClientRepresentation patchClient(RealmModel realm, String clientId, PatchType patchType, InputStream patch)
throws ServiceException {
return delegate.patchClient(realm, clientId, patchType, patch);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package org.keycloak.services.client.query;

import java.util.LinkedHashSet;
import java.util.Set;

import org.keycloak.scim.filter.ScimFilterParser;
import org.keycloak.scim.filter.ScimFilterParserBaseVisitor;

public final class QueryFieldExtractor extends ScimFilterParserBaseVisitor<Void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we re-use the existing ClientQueryEvaluator?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, QueryFieldExtractor returns Set<String> of attribute names and ClientQueryEvaluator boolean. Also ClientQueryEvaluator needs a client. If we would to merge them it would only save on the methods, the implementation is totally different


private final Set<String> fields = new LinkedHashSet<>();

private QueryFieldExtractor() {
}

public static Set<String> extractFields(ScimFilterParser.FilterContext filterContext) {
QueryFieldExtractor extractor = new QueryFieldExtractor();
extractor.visit(filterContext);
return Set.copyOf(extractor.fields);
}

@Override
public Void visitFilter(ScimFilterParser.FilterContext ctx) {
return visit(ctx.expression());
}

@Override
public Void visitExpression(ScimFilterParser.ExpressionContext ctx) {
if (ctx.OR() != null) {
visit(ctx.expression());
}
return visit(ctx.andExpression());
}

@Override
public Void visitAndExpression(ScimFilterParser.AndExpressionContext ctx) {
if (ctx.AND() != null) {
visit(ctx.andExpression());
}
return visit(ctx.notExpression());
}

@Override
public Void visitNotExpression(ScimFilterParser.NotExpressionContext ctx) {
if (ctx.NOT() != null) {
return visit(ctx.notExpression());
}
return visit(ctx.atom());
}

@Override
public Void visitAtom(ScimFilterParser.AtomContext ctx) {
if (ctx.attributeExpression() != null) {
return visit(ctx.attributeExpression());
}
if (ctx.expression() != null) {
return visit(ctx.expression());
}
return null;
}

@Override
public Void visitComparisonExpression(ScimFilterParser.ComparisonExpressionContext ctx) {
fields.add(ctx.ATTRPATH().getText());
return null;
}

@Override
public Void visitPresentExpression(ScimFilterParser.PresentExpressionContext ctx) {
fields.add(ctx.ATTRPATH().getText());
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package org.keycloak.services.client.scim;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;

import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;

import org.keycloak.admin.api.SortOption;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.jpa.entities.ClientEntity;
import org.keycloak.scim.filter.ScimFilterParser;
import org.keycloak.scim.model.filter.ScimJPAPredicateEvaluator;
import org.keycloak.services.client.ClientField;

import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
import static org.keycloak.utils.StreamsUtil.closing;

public final class ClientJpaQueryExecutor {

private static final ClientJpaQueryProvider QUERY_PROVIDER = new ClientJpaQueryProvider();

private ClientJpaQueryExecutor() {
}

public static Stream<ClientModel> findClients(KeycloakSession session, RealmModel realm,
ScimFilterParser.FilterContext filterContext,
List<SortOption<ClientField>> sortOptions,
int offset,
int limit) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<ClientEntity> query = cb.createQuery(ClientEntity.class);
Root<ClientEntity> root = query.from(ClientEntity.class);
query.select(root);

List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("realmId"), realm.getId()));
predicates.add(root.get("protocol").isNotNull());
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(
session, AdminPermissionsSchema.CLIENTS, realm, cb, query, root));

ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(
QUERY_PROVIDER, ClientJpaQuerySchema.SCHEMAS, cb, root);
if (filterContext != null) {
predicates.add(evaluator.visit(filterContext).predicate());
Comment thread
shawkins marked this conversation as resolved.
}

var q = query.where(predicates.toArray(Predicate[]::new));
var orders = new ArrayList<>(sortOptions.stream().map(sortOption -> {
var field = ClientJpaQuerySchema.INSTANCE.getAttributeByPath(sortOption.field().toQueryValue())
.getModelAttributeName();
Comment on lines +60 to +61
return sortOption.isAscending() ? cb.asc(root.get(field)) : cb.desc(root.get(field));
}).toList());

// add default sort by clientId if no sort options are provided
if (sortOptions.stream().noneMatch(sortOption -> "clientId".equals(sortOption.field().toQueryValue()))) {
orders.add(cb.asc(root.get("clientId")));
}

q.orderBy(orders);

return closing(paginateQuery(em.createQuery(q), offset, limit).getResultStream()
// Resolve through the provider to preserve adapter augmentation behavior.
.map(clientEntity -> session.clients().getClientById(realm, clientEntity.getId()))
.filter(Objects::nonNull));
}
}
Loading
Loading