-
Notifications
You must be signed in to change notification settings - Fork 8.7k
scim prototype #50324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
scim prototype #50324
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)) { | ||
|
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); | ||
|
edewit marked this conversation as resolved.
Comment on lines
+98
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Couldn't we re-use the existing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, |
||
|
|
||
| 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()); | ||
|
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)); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.