Expose supported schemas through the Schemas endpoint

Closes #46217

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik
2026-02-27 11:41:34 +01:00
committed by Pedro Igor
parent 168f824741
commit 9cc536d14c
19 changed files with 713 additions and 37 deletions
@@ -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");
}
}
@@ -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);
@@ -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.
@@ -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("[");
@@ -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
View File
@@ -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>
@@ -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");
@@ -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();
}
}
@@ -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
}
}
@@ -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<>();
@@ -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;
@@ -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);
}
}