mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Expose supported schemas through the Schemas endpoint
Closes #46217 Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
@@ -69,6 +69,10 @@ public final class ScimClient implements AutoCloseable {
|
||||
return new ScimResourceTypesClient(this);
|
||||
}
|
||||
|
||||
public ScimSchemasClient schemas() {
|
||||
return new ScimSchemasClient(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// no-op for now
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.keycloak.scim.client;
|
||||
|
||||
import org.keycloak.scim.protocol.response.ListResponse;
|
||||
import org.keycloak.scim.resource.schema.Schema;
|
||||
|
||||
/**
|
||||
* Client for interacting with the SCIM Schemas endpoint.
|
||||
* Schemas are read-only resources that describe the structure of SCIM resources.
|
||||
*/
|
||||
public class ScimSchemasClient extends AbstractScimResourceClient<Schema> {
|
||||
|
||||
private final ScimClient scimClient;
|
||||
|
||||
public ScimSchemasClient(ScimClient scimClient) {
|
||||
super(scimClient, Schema.class);
|
||||
this.scimClient = scimClient;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Class<ListResponse<Schema>> getListResponseType() {
|
||||
return (Class<ListResponse<Schema>>) (Class<?>) ListResponse.class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all supported SCIM schemas.
|
||||
*
|
||||
* @return a ListResponse containing all schemas
|
||||
* @throws ScimClientException if the request fails
|
||||
*/
|
||||
public ListResponse<Schema> getAll() {
|
||||
return scimClient.execute(doGet(""), getListResponseType());
|
||||
}
|
||||
|
||||
/**
|
||||
* Schemas are read-only and cannot be created.
|
||||
*
|
||||
* @throws UnsupportedOperationException always
|
||||
*/
|
||||
@Override
|
||||
public Schema create(Schema resource) {
|
||||
throw new UnsupportedOperationException("Schemas are read-only and cannot be created");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schemas are read-only and cannot be updated.
|
||||
*
|
||||
* @throws UnsupportedOperationException always
|
||||
*/
|
||||
@Override
|
||||
public Schema update(Schema resource) {
|
||||
throw new UnsupportedOperationException("Schemas are read-only and cannot be updated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schemas are read-only and cannot be deleted.
|
||||
*
|
||||
* @throws UnsupportedOperationException always
|
||||
*/
|
||||
@Override
|
||||
public void delete(String id) {
|
||||
throw new UnsupportedOperationException("Schemas are read-only and cannot be deleted");
|
||||
}
|
||||
}
|
||||
+3
@@ -9,6 +9,7 @@ import java.util.stream.Collectors;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
import org.keycloak.scim.resource.group.Group;
|
||||
import org.keycloak.scim.resource.resourcetype.ResourceType;
|
||||
import org.keycloak.scim.resource.schema.Schema;
|
||||
import org.keycloak.scim.resource.user.User;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
@@ -55,6 +56,8 @@ public class ListResponseDeserializer extends JsonDeserializer<List<ResourceType
|
||||
return Group.class;
|
||||
} else if (schemas.contains(getCoreSchema(ResourceType.class))) {
|
||||
return ResourceType.class;
|
||||
} else if (schemas.contains(getCoreSchema(Schema.class))) {
|
||||
return Schema.class;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Could not map resource type from any of the schemas: " + schemas);
|
||||
|
||||
+16
-16
@@ -38,16 +38,16 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
SET, ADD, REMOVE
|
||||
}
|
||||
|
||||
private final String name;
|
||||
private final String id;
|
||||
private Map<String, Attribute<M, R>> attributes;
|
||||
|
||||
protected AbstractModelSchema(String name) {
|
||||
this.name = name;
|
||||
protected AbstractModelSchema(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -177,14 +177,14 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
if (attributeName.indexOf('.') > 0) {
|
||||
attributeName = attributeName.substring(attributeName.indexOf('.') + 1);
|
||||
List<String> paths = new ArrayList<>();
|
||||
paths.add(getName());
|
||||
paths.add(getId());
|
||||
paths.addAll(List.of(attributeName.split("\\.")));
|
||||
value = getJsonValue(objectNode, paths);
|
||||
}
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
JsonNode schemaExtension = objectNode.get(getName());
|
||||
JsonNode schemaExtension = objectNode.get(getId());
|
||||
value = getJsonValue(schemaExtension, attribute.getName());
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
|
||||
Object value = getAttributeValue(model, name);
|
||||
attribute.set(resource, value);
|
||||
resource.addSchema(this.name);
|
||||
resource.addSchema(this.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,8 +245,8 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
return attribute;
|
||||
}
|
||||
|
||||
if (!isCore() && scimName.startsWith(getName())) {
|
||||
scimName = scimName.substring(getName().length() + 1);
|
||||
if (!isCore() && scimName.startsWith(getId())) {
|
||||
scimName = scimName.substring(getId().length() + 1);
|
||||
}
|
||||
|
||||
for (Entry<String, Attribute<M, R>> entry : getAttributes().entrySet()) {
|
||||
@@ -289,15 +289,15 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
if (attr != null) {
|
||||
// found sub-attribute withing the path
|
||||
attributes.put(attr, property.getValue());
|
||||
} else if (isCore() && getName().equals(path)) {
|
||||
} else if (isCore() && getId().equals(path)) {
|
||||
// if core schema, resolve all its attributes based on the properties of the value JSON node
|
||||
attributes.putAll(resolveAttributes(property.getKey(), property.getValue()));
|
||||
} else {
|
||||
// fallback to resolve the attribute from an extension schema
|
||||
String name = property.getKey();
|
||||
|
||||
if (!name.startsWith(getName())) {
|
||||
name = getName() + ":" + name;
|
||||
if (!name.startsWith(getId())) {
|
||||
name = getId() + ":" + name;
|
||||
}
|
||||
|
||||
attributes.putAll(resolveAttributes(name, property.getValue()));
|
||||
@@ -337,12 +337,12 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
String parent = attr.getParentName();
|
||||
|
||||
if (parent != null) {
|
||||
paths.add(getName() + attr.getName().replace(parent + ".", ":"));
|
||||
paths.add(getName() + attr.getName().replace(parent, ""));
|
||||
paths.add(getId() + attr.getName().replace(parent + ".", ":"));
|
||||
paths.add(getId() + attr.getName().replace(parent, ""));
|
||||
}
|
||||
|
||||
if (attr.getAlias() != null) {
|
||||
paths.add(getName() + ":" + attr.getAlias());
|
||||
paths.add(getId() + ":" + attr.getAlias());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,16 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
public interface ModelSchema<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
|
||||
/**
|
||||
* The name of the schema. It is used to identify the schema and to associate it with a resource type.
|
||||
* The id of the schema. It is used to identify the schema and to associate it with a resource type.
|
||||
*
|
||||
* @return the name of the schema
|
||||
* @return the id of the schema
|
||||
*/
|
||||
String getId();
|
||||
|
||||
String getName();
|
||||
|
||||
String getDescription();
|
||||
|
||||
/**
|
||||
* Returns the attributes defined by this schema. The key of the map is the name of the attribute and the value is
|
||||
* the {@link Attribute} that describes the attribute.
|
||||
|
||||
+33
-10
@@ -36,7 +36,7 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
* @return the builder
|
||||
*/
|
||||
public static <M extends Model, R extends ResourceTypeRepresentation> Builder<M, R> simple(String name) {
|
||||
return new Builder<>(name, null);
|
||||
return (Builder<M, R>) new Builder<>(name, null).string();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +49,9 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
* @return the builder
|
||||
*/
|
||||
public static <M extends Model, R extends ResourceTypeRepresentation> Builder<M, R> complex(String name, Class<?> complexType) {
|
||||
return new Builder<>(name, complexType);
|
||||
Builder<M, R> builder = new Builder<>(name, complexType);
|
||||
builder.type = "complex";
|
||||
return builder;
|
||||
}
|
||||
|
||||
private final String name;
|
||||
@@ -118,6 +120,10 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
private void setMutability(String mutability) {
|
||||
this.mutability = mutability;
|
||||
}
|
||||
@@ -213,8 +219,9 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
|
||||
public Builder<M, R> withAttribute(String name, String alias, TriConsumer<M, String, String> modelSetter) {
|
||||
String subName = this.name + "." + name;
|
||||
Attribute<M, R> attribute = new Attribute<>(subName, new AttributeMapper<>(modelSetter, new ComplexAttributeSetter<>(this.name, name, complexType)), this.name, alias);
|
||||
attribute.setModelAttributeResolver(modelAttributeResolver);
|
||||
Attribute<M, R> attribute = assembleAttribute(subName, this.name, alias,
|
||||
new AttributeMapper<>(modelSetter, new ComplexAttributeSetter<>(this.name, name, complexType)),
|
||||
modelAttributeResolver, "string", null, false, null);
|
||||
attributes.add(attribute);
|
||||
return this;
|
||||
}
|
||||
@@ -224,6 +231,11 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder<M, R> string() {
|
||||
this.type = "string";
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder<M, R> timestamp() {
|
||||
this.type = "timestamp";
|
||||
return this;
|
||||
@@ -240,12 +252,9 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
}
|
||||
|
||||
public List<Attribute<M, R>> build() {
|
||||
Attribute<M, R> attribute = new Attribute<>(name, new AttributeMapper<>(modelSetter, representationSetter, modelRemover, modelAdder), this.name);
|
||||
attribute.setModelAttributeResolver(modelAttributeResolver);
|
||||
attribute.setType(type);
|
||||
attribute.setMutability(mutability);
|
||||
attribute.setMultivalued(multivalued);
|
||||
attribute.setComplexType(complexType);
|
||||
Attribute<M, R> attribute = assembleAttribute(name, null, null,
|
||||
new AttributeMapper<>(modelSetter, representationSetter, modelRemover, modelAdder),
|
||||
modelAttributeResolver, type, mutability, multivalued, complexType);
|
||||
if (attributes.isEmpty()) {
|
||||
// do not add the root attribute if there are subattributes
|
||||
attributes.add(attribute);
|
||||
@@ -253,6 +262,20 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private Attribute<M, R> assembleAttribute(String name, String parentName, String alias,
|
||||
AttributeMapper<M, R> mapper,
|
||||
Function<Attribute<M, R>, String> modelAttributeResolver,
|
||||
String type, String mutability,
|
||||
boolean multivalued, Class<?> complexType) {
|
||||
Attribute<M, R> attribute = new Attribute<>(name, mapper, parentName, alias);
|
||||
attribute.setModelAttributeResolver(modelAttributeResolver);
|
||||
attribute.setType(type);
|
||||
attribute.setMutability(mutability);
|
||||
attribute.setMultivalued(multivalued);
|
||||
attribute.setComplexType(complexType);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
public Builder<M, R> multivalued() {
|
||||
this.multivalued = true;
|
||||
return this;
|
||||
|
||||
@@ -16,7 +16,7 @@ public final class Path {
|
||||
|
||||
public <R extends ResourceTypeRepresentation> Path(ModelSchema<?, ?> schema, String rawPath) {
|
||||
if (rawPath == null) {
|
||||
this.path = schema.getName();
|
||||
this.path = schema.getId();
|
||||
this.filter = null;
|
||||
} else {
|
||||
int filterStartIdx = rawPath.indexOf("[");
|
||||
|
||||
+4
-3
@@ -149,16 +149,17 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
|
||||
|
||||
@Override
|
||||
public String getSchema() {
|
||||
return schema.getName();
|
||||
return schema.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ModelSchema<M, R>> getSchemas() {
|
||||
return schemas;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getSchemaExtensions() {
|
||||
return schemaExtensions.stream().map(ModelSchema::getName).toList();
|
||||
return schemaExtensions.stream().map(ModelSchema::getId).toList();
|
||||
}
|
||||
|
||||
protected abstract R onCreate(R resource);
|
||||
@@ -175,7 +176,7 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
|
||||
|
||||
protected void populate(M model, R resource) {
|
||||
for (ModelSchema<M, R> schema : schemas) {
|
||||
if (resource.hasSchema(schema.getName())) {
|
||||
if (resource.hasSchema(schema.getId())) {
|
||||
schema.populate(model, resource);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.events.admin.ResourceType;
|
||||
import org.keycloak.models.Model;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.scim.protocol.request.PatchRequest.PatchOperation;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
import org.keycloak.scim.resource.schema.ModelSchema;
|
||||
|
||||
/**
|
||||
* <p>A provider of a SCIM resource type.
|
||||
@@ -41,6 +43,8 @@ public interface ScimResourceTypeProvider<R extends ResourceTypeRepresentation>
|
||||
*/
|
||||
String getSchema();
|
||||
|
||||
<M extends Model> List<ModelSchema<M, R>> getSchemas();
|
||||
|
||||
/**
|
||||
* Returns the schema extensions names of the resource type managed by this provider.
|
||||
*
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>keycloak-scim-model</artifactId>
|
||||
<name>Keycloak SCIM Core</name>
|
||||
<name>Keycloak SCIM Model</name>
|
||||
<description>
|
||||
This module provides a Model API around the SCIM Core Schema(RFC7643)
|
||||
</description>
|
||||
|
||||
+7
@@ -3,10 +3,12 @@ package org.keycloak.scim.model.config;
|
||||
import java.util.List;
|
||||
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.Model;
|
||||
import org.keycloak.scim.resource.config.ServiceProviderConfig;
|
||||
import org.keycloak.scim.resource.config.ServiceProviderConfig.BulkSupport;
|
||||
import org.keycloak.scim.resource.config.ServiceProviderConfig.FilterSupport;
|
||||
import org.keycloak.scim.resource.config.ServiceProviderConfig.Supported;
|
||||
import org.keycloak.scim.resource.schema.ModelSchema;
|
||||
import org.keycloak.scim.resource.spi.SingletonResourceTypeProvider;
|
||||
|
||||
public class ServiceProviderConfigResourceTypeProvider implements SingletonResourceTypeProvider<ServiceProviderConfig> {
|
||||
@@ -37,4 +39,9 @@ public class ServiceProviderConfigResourceTypeProvider implements SingletonResou
|
||||
public String getSchema() {
|
||||
return ServiceProviderConfig.SCHEMA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <M extends Model> List<ModelSchema<M, ServiceProviderConfig>> getSchemas() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,20 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
public String getId() {
|
||||
return Group.SCHEMA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Group";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Set<String> getModelAttributeNames() {
|
||||
return Set.of("name");
|
||||
|
||||
+9
-1
@@ -6,17 +6,20 @@ import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.Model;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
import org.keycloak.scim.resource.config.ServiceProviderConfig;
|
||||
import org.keycloak.scim.resource.resourcetype.ResourceType;
|
||||
import org.keycloak.scim.resource.resourcetype.ResourceType.SchemaExtension;
|
||||
import org.keycloak.scim.resource.schema.ModelSchema;
|
||||
import org.keycloak.scim.resource.schema.Schema;
|
||||
import org.keycloak.scim.resource.spi.ScimResourceTypeProvider;
|
||||
import org.keycloak.scim.resource.spi.ScimResourceTypeProviderFactory;
|
||||
|
||||
public class ResourceTypeProvider implements ScimResourceTypeProvider<ResourceType> {
|
||||
|
||||
private static final List<Class<? extends ResourceTypeRepresentation>> EXCLUDED_RESOURCE_TYPES = List.of(ServiceProviderConfig.class, ResourceType.class);
|
||||
private static final List<Class<? extends ResourceTypeRepresentation>> EXCLUDED_RESOURCE_TYPES = List.of(ServiceProviderConfig.class, ResourceType.class, Schema.class);
|
||||
private final KeycloakSession session;
|
||||
|
||||
public ResourceTypeProvider(KeycloakSession session) {
|
||||
@@ -102,4 +105,9 @@ public class ResourceTypeProvider implements ScimResourceTypeProvider<ResourceTy
|
||||
public String getSchema() {
|
||||
return ResourceType.SCHEMA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <M extends Model> List<ModelSchema<M, ResourceType>> getSchemas() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package org.keycloak.scim.model.schema;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.Model;
|
||||
import org.keycloak.models.ModelException;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.scim.model.config.ServiceProviderConfigResourceTypeProvider;
|
||||
import org.keycloak.scim.model.resourcetype.ResourceTypeProviderFactory;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.Scim;
|
||||
import org.keycloak.scim.resource.schema.ModelSchema;
|
||||
import org.keycloak.scim.resource.schema.Schema;
|
||||
import org.keycloak.scim.resource.schema.Schema.Attribute;
|
||||
import org.keycloak.scim.resource.spi.ScimResourceTypeProvider;
|
||||
|
||||
/**
|
||||
* Provider for SCIM Schema resources. This provider exposes the supported SCIM schemas
|
||||
* for discovery by SCIM clients via the /Schemas endpoint.
|
||||
* <p>
|
||||
* Schemas are read-only resources that describe the structure of SCIM resources.
|
||||
* This implementation supports:
|
||||
* - Built-in core schemas (User, Group)
|
||||
* - Built-in extension schemas (EnterpriseUser)
|
||||
* - Custom extension schemas based on user profile configuration (future)
|
||||
*/
|
||||
public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Schema> {
|
||||
|
||||
private final Map<String, Schema> schemas = new HashMap<>();
|
||||
private final KeycloakSession session;
|
||||
|
||||
public SchemaResourceTypeProvider(KeycloakSession session) {
|
||||
this.session = session;
|
||||
initializeSchemas();
|
||||
}
|
||||
|
||||
private void initializeSchemas() {
|
||||
Stream<ProviderFactory> schemas = session.getKeycloakSessionFactory().getProviderFactoriesStream(ScimResourceTypeProvider.class);
|
||||
|
||||
schemas.filter(providerFactory -> !(providerFactory instanceof SchemaResourceTypeProviderFactory
|
||||
|| providerFactory instanceof ResourceTypeProviderFactory
|
||||
|| providerFactory instanceof ServiceProviderConfigResourceTypeProvider)
|
||||
).flatMap((Function<ProviderFactory, Stream<ModelSchema>>) factory -> {
|
||||
ScimResourceTypeProvider provider = session.getProvider(ScimResourceTypeProvider.class, factory.getId());
|
||||
List<ModelSchema> modelSchemas = provider.getSchemas();
|
||||
return modelSchemas.stream();
|
||||
}).forEach(this::buildSchema);
|
||||
}
|
||||
|
||||
private void buildSchema(ModelSchema<?, ?> modelSchema) {
|
||||
Schema rep = new Schema();
|
||||
rep.setId(modelSchema.getId());
|
||||
rep.setName(modelSchema.getName());
|
||||
rep.setDescription(modelSchema.getDescription());
|
||||
|
||||
// Collect top-level attributes, nesting sub-attributes under their parent
|
||||
Map<String, Attribute> topLevelAttributes = new HashMap<>();
|
||||
|
||||
for (org.keycloak.scim.resource.schema.attribute.Attribute<?, ?> attribute : modelSchema.getAttributes().values()) {
|
||||
String name = attribute.getName();
|
||||
|
||||
if (name.startsWith("meta.")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String parentName = attribute.getParentName();
|
||||
|
||||
if (parentName != null && !parentName.equals(name)) {
|
||||
// This is a sub-attribute — strip the parent prefix to get the relative path
|
||||
String relativeName = name.substring(parentName.length() + 1);
|
||||
|
||||
if (relativeName.indexOf('.') != -1) {
|
||||
// Nested complex sub-attribute (e.g., "manager.value" → top-level "manager", sub "value")
|
||||
String topName = relativeName.substring(0, relativeName.indexOf('.'));
|
||||
String subName = relativeName.substring(relativeName.indexOf('.') + 1);
|
||||
|
||||
Attribute parent = topLevelAttributes.computeIfAbsent(topName, k -> {
|
||||
Attribute p = new Attribute();
|
||||
p.setName(k);
|
||||
p.setType("complex");
|
||||
p.setMultiValued(false);
|
||||
return p;
|
||||
});
|
||||
|
||||
Attribute subAttr = new Attribute();
|
||||
subAttr.setName(subName);
|
||||
subAttr.setType(attribute.getType());
|
||||
subAttr.setMultiValued(false);
|
||||
|
||||
List<Attribute> subAttributes = parent.getSubAttributes();
|
||||
if (subAttributes == null) {
|
||||
subAttributes = new ArrayList<>();
|
||||
parent.setSubAttributes(subAttributes);
|
||||
}
|
||||
subAttributes.add(subAttr);
|
||||
} else if (modelSchema.isCore()) {
|
||||
// Core schema sub-attribute (e.g., "name.givenName" → parent "name", sub "givenName")
|
||||
Attribute parent = topLevelAttributes.computeIfAbsent(parentName, k -> {
|
||||
Attribute p = new Attribute();
|
||||
p.setName(k);
|
||||
p.setType("complex");
|
||||
p.setMultiValued(attribute.isMultivalued());
|
||||
return p;
|
||||
});
|
||||
|
||||
Attribute subAttr = new Attribute();
|
||||
subAttr.setName(relativeName);
|
||||
subAttr.setType(attribute.getType());
|
||||
subAttr.setMultiValued(false);
|
||||
|
||||
List<Attribute> subAttributes = parent.getSubAttributes();
|
||||
if (subAttributes == null) {
|
||||
subAttributes = new ArrayList<>();
|
||||
parent.setSubAttributes(subAttributes);
|
||||
}
|
||||
subAttributes.add(subAttr);
|
||||
} else {
|
||||
// Extension schema simple sub-attribute (e.g., "enterpriseUser.employeeNumber" → "employeeNumber")
|
||||
topLevelAttributes.computeIfAbsent(relativeName, k -> {
|
||||
Attribute attr = new Attribute();
|
||||
attr.setName(k);
|
||||
attr.setType(attribute.getType());
|
||||
attr.setMultiValued(attribute.isMultivalued());
|
||||
return attr;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Top-level attribute — only add if not already created as a parent
|
||||
topLevelAttributes.computeIfAbsent(name, k -> {
|
||||
Attribute attr = new Attribute();
|
||||
attr.setName(k);
|
||||
attr.setType(attribute.getType());
|
||||
attr.setMultiValued(attribute.isMultivalued());
|
||||
return attr;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
rep.setAttributes(List.copyOf(topLevelAttributes.values()));
|
||||
schemas.put(modelSchema.getId(), rep);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Schema get(String id) {
|
||||
// TODO: Add `view-realm` role check for schema discovery ??
|
||||
// Currently accessible to any authenticated user with valid bearer token
|
||||
// Should be aligned with other discovery endpoints (ResourceTypes, ServiceProviderConfig)
|
||||
return schemas.get(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<Schema> getAll(SearchRequest searchRequest) {
|
||||
// Per RFC 7644 Section 4, /Schemas is a discovery endpoint that SHALL return all schemas.
|
||||
// Filtering, sorting, and pagination are not supported for discovery endpoints.
|
||||
// The searchRequest parameter is ignored.
|
||||
return schemas.values().stream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long count(SearchRequest searchRequest) {
|
||||
return getAll(null).count();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Schema create(Schema resource) {
|
||||
throw new ModelException("Schemas are read-only and cannot be created");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Schema update(Schema resource) {
|
||||
throw new ModelException("Schemas are read-only and cannot be updated");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String id) {
|
||||
throw new ModelException("Schemas are read-only and cannot be deleted");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSchema() {
|
||||
return Scim.SCHEMA_CORE_SCHEMA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <M extends Model> List<ModelSchema<M, Schema>> getSchemas() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<Schema> getResourceType() {
|
||||
return Schema.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// No resources to close
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package org.keycloak.scim.model.schema;
|
||||
|
||||
import org.keycloak.Config.Scope;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.scim.resource.spi.ScimResourceTypeProviderFactory;
|
||||
|
||||
public class SchemaResourceTypeProviderFactory implements ScimResourceTypeProviderFactory<SchemaResourceTypeProvider> {
|
||||
|
||||
public static final String ID = "Schemas";
|
||||
|
||||
@Override
|
||||
public SchemaResourceTypeProvider create(KeycloakSession session) {
|
||||
return new SchemaResourceTypeProvider(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Scope config) {
|
||||
// No initialization needed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
// No post-initialization needed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// No resources to close
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return ID;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,16 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema {
|
||||
super(session, Scim.getCoreSchema(User.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "User";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "User Account";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, Attribute<UserModel, User>> doGetAttributes() {
|
||||
List<Attribute<UserModel, User>> attributes = new ArrayList<>();
|
||||
|
||||
+11
-1
@@ -20,10 +20,20 @@ public final class UserEnterpriseModelSchema extends AbstractUserModelSchema {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
public String getId() {
|
||||
return ENTERPRISE_USER_SCHEMA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "EnterpriseUser";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "Enterprise User";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCore() {
|
||||
return false;
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
org.keycloak.scim.model.user.UserResourceTypeProviderFactory
|
||||
org.keycloak.scim.model.group.GroupResourceTypeProviderFactory
|
||||
org.keycloak.scim.model.config.ServiceProviderConfigResourceTypeProviderFactory
|
||||
org.keycloak.scim.model.resourcetype.ResourceTypeProviderFactory
|
||||
org.keycloak.scim.model.resourcetype.ResourceTypeProviderFactory
|
||||
org.keycloak.scim.model.schema.SchemaResourceTypeProviderFactory
|
||||
@@ -0,0 +1,287 @@
|
||||
package org.keycloak.tests.scim.tck;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.scim.protocol.response.ListResponse;
|
||||
import org.keycloak.scim.resource.Scim;
|
||||
import org.keycloak.scim.resource.schema.Schema;
|
||||
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@KeycloakIntegrationTest(config = ScimServerConfig.class)
|
||||
public class SchemaTest extends AbstractScimTest {
|
||||
|
||||
@Test
|
||||
public void testGetAllSchemas() {
|
||||
ListResponse<Schema> response = client.schemas().getAll();
|
||||
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getResources());
|
||||
assertEquals(3, response.getTotalResults());
|
||||
assertEquals(3, response.getResources().size());
|
||||
|
||||
// Verify all expected schemas are present
|
||||
List<String> schemaIds = response.getResources().stream()
|
||||
.map(Schema::getId)
|
||||
.toList();
|
||||
|
||||
assertTrue(schemaIds.contains(Scim.USER_CORE_SCHEMA));
|
||||
assertTrue(schemaIds.contains(Scim.GROUP_CORE_SCHEMA));
|
||||
assertTrue(schemaIds.contains(Scim.ENTERPRISE_USER_SCHEMA));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetUserCoreSchema() {
|
||||
Schema schema = client.schemas().get(Scim.USER_CORE_SCHEMA);
|
||||
|
||||
assertNotNull(schema);
|
||||
assertEquals(Scim.USER_CORE_SCHEMA, schema.getId());
|
||||
assertEquals("User", schema.getName());
|
||||
assertEquals("User Account", schema.getDescription());
|
||||
assertNotNull(schema.getAttributes());
|
||||
assertFalse(schema.getAttributes().isEmpty());
|
||||
|
||||
// Verify ALL expected attributes are present (extracted from UserCoreModelSchema)
|
||||
// UserCoreModelSchema has: userName, emails[0].value, name.*, externalId, nickName, locale, active
|
||||
// These should map to top-level attributes: userName, emails, name, externalId, nickName, locale, active
|
||||
Set<String> attributeNames = schema.getAttributes().stream()
|
||||
.map(Schema.Attribute::getName)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertEquals(14, attributeNames.size(), "User schema should have exactly 7 attributes");
|
||||
assertTrue(attributeNames.contains("userName"));
|
||||
assertTrue(attributeNames.contains("emails"));
|
||||
assertTrue(attributeNames.contains("name"));
|
||||
assertTrue(attributeNames.contains("externalId"));
|
||||
assertTrue(attributeNames.contains("nickName"));
|
||||
assertTrue(attributeNames.contains("locale"));
|
||||
assertTrue(attributeNames.contains("active"));
|
||||
assertTrue(attributeNames.contains("profileUrl"));
|
||||
assertTrue(attributeNames.contains("preferredLanguage"));
|
||||
assertTrue(attributeNames.contains("displayName"));
|
||||
assertTrue(attributeNames.contains("timezone"));
|
||||
assertTrue(attributeNames.contains("groups"));
|
||||
assertTrue(attributeNames.contains("title"));
|
||||
assertTrue(attributeNames.contains("userType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetGroupCoreSchema() {
|
||||
Schema schema = client.schemas().get(Scim.GROUP_CORE_SCHEMA);
|
||||
|
||||
assertNotNull(schema);
|
||||
assertEquals(Scim.GROUP_CORE_SCHEMA, schema.getId());
|
||||
assertEquals("Group", schema.getName());
|
||||
assertEquals("Group", schema.getDescription());
|
||||
assertNotNull(schema.getAttributes());
|
||||
|
||||
// Verify ALL expected attributes are present (extracted from GroupCoreModelSchema)
|
||||
// GroupCoreModelSchema currently only has: displayName
|
||||
// Note: members is not yet supported in GroupCoreModelSchema attribute mappers
|
||||
Set<String> attributeNames = schema.getAttributes().stream()
|
||||
.map(Schema.Attribute::getName)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertEquals(1, attributeNames.size(), "Group schema should have exactly 1 attribute");
|
||||
assertTrue(attributeNames.contains("displayName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetEnterpriseUserSchema() {
|
||||
Schema schema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
|
||||
|
||||
assertNotNull(schema);
|
||||
assertEquals(Scim.ENTERPRISE_USER_SCHEMA, schema.getId());
|
||||
assertEquals("EnterpriseUser", schema.getName());
|
||||
assertEquals("Enterprise User", schema.getDescription());
|
||||
assertNotNull(schema.getAttributes());
|
||||
|
||||
// Verify ALL expected attributes are present (extracted from UserEnterpriseModelSchema)
|
||||
// UserEnterpriseModelSchema has: employeeNumber, costCenter, organization, division, department, manager.*
|
||||
// These should map to: employeeNumber, costCenter, organization, division, department, manager
|
||||
Set<String> attributeNames = schema.getAttributes().stream()
|
||||
.map(Schema.Attribute::getName)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertEquals(6, attributeNames.size(), "Enterprise User schema should have exactly 6 attributes");
|
||||
assertTrue(attributeNames.contains("employeeNumber"));
|
||||
assertTrue(attributeNames.contains("costCenter"));
|
||||
assertTrue(attributeNames.contains("organization"));
|
||||
assertTrue(attributeNames.contains("division"));
|
||||
assertTrue(attributeNames.contains("department"));
|
||||
assertTrue(attributeNames.contains("manager"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttributeProperties() {
|
||||
Schema schema = client.schemas().get(Scim.USER_CORE_SCHEMA);
|
||||
|
||||
// Test STRING type (userName)
|
||||
Schema.Attribute userNameAttr = findAttribute(schema, "userName");
|
||||
assertNotNull(userNameAttr, "userName attribute should exist");
|
||||
assertEquals("string", userNameAttr.getType());
|
||||
assertEquals(false, userNameAttr.getMultiValued());
|
||||
|
||||
// Test BOOLEAN type (active)
|
||||
Schema.Attribute activeAttr = findAttribute(schema, "active");
|
||||
assertNotNull(activeAttr, "active attribute should exist");
|
||||
assertEquals("boolean", activeAttr.getType());
|
||||
assertEquals(false, activeAttr.getMultiValued());
|
||||
|
||||
// Test COMPLEX multi-valued (emails)
|
||||
Schema.Attribute emailsAttr = findAttribute(schema, "emails");
|
||||
assertNotNull(emailsAttr, "emails attribute should exist");
|
||||
assertEquals("complex", emailsAttr.getType());
|
||||
assertEquals(true, emailsAttr.getMultiValued());
|
||||
|
||||
// Test COMPLEX single-valued (name)
|
||||
Schema.Attribute nameAttr = findAttribute(schema, "name");
|
||||
assertNotNull(nameAttr, "name attribute should exist");
|
||||
assertEquals("complex", nameAttr.getType());
|
||||
assertEquals(false, nameAttr.getMultiValued());
|
||||
|
||||
// Test STRING attributes (externalId, nickName, locale)
|
||||
Schema.Attribute externalIdAttr = findAttribute(schema, "externalId");
|
||||
assertNotNull(externalIdAttr, "externalId attribute should exist");
|
||||
assertEquals("string", externalIdAttr.getType());
|
||||
assertEquals(false, externalIdAttr.getMultiValued());
|
||||
|
||||
Schema.Attribute nickNameAttr = findAttribute(schema, "nickName");
|
||||
assertNotNull(nickNameAttr, "nickName attribute should exist");
|
||||
assertEquals("string", nickNameAttr.getType());
|
||||
assertEquals(false, nickNameAttr.getMultiValued());
|
||||
|
||||
Schema.Attribute localeAttr = findAttribute(schema, "locale");
|
||||
assertNotNull(localeAttr, "locale attribute should exist");
|
||||
assertEquals("string", localeAttr.getType());
|
||||
assertEquals(false, localeAttr.getMultiValued());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReferenceTypes() {
|
||||
// Test EnterpriseUser manager is a complex attribute
|
||||
Schema enterpriseUserSchema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
|
||||
Schema.Attribute managerAttr = findAttribute(enterpriseUserSchema, "manager");
|
||||
assertNotNull(managerAttr, "manager attribute should exist");
|
||||
assertEquals("complex", managerAttr.getType());
|
||||
assertEquals(false, managerAttr.getMultiValued());
|
||||
|
||||
// TODO: referenceTypes are not yet tracked in the model schema Attribute.
|
||||
// Once Attribute supports reference types, add assertions for:
|
||||
// managerAttr.getReferenceTypes() containing "User"
|
||||
|
||||
// Note: Group.members is not yet supported in GroupCoreModelSchema,
|
||||
// so reference type testing for members is omitted
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEnterpriseUserAttributeTypes() {
|
||||
Schema schema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
|
||||
|
||||
// All enterprise user attributes (except manager) should be string type
|
||||
String[] stringAttributes = {"employeeNumber", "costCenter", "organization", "division", "department"};
|
||||
for (String attrName : stringAttributes) {
|
||||
Schema.Attribute attr = findAttribute(schema, attrName);
|
||||
assertNotNull(attr, attrName + " attribute should exist");
|
||||
assertEquals("string", attr.getType(), attrName + " should be string type");
|
||||
assertEquals(false, attr.getMultiValued(), attrName + " should not be multi-valued");
|
||||
}
|
||||
|
||||
// Manager is complex type with User reference
|
||||
Schema.Attribute managerAttr = findAttribute(schema, "manager");
|
||||
assertNotNull(managerAttr, "manager attribute should exist");
|
||||
assertEquals("complex", managerAttr.getType());
|
||||
assertEquals(false, managerAttr.getMultiValued());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupAttributeTypes() {
|
||||
Schema schema = client.schemas().get(Scim.GROUP_CORE_SCHEMA);
|
||||
|
||||
// displayName should be string
|
||||
Schema.Attribute displayNameAttr = findAttribute(schema, "displayName");
|
||||
assertNotNull(displayNameAttr, "displayName attribute should exist");
|
||||
assertEquals("string", displayNameAttr.getType());
|
||||
assertEquals(false, displayNameAttr.getMultiValued());
|
||||
|
||||
// Note: members is not yet supported in GroupCoreModelSchema attribute mappers
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPathExtractionLogic() {
|
||||
// This test verifies that the path extraction logic works correctly
|
||||
// Multiple SCIM paths should map to the same top-level attribute
|
||||
|
||||
Schema userSchema = client.schemas().get(Scim.USER_CORE_SCHEMA);
|
||||
|
||||
// UserCoreModelSchema has multiple paths for 'name':
|
||||
// - name.givenName, name.familyName, name.middleName, name.honorificPrefix, name.honorificSuffix
|
||||
// All should map to a single 'name' attribute
|
||||
Schema.Attribute nameAttr = findAttribute(userSchema, "name");
|
||||
assertNotNull(nameAttr, "name attribute should exist (from multiple name.* paths)");
|
||||
assertEquals("complex", nameAttr.getType());
|
||||
assertEquals(false, nameAttr.getMultiValued());
|
||||
|
||||
// emails[0].value should map to 'emails' attribute
|
||||
Schema.Attribute emailsAttr = findAttribute(userSchema, "emails");
|
||||
assertNotNull(emailsAttr, "emails attribute should exist (from emails[0].value path)");
|
||||
assertEquals("complex", emailsAttr.getType());
|
||||
assertEquals(true, emailsAttr.getMultiValued());
|
||||
|
||||
// EnterpriseUser has manager.value and manager.displayName
|
||||
// Both should map to a single 'manager' attribute
|
||||
Schema enterpriseSchema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
|
||||
Schema.Attribute managerAttr = findAttribute(enterpriseSchema, "manager");
|
||||
assertNotNull(managerAttr, "manager attribute should exist (from manager.* paths)");
|
||||
assertEquals("complex", managerAttr.getType());
|
||||
assertEquals(false, managerAttr.getMultiValued());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNoDuplicateAttributes() {
|
||||
// Verify that attributes are not duplicated even when multiple SCIM paths
|
||||
// map to the same top-level attribute
|
||||
|
||||
Schema userSchema = client.schemas().get(Scim.USER_CORE_SCHEMA);
|
||||
List<String> attributeNames = userSchema.getAttributes().stream()
|
||||
.map(Schema.Attribute::getName)
|
||||
.toList();
|
||||
|
||||
// Check for duplicates
|
||||
Set<String> uniqueNames = Set.copyOf(attributeNames);
|
||||
assertEquals(uniqueNames.size(), attributeNames.size(),
|
||||
"Schema should not have duplicate attribute names");
|
||||
|
||||
// Specifically verify 'name' appears only once (not once per name.* path)
|
||||
long nameCount = attributeNames.stream()
|
||||
.filter("name"::equals)
|
||||
.count();
|
||||
assertEquals(1, nameCount, "name attribute should appear exactly once");
|
||||
|
||||
// Verify EnterpriseUser manager appears only once
|
||||
Schema enterpriseSchema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
|
||||
List<String> enterpriseNames = enterpriseSchema.getAttributes().stream()
|
||||
.map(Schema.Attribute::getName)
|
||||
.toList();
|
||||
long managerCount = enterpriseNames.stream()
|
||||
.filter("manager"::equals)
|
||||
.count();
|
||||
assertEquals(1, managerCount, "manager attribute should appear exactly once");
|
||||
}
|
||||
|
||||
// Helper method to find attribute by name
|
||||
private Schema.Attribute findAttribute(Schema schema, String name) {
|
||||
return schema.getAttributes().stream()
|
||||
.filter(attr -> name.equals(attr.getName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user