Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.keycloak.services.client;

import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
Expand All @@ -10,26 +12,32 @@
import jakarta.ws.rs.core.Response.Status;

import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.ClientModel;
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.representations.admin.v2.OIDCClientRepresentation;
import org.keycloak.representations.admin.v2.SAMLClientRepresentation;
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.BaseClientModelSchema;
import org.keycloak.services.client.scim.ClientJpaQueryExecutor;
import org.keycloak.services.client.scim.ClientJpaQuerySchema;
import org.keycloak.services.client.scim.OIDCClientModelSchema;
import org.keycloak.services.client.scim.SAMLClientModelSchema;
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 static final Map<String, BaseClientModelSchema<?>> SCHEMAS = Map.of(
OIDCClientRepresentation.PROTOCOL, OIDCClientModelSchema.INSTANCE,
SAMLClientRepresentation.PROTOCOL, SAMLClientModelSchema.INSTANCE);

private final KeycloakSession session;
private final AdminPermissionEvaluator permissions;
Expand Down Expand Up @@ -70,12 +78,18 @@ public Stream<BaseClientRepresentation> getClients(RealmModel realm,
QueryParseUtils.validate(filterContext);
}

Set<String> includeFields = projectionOptions.getFields();
List<String> includeList = includeFields.isEmpty() ? null : includeFields.stream().toList();
Stream<BaseClientRepresentation> stream = ClientJpaQueryExecutor.findClients(
session, realm, filterContext, sortAndSliceOptions.getSortOptions(), offset, limit)
.map(client -> delegate.getMapper(client.getProtocol()).fromModel(client))
.<BaseClientRepresentation>map(client -> {
BaseClientModelSchema<?> schema = SCHEMAS.get(client.getProtocol());
if (schema == null) return null;
return populateFromSchema(schema, client, includeList);
})
Comment on lines +85 to +89

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.

This is the expected initial state.

@michalvavrik michalvavrik Aug 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IIUC it is not an issue as there is a fallback when field is not JPA_FIELDS?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Actually, never mind, I need to re-read it properly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Right, the issue is that if it is a JPA_FIELDS, we only return one of these JPA fields and not things like uuid, direct uris etc. Ok, got it.

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.

In a subsequent PR or commit all (or nearly all) the fields will be added to the schema. The JPA_FIELDS tracking will instead become "searchable fields"

.filter(Objects::nonNull);

return applyProjection(stream, projectionOptions);
return stream;
} catch (ClientQueryException | ScimFilterException e) {
throw new ServiceException(e.getMessage(), Status.BAD_REQUEST);
} catch (ModelException e) {
Expand All @@ -97,31 +111,28 @@ private boolean canUseJpaQuery(RealmModel realm, ClientSearchOptions searchOptio
try {
var filterContext = QueryParseUtils.parse(searchOptions.query());
Set<String> queryFields = QueryFieldExtractor.extractFields(filterContext);
return ClientJpaQuerySchema.JPA_FIELDS.containsAll(queryFields);
return BaseClientModelSchema.JPA_FIELDS.containsAll(queryFields);
} catch (ClientQueryException e) {
return false;
}
}

private static <R extends BaseClientRepresentation> R populateFromSchema(
BaseClientModelSchema<R> schema, ClientModel client, List<String> includeFields) {
R rep = schema.createRepresentation();
schema.populate(rep, client, includeFields, null);
return rep;
}

// TODO: still need to have well defined handling for polymorphic fields
private void validateProjectionFields(ClientProjectionOptions projectionOptions) {
projectionOptions.getFields().forEach(field -> {
if (!MAPPERS.isKnownField(field)) {
if (SCHEMAS.values().stream().noneMatch(s -> s.getAttributes().containsKey(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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package org.keycloak.services.client.scim;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;

import org.keycloak.common.util.TriConsumer;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ModelValidationException;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.scim.resource.schema.ModelSchema;
import org.keycloak.scim.resource.schema.attribute.Attribute;

/**
* Abstract schema for client models. Defines the 8 shared JPA-queryable fields as
* {@link Attribute} objects and provides attribute-filtered population from a {@link ClientModel}.
*
* @param <R> the representation type, must extend {@link BaseClientRepresentation}
*/
public abstract class BaseClientModelSchema<R extends BaseClientRepresentation>
implements ModelSchema<ClientModel, R> {

public static final Set<String> JPA_FIELDS = Set.of(
"clientId", "enabled", "description", "displayName",
"protocol", "appUrl", "createdTimestamp", "updatedTimestamp");

private final Map<String, Attribute<ClientModel, R>> attributes;

protected BaseClientModelSchema() {
Map<String, Attribute<ClientModel, R>> map = new LinkedHashMap<>();
map.put("clientId", stringAttr("clientId", "clientId", BaseClientRepresentation::setClientId, ClientModel::setClientId));
map.put("enabled", boolAttr ("enabled", "enabled", BaseClientRepresentation::setEnabled, (model, v) -> model.setEnabled(Boolean.TRUE.equals(v))));
map.put("description", stringAttr("description", "description", BaseClientRepresentation::setDescription, ClientModel::setDescription));
map.put("displayName", stringAttr("displayName", "name", BaseClientRepresentation::setDisplayName, ClientModel::setName));
map.put("protocol", stringAttr("protocol", "protocol", BaseClientRepresentation::setProtocol, ClientModel::setProtocol));
map.put("appUrl", stringAttr("appUrl", "baseUrl", BaseClientRepresentation::setAppUrl, ClientModel::setBaseUrl));
map.put("createdTimestamp", longAttr ("createdTimestamp", "createdTimestamp", BaseClientRepresentation::setCreatedTimestamp, null)); // read-only
map.put("updatedTimestamp", longAttr ("updatedTimestamp", "lastModifiedTimestamp", BaseClientRepresentation::setUpdatedTimestamp, null)); // read-only
this.attributes = Map.copyOf(map);
}

@SuppressWarnings("unchecked")
private Attribute<ClientModel, R> stringAttr(String name, String entityField,
BiConsumer<BaseClientRepresentation, String> repSetter,
BiConsumer<ClientModel, String> modelSetter) {
return Attribute.<ClientModel, R>simple(name)
.modelAttributeResolver(a -> entityField)
.withModelSetter(
modelSetter != null ? (TriConsumer<ClientModel, String, String>) (model, n, v) -> modelSetter.accept(model, v) : null,
(BiConsumer<R, String>) (rep, v) -> repSetter.accept(rep, v))
.build()
.get(0);
}

@SuppressWarnings("unchecked")
private Attribute<ClientModel, R> boolAttr(String name, String entityField,
BiConsumer<BaseClientRepresentation, Boolean> repSetter,
BiConsumer<ClientModel, Boolean> modelSetter) {
return Attribute.<ClientModel, R>simple(name)
.modelAttributeResolver(a -> entityField)
.bool()
.withModelSetter(
modelSetter != null ? (TriConsumer<ClientModel, String, Boolean>) (model, n, v) -> modelSetter.accept(model, v) : null,
(BiConsumer<R, Boolean>) (rep, v) -> repSetter.accept(rep, v))
.build()
.get(0);
}

@SuppressWarnings("unchecked")
private Attribute<ClientModel, R> longAttr(String name, String entityField,
BiConsumer<BaseClientRepresentation, Long> repSetter,
BiConsumer<ClientModel, Long> modelSetter) {
return Attribute.<ClientModel, R>simple(name)
.modelAttributeResolver(a -> entityField)
.timestamp()
.withModelSetter(
modelSetter != null ? (TriConsumer<ClientModel, String, Long>) (model, n, v) -> modelSetter.accept(model, v) : null,
(BiConsumer<R, Long>) (rep, v) -> repSetter.accept(rep, v))
.build()
.get(0);
}

@Override
public Map<String, Attribute<ClientModel, R>> getAttributes() {
return attributes;
}

@Override
public Attribute<ClientModel, R> getAttributeByPath(String path) {
return attributes.get(path);
}

/**
* Populates {@code representation} with fields from {@code model}, honouring inclusion/exclusion filters.
* Mirrors {@code AbstractModelSchema.populateResourceType} but without {@code setId}/{@code addSchema} calls.
*/
@Override
public void populate(R representation, ClientModel model, List<String> attributes, List<String> excludedAttributes) {
for (Attribute<ClientModel, R> attribute : this.attributes.values()) {
if (attribute.isExcluded(this, attributes, excludedAttributes)) {
continue;
}
Object value = getAttributeValue(model, attribute.getModelAttributeName());
attribute.set(representation, value);
}
}

/**
* Returns the value of the named model attribute (using the <em>entity-column</em> name, not the schema name).
*/
protected Object getAttributeValue(ClientModel model, String name) {
return switch (name) {
case "clientId" -> model.getClientId();
case "enabled" -> model.isEnabled();
case "description" -> model.getDescription();
case "name" -> model.getName();
case "protocol" -> model.getProtocol();
case "baseUrl" -> model.getBaseUrl();
case "createdTimestamp" -> model.getCreatedTimestamp();
case "lastModifiedTimestamp" -> model.getLastModifiedTimestamp();
default -> null;
};
}

/** Factory method — subclasses return a fresh, empty representation instance. */
public abstract R createRepresentation();

// ---- Methods not needed for query/projection use ----

/**
* Populates {@code model} from {@code representation} by calling the model-setter side of each attribute.
* Read-only attributes (createdTimestamp, updatedTimestamp) are silently skipped.
*/
@Override
public void populate(ClientModel model, R representation) {
throw new UnsupportedOperationException("populate(ClientModel, R) not yet implemented");
}
Comment thread
shawkins marked this conversation as resolved.

@Override
public void populate(R representation, ClientModel model) {
throw new UnsupportedOperationException("populate(R, ClientModel) is not supported — use populate(R, ClientModel, List, List) instead");
}

@Override
public void validate(R representation) throws ModelValidationException {
throw new UnsupportedOperationException("validate is not supported");
}

@Override
public String getId() {
return ""; // anonymous
}

@Override
public String getName() {
throw new UnsupportedOperationException("not needed for v2");
}

@Override
public String getDescription() {
throw new UnsupportedOperationException("not needed for v2");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@

public final class ClientJpaQueryExecutor {

private static final ClientJpaQueryProvider QUERY_PROVIDER = new ClientJpaQueryProvider();
private static final List<?> SCHEMAS = List.of(
OIDCClientModelSchema.INSTANCE,
SAMLClientModelSchema.INSTANCE);

private ClientJpaQueryExecutor() {
}
Expand All @@ -50,14 +52,14 @@ public static Stream<ClientModel> findClients(KeycloakSession session, RealmMode
session, AdminPermissionsSchema.CLIENTS, realm, cb, query, root));

ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(
QUERY_PROVIDER, ClientJpaQuerySchema.SCHEMAS, cb, root);
null, SCHEMAS, cb, root);
if (filterContext != null) {
predicates.add(evaluator.visit(filterContext).predicate());
}

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

This file was deleted.

Loading
Loading