From 98e9ec6028c80c724ace4265c57de561c22980a2 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Wed, 29 Apr 2026 16:28:11 -0300 Subject: [PATCH] Support for custom user scim extension Closes #48345 Signed-off-by: Pedro Igor --- .../resource/ResourceTypeRepresentation.java | 26 +- .../resource/schema/AbstractModelSchema.java | 168 +++++++------ .../scim/resource/schema/ModelSchema.java | 27 ++- .../schema/SchemaValidationException.java | 10 - .../resource/schema/attribute/Attribute.java | 73 +++++- .../schema/attribute/AttributeMapper.java | 9 +- .../attribute/ComplexAttributeSetter.java | 7 +- .../spi/AbstractScimResourceTypeProvider.java | 6 +- .../model/group/GroupCoreModelSchema.java | 11 +- .../group/GroupResourceTypeProvider.java | 2 +- .../schema/SchemaResourceTypeProvider.java | 77 +++--- .../model/user/AbstractUserModelSchema.java | 14 +- .../scim/model/user/UserCoreModelSchema.java | 16 +- .../model/user/UserEnterpriseModelSchema.java | 24 +- .../model/user/UserExtensionModelSchema.java | 225 ++++++++++++++++++ .../model/user/UserResourceTypeProvider.java | 2 +- .../tests/scim/tck/AbstractScimTest.java | 28 +++ .../keycloak/tests/scim/tck/FilterTest.java | 2 + .../keycloak/tests/scim/tck/GroupTest.java | 2 +- .../keycloak/tests/scim/tck/SchemaTest.java | 56 ++++- .../org/keycloak/tests/scim/tck/UserTest.java | 209 ++++++++++++++-- 21 files changed, 813 insertions(+), 181 deletions(-) delete mode 100644 scim/core/src/main/java/org/keycloak/scim/resource/schema/SchemaValidationException.java create mode 100644 scim/model/src/main/java/org/keycloak/scim/model/user/UserExtensionModelSchema.java diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/ResourceTypeRepresentation.java b/scim/core/src/main/java/org/keycloak/scim/resource/ResourceTypeRepresentation.java index 08a1269a201..4f7a55c9602 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/ResourceTypeRepresentation.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/ResourceTypeRepresentation.java @@ -1,19 +1,24 @@ package org.keycloak.scim.resource; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Optional; import java.util.Set; import org.keycloak.scim.resource.common.Meta; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import static org.keycloak.scim.resource.Scim.getCoreSchema; -@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonInclude(Include.NON_NULL) @JsonIgnoreProperties({"type"}) public abstract class ResourceTypeRepresentation { @@ -34,6 +39,8 @@ public abstract class ResourceTypeRepresentation { @JsonIgnore private Long lastModifiedTimestamp; + private Map extensions; + public Set getSchemas() { if (schemas == null) { schemas = new HashSet<>(); @@ -96,4 +103,21 @@ public abstract class ResourceTypeRepresentation { } schemas.add(schema); } + + @JsonAnyGetter + public Map getExtensions() { + return extensions; + } + + @JsonAnySetter + public void setExtensions(String name, Object value) { + if (extensions == null) { + extensions = new HashMap<>(); + } + this.extensions.put(name, value); + } + + public void setExtensions(Map extensions) { + this.extensions = extensions; + } } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java index fb0527c16fe..b71ae1c85bc 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java @@ -25,6 +25,8 @@ import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; +import static java.util.Optional.ofNullable; + import static org.keycloak.scim.resource.schema.AbstractModelSchema.Operation.ADD; import static org.keycloak.scim.resource.schema.AbstractModelSchema.Operation.REMOVE; import static org.keycloak.scim.resource.schema.AbstractModelSchema.Operation.SET; @@ -52,15 +54,14 @@ public abstract class AbstractModelSchema> getAttributes() { if (attributes == null) { - attributes = doGetAttributes(); + attributes = getAttributeMappers(); } return attributes; } - protected abstract Map> doGetAttributes(); - @Override public void populate(M model, R representation) { + validate(representation); populateModel(model, representation); representation.setId(model.getId()); } @@ -78,7 +79,7 @@ public abstract class AbstractModelSchema attribute = getAttributeMapperByModelAttribute(name); + for (Entry> entry : getAttributeMappers().entrySet()) { + Attribute attribute = entry.getValue(); + String scimName = attribute.getName(); + ObjectNode valueNode = resourceNode; - if (attribute == null) { - continue; - } + if (attribute.isExtension()) { + JsonNode node = ofNullable(resourceNode.get(attribute.getSchema())).orElse(NullNode.getInstance()); - Object value = getJsonValue(objectNode, attribute.getName()); - - if (value == null) { - String attributeName = attribute.getName(); - - if (attributeName.indexOf('.') > 0) { - attributeName = attributeName.substring(attributeName.indexOf('.') + 1); - List paths = new ArrayList<>(); - paths.add(getId()); - paths.addAll(List.of(attributeName.split("\\."))); - value = getJsonValue(objectNode, paths); + if (!node.isObject()) { + continue; } + + valueNode = (ObjectNode) node; + scimName = attribute.getSimpleName(); } - if (value == null) { - JsonNode schemaExtension = objectNode.get(getId()); - value = getJsonValue(schemaExtension, attribute.getName()); - } + Object value = getJsonValue(valueNode, scimName); if (value != null) { - if (value instanceof Collection values) { - if (attribute.isMultivalued()) { - ArrayNode nodes = JsonNodeFactory.instance.arrayNode(); - - for (Object v : values) { - if (v instanceof JsonNode jsonNode) { - nodes.add(jsonNode); - } else { - nodes.add(TextNode.valueOf(v.toString())); - } - } - - setValue(model, attribute, nodes); - } else { - for (Object v : values) { - if (v instanceof JsonNode jsonNode) { - setValue(model, attribute, resolveAttributeValue(attribute, jsonNode)); - } - // no support for multivalued attributes for now, so we take the first value as the value of the attribute - break; - } - } - } else { - attribute.set(model, TextNode.valueOf(value.toString())); - } + setValue(model, attribute, value); } } } private void populateResourceType(R resource, M model, List requestedAttributes, List excludedAttributes) { - for (String name : getModelAttributeNames()) { - Attribute attribute = getAttributeMapperByModelAttribute(name); + for (Entry> entry : getAttributeMappers().entrySet()) { + Attribute attribute = entry.getValue(); + + if (!attribute.isExcluded(this, requestedAttributes, excludedAttributes)) { + String modelAttributeName = attribute.getModelAttributeName(); + Object value = getAttributeValue(model, modelAttributeName); - if (attribute != null && !attribute.isExcluded(this, requestedAttributes, excludedAttributes)) { - Object value = getAttributeValue(model, name); attribute.set(resource, value); - resource.addSchema(this.id); + + if (!isInternal()) { + resource.addSchema(this.id); + } } } } - private Attribute getAttributeMapperByModelAttribute(String name) { + protected Map> getAttributeMappers() { + Map> mappers = new HashMap<>(); + + for (String name : getModelAttributeNames()) { + Attribute attribute = getAttributeMapperByModelAttribute(name); + + if (attribute != null) { + mappers.put(name, attribute); + } + } + + return mappers; + } + + protected Attribute getAttributeMapperByModelAttribute(String name) { String scimName = getAttributeSchemaName(name); if (scimName == null) { @@ -323,27 +312,25 @@ public abstract class AbstractModelSchema getPaths(Attribute attr) { + private List resolvePaths(Attribute attr) { List paths = new ArrayList<>(); // the name of the attribute itself is always a valid path paths.add(attr.getName()); - if (!isCore()) { - // if processing an extension schema try to resolve the attribute based on parent name and alias as well - String parent = attr.getParentName(); + if (attr.isExtension()) { + paths.add(attr.getSchema() + ":" + attr.getSimpleName()); + } - if (parent != null) { - paths.add(getId() + attr.getName().replace(parent + ".", ":")); - paths.add(getId() + attr.getName().replace(parent, "")); - } + if (attr.getName().endsWith(".value")) { + paths.add(attr.getSchema() + ":" + attr.getSimpleName().substring(0, attr.getSimpleName().length() - 6)); + } - if (attr.getAlias() != null) { - paths.add(getId() + ":" + attr.getAlias()); - } + if (attr.getAlias() != null) { + paths.add(getId() + ":" + attr.getAlias()); } Class complexType = attr.getComplexType(); @@ -357,25 +344,50 @@ public abstract class AbstractModelSchema resolved, JsonNode value) { - setValue(model, resolved, value, SET); + private void setValue(M model, Attribute attribute, Object value) { + setValue(model, attribute, value, SET); } - private void setValue(M model, Attribute attribute, JsonNode value, Operation operation) { + private void setValue(M model, Attribute attribute, Object value, Operation operation) { Objects.requireNonNull(model, "model cannot be null"); Objects.requireNonNull(attribute, "attribute cannot be null"); - Objects.requireNonNull(value, "value cannot be null"); Objects.requireNonNull(operation, "operation cannot be null"); + JsonNode jsonValue = toJsonNode(value); + switch (operation) { - case SET -> attribute.set(model, value); - case ADD -> attribute.add(model, value); - case REMOVE -> attribute.remove(model, value); + case SET -> attribute.set(model, jsonValue); + case ADD -> attribute.add(model, jsonValue); + case REMOVE -> attribute.remove(model, jsonValue); default -> throw new ModelException("Invalid operation: " + operation); } } - private JsonNode resolveAttributeValue(Attribute attribute,JsonNode jsonNode) { + private JsonNode toJsonNode(Object value) { + if (value == null) { + return null; + } + + if (value instanceof JsonNode) { + return (JsonNode) value; + } else if (value instanceof Collection values) { + ArrayNode nodes = JsonNodeFactory.instance.arrayNode(); + + for (Object v : values) { + if (v instanceof JsonNode jsonNode) { + nodes.add(jsonNode); + } else { + nodes.add(TextNode.valueOf(v.toString())); + } + } + + return nodes; + } + + return TextNode.valueOf(value.toString()); + } + + private JsonNode resolveAttributeValue(Attribute attribute, JsonNode jsonNode) { if (jsonNode.isValueNode()) { Class complexType = attribute.getComplexType(); diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/ModelSchema.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/ModelSchema.java index 8373370ce46..23a0987f25f 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/ModelSchema.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/ModelSchema.java @@ -2,13 +2,17 @@ package org.keycloak.scim.resource.schema; import java.util.List; import java.util.Map; +import java.util.Set; import org.keycloak.models.Model; +import org.keycloak.models.ModelValidationException; import org.keycloak.scim.resource.ResourceTypeRepresentation; import org.keycloak.scim.resource.schema.attribute.Attribute; import com.fasterxml.jackson.databind.JsonNode; +import static java.util.Optional.ofNullable; + /** *

An interface that represents a schema for a resource type. * @@ -70,9 +74,9 @@ public interface ModelSchema getAttributeByPath(String path); + + /** + * Returns {@code true} if this schema is an internal schema, not exposed from the schema endpoint. + * + * @return {@code true} if this schema is an internal schema, {@code false} otherwise + */ + default boolean isInternal() { + return false; + } + + /** + * Returns {@code true} if this schema supports any of the given {@code schemas} + * + * @param schemas the schemas + * @return {@code true} if this schema supports any of the given {@code schemas}. Otherwise, {@code false} + */ + default boolean supports(Set schemas) { + return isCore() || ofNullable(schemas).orElse(Set.of()).contains(getId()); + } } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/SchemaValidationException.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/SchemaValidationException.java deleted file mode 100644 index 05debf4ea1c..00000000000 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/SchemaValidationException.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.keycloak.scim.resource.schema; - -import org.keycloak.scim.resource.ResourceTypeRepresentation; - -/** - * Thrown to indicate errors when validating a {@link ResourceTypeRepresentation} against a {@link ModelSchema}. - */ -public class SchemaValidationException extends RuntimeException { - -} diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java index 091b48c6d84..ad0f6bfecfe 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java @@ -14,6 +14,7 @@ import org.keycloak.scim.resource.schema.ModelSchema; import com.fasterxml.jackson.databind.JsonNode; +import static java.util.Objects.requireNonNull; import static java.util.Optional.ofNullable; /** @@ -29,6 +30,39 @@ public class Attribute { public static final String RETURNED_REQUEST = "request"; public static final String RETURNED_NEVER = "never"; + public static String getSchema(String name) { + requireNonNull(name, "name is required"); + int schemaSeparator = name.lastIndexOf(':'); + + if (schemaSeparator == -1) { + return null; + } + + return name.substring(0, schemaSeparator) + ":" + getResourceType(name); + } + + public static String getResourceType(String name) { + requireNonNull(name, "name is required"); + int schemaSeparator = name.lastIndexOf(':'); + + if (schemaSeparator == -1) { + return null; + } + + String resourceType = name.substring(name.substring(0, schemaSeparator).length() + 1); + return resourceType.substring(0, resourceType.indexOf('.')); + } + + public static String getSimpleName(String name) { + String schema = getSchema(name); + + if (schema == null) { + return name; + } + + return name.substring(schema.length() + 1); + } + /** * Creates a simple attribute with the given {@code name}. * @@ -186,6 +220,27 @@ public class Attribute { return uniqueness; } + public String getSchema() { + if (!isExtension()) { + return null; + } + return getSchema(getName()); + } + + public String getResourceType() { + if (!isExtension()) { + return null; + } + return getResourceType(getName()); + } + + public String getSimpleName() { + if (!isExtension()) { + return null; + } + return getSimpleName(getName()); + } + @Override public boolean equals(Object o) { if (!(o instanceof Attribute attribute)) return false; @@ -264,12 +319,16 @@ public class Attribute { }).anyMatch(this::equals); } + public boolean isExtension() { + return getName().contains(":"); + } + public static class Builder { private final Class complexType; private final String name; private TriConsumer modelSetter; - private BiConsumer representationSetter; + private TriConsumer, R, ?> representationSetter; List> attributes = new ArrayList<>(); // by default, resolve model attribute name as the same as the scim attribute name private Function, String> modelAttributeResolver = Attribute::getName; @@ -284,14 +343,20 @@ public class Attribute { private String uniqueness = "none"; private Builder(String name, Class complexType) { - Objects.requireNonNull(name, "name cannot be null"); + requireNonNull(name, "name cannot be null"); this.complexType = complexType; this.name = name; } - public > Builder withModelSetter(TriConsumer modelSetter, C representationSetter) { + public > Builder withModelSetter(TriConsumer modelSetter, C repSetter) { this.modelSetter = modelSetter; - this.representationSetter = representationSetter; + this.representationSetter = (TriConsumer, R, Object>) (attribute, r, o) -> repSetter.accept(r, (V) o); + return this; + } + + public , R, V>> Builder withSetters(TriConsumer modelSetter, C repSetter) { + this.modelSetter = modelSetter; + this.representationSetter = repSetter; return this; } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/AttributeMapper.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/AttributeMapper.java index 4f488082b21..af2aec34fee 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/AttributeMapper.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/AttributeMapper.java @@ -3,7 +3,6 @@ package org.keycloak.scim.resource.schema.attribute; import java.io.IOException; import java.util.HashSet; import java.util.Set; -import java.util.function.BiConsumer; import java.util.stream.Collectors; import org.keycloak.common.util.TriConsumer; @@ -24,13 +23,13 @@ public class AttributeMapper modelSetter; private TriConsumer modelRemover; private TriConsumer modelAdder; - private final BiConsumer representationSetter; + private final TriConsumer, R, ?> representationSetter; - AttributeMapper(TriConsumer modelSetter, BiConsumer representationSetter) { + AttributeMapper(TriConsumer modelSetter, TriConsumer, R, ?> representationSetter) { this(modelSetter, representationSetter, null, null); } - AttributeMapper(TriConsumer modelSetter, BiConsumer representationSetter, TriConsumer modelRemover, TriConsumer modelAdder) { + AttributeMapper(TriConsumer modelSetter, TriConsumer, R, ?> representationSetter, TriConsumer modelRemover, TriConsumer modelAdder) { this.modelSetter = modelSetter; this.representationSetter = representationSetter; this.modelRemover = modelRemover; @@ -39,7 +38,7 @@ public class AttributeMapper) representationSetter).accept(representation, value); + ((TriConsumer, R, Object>) representationSetter).accept(attribute, representation, value); } } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/ComplexAttributeSetter.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/ComplexAttributeSetter.java index dfa6d7fe7ee..7b4c5482ee9 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/ComplexAttributeSetter.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/ComplexAttributeSetter.java @@ -5,12 +5,13 @@ import java.lang.reflect.ParameterizedType; import java.util.ArrayList; import java.util.Collection; import java.util.Objects; -import java.util.function.BiConsumer; +import org.keycloak.common.util.TriConsumer; +import org.keycloak.models.Model; import org.keycloak.scim.resource.ResourceTypeRepresentation; import org.keycloak.scim.resource.common.MultiValuedAttribute; -public class ComplexAttributeSetter implements BiConsumer { +public class ComplexAttributeSetter implements TriConsumer, R, String> { private final String name; private final String subName; @@ -28,7 +29,7 @@ public class ComplexAttributeSetter implem } @Override - public void accept(R representation, String newValue) { + public void accept(Attribute attribute, R representation, String newValue) { try { Method declaredMethod = representation.getClass().getMethod("get" + Character.toUpperCase(name.charAt(0)) + name.substring(1)); Class returnType = declaredMethod.getReturnType(); diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/spi/AbstractScimResourceTypeProvider.java b/scim/core/src/main/java/org/keycloak/scim/resource/spi/AbstractScimResourceTypeProvider.java index b1e9d63b5f6..fa91866c723 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/spi/AbstractScimResourceTypeProvider.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/spi/AbstractScimResourceTypeProvider.java @@ -17,6 +17,8 @@ import org.keycloak.scim.resource.schema.ModelSchema; import com.fasterxml.jackson.databind.JsonNode; +import static java.util.function.Predicate.not; + import static org.keycloak.utils.StringUtil.isBlank; public abstract class AbstractScimResourceTypeProvider implements ScimResourceTypeProvider { @@ -155,7 +157,7 @@ public abstract class AbstractScimResourceTypeProvider getSchemaExtensions() { - return schemaExtensions.stream().map(ModelSchema::getId).toList(); + return schemaExtensions.stream().filter(not(ModelSchema::isInternal)).map(ModelSchema::getId).toList(); } protected abstract R onCreate(R resource); @@ -172,7 +174,7 @@ public abstract class AbstractScimResourceTypeProvider schema : schemas) { - if (resource.hasSchema(schema.getId())) { + if (schema.supports(resource.getSchemas())) { schema.populate(model, resource); } } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java index 0be62912b36..8b083baf7b4 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java @@ -25,6 +25,8 @@ import org.keycloak.scim.resource.schema.AbstractModelSchema; import org.keycloak.scim.resource.schema.attribute.Attribute; import org.keycloak.utils.KeycloakSessionUtil; +import static org.keycloak.utils.StringUtil.isBlank; + public final class GroupCoreModelSchema extends AbstractModelSchema { public GroupCoreModelSchema() { @@ -76,7 +78,7 @@ public final class GroupCoreModelSchema extends AbstractModelSchema> doGetAttributes() { + protected Map> getAttributeMappers() { List> attributes = new ArrayList<>(Attribute.simple("displayName") .notCaseExact() .modelAttributeResolver((attribute) -> { @@ -163,6 +165,13 @@ public final class GroupCoreModelSchema extends AbstractModelSchema members = resource.getMembers(); if (!Optional.ofNullable(members).orElse(List.of()).isEmpty()) { - throw new ModelValidationException("Managing members on updates are not supported"); + throw new ModelValidationException("Managing members on updates is not supported"); } return super.update(resource); diff --git a/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java index 499b9edd427..1cda8727b47 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java @@ -22,6 +22,7 @@ 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. @@ -50,8 +51,7 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider>) factory -> { ScimResourceTypeProvider provider = session.getProvider(ScimResourceTypeProvider.class, factory.getId()); - List modelSchemas = provider.getSchemas(); - return modelSchemas.stream(); + return provider.getSchemas().stream(); }).forEach(this::buildSchema); } @@ -73,6 +73,11 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider { - Attribute attr = new Attribute(); - attr.setName(k); - attr.setType(attribute.getType()); - attr.setMultiValued(attribute.isMultivalued()); - attr.setReturned(attribute.getReturned()); - attr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite"); - attr.setRequired(attribute.isRequired()); - attr.setCaseExact(attribute.isCaseExact()); - attr.setUniqueness(attribute.getUniqueness()); - return attr; - }); + topLevelAttributes.computeIfAbsent(relativeName, createExtensionAttribute(modelSchema, parentName, attribute)); } } 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()); - attr.setReturned(attribute.getReturned()); - attr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite"); - attr.setRequired(attribute.isRequired()); - attr.setCaseExact(attribute.isCaseExact()); - attr.setUniqueness(attribute.getUniqueness()); - return attr; - }); + topLevelAttributes.computeIfAbsent(name, k -> createTopLevelAttribute(attribute, k)); } } rep.setAttributes(List.copyOf(topLevelAttributes.values())); - schemas.put(modelSchema.getId(), rep); + + if (!modelSchema.isInternal()) { + schemas.put(modelSchema.getId(), rep); + } + } + + private Function createExtensionAttribute(ModelSchema modelSchema, String schemaName, org.keycloak.scim.resource.schema.attribute.Attribute attribute) { + return k -> { + Attribute attr = createTopLevelAttribute(attribute, k); + + if (modelSchema.isCore()) { + return attr; + } + + schemas.computeIfAbsent(schemaName, n -> { + Schema schema = new Schema(); + + schema.setName(n); + schema.setId(n); + schema.setAttributes(new ArrayList<>()); + + return schema; + }).getAttributes().add(attr); + + return attr; + }; + } + + private Attribute createTopLevelAttribute(org.keycloak.scim.resource.schema.attribute.Attribute attribute, String name) { + Attribute attr = new Attribute(); + + attr.setName(name); + attr.setType(attribute.getType()); + attr.setMultiValued(attribute.isMultivalued()); + attr.setReturned(attribute.getReturned()); + attr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite"); + attr.setRequired(attribute.isRequired()); + attr.setCaseExact(attribute.isCaseExact()); + attr.setUniqueness(attribute.getUniqueness()); + + return attr; } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/AbstractUserModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/user/AbstractUserModelSchema.java index c7ce5562dc1..fab92526e77 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/AbstractUserModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/AbstractUserModelSchema.java @@ -18,6 +18,8 @@ import org.keycloak.userprofile.UserProfileProvider; import static java.util.Optional.ofNullable; +import static org.keycloak.scim.resource.schema.attribute.Attribute.getSchema; + public abstract class AbstractUserModelSchema extends AbstractModelSchema { public static final String ANNOTATION_SCIM_SCHEMA_ATTRIBUTE = "kc.scim.schema.attribute"; @@ -30,11 +32,13 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema getModelAttributeNames() { - UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, Map.of()); + UserProfile profile = getUserProfile(); Attributes attributes = profile.getAttributes(); Set names = new HashSet<>(attributes.nameSet()); + names.add(UserModel.ENABLED); names.add("groups"); + return names; } @@ -95,4 +99,12 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema> doGetAttributes() { + protected boolean hasSchema(String attributeName) { + String schema = Attribute.getSchema(attributeName); + + return schema == null || getId().equals(schema); + } + + @Override + protected Map> getAttributeMappers() { List> attributes = new ArrayList<>(); attributes.addAll(Attribute.simple("userName") @@ -131,7 +138,12 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema { .bool() .withModelSetter( (model, name, value) -> model.setEnabled(Boolean.parseBoolean(Optional.ofNullable(value).orElse("").toString())) - , (user, value) -> user.setActive(Boolean.parseBoolean(value.toString())) + , (user, value) -> { + if (value == null) { + return; + } + user.setActive(Boolean.parseBoolean(value.toString())); + } ) .build()); attributes.addAll(Attribute.simple("meta.created") diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserEnterpriseModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserEnterpriseModelSchema.java index ee4e2d089d8..e766a3f1b4a 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/UserEnterpriseModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/UserEnterpriseModelSchema.java @@ -1,19 +1,12 @@ package org.keycloak.scim.model.user; -import java.util.ArrayList; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserModel; import org.keycloak.scim.resource.schema.attribute.Attribute; -import org.keycloak.scim.resource.user.EnterpriseUser; -import org.keycloak.scim.resource.user.User; import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA; -public final class UserEnterpriseModelSchema extends AbstractUserModelSchema { +public final class UserEnterpriseModelSchema extends UserExtensionModelSchema { public UserEnterpriseModelSchema(KeycloakSession session) { super(session, ENTERPRISE_USER_SCHEMA); @@ -35,21 +28,12 @@ public final class UserEnterpriseModelSchema extends AbstractUserModelSchema { } @Override - public boolean isCore() { + public boolean isInternal() { return false; } @Override - protected Map> doGetAttributes() { - return new ArrayList<>(Attribute.complex("enterpriseUser", EnterpriseUser.class) - .modelAttributeResolver(this::createModelAttributeResolver) - .withAttribute("employeeNumber", UserModel::setSingleAttribute) - .withAttribute("costCenter", UserModel::setSingleAttribute) - .withAttribute("organization", UserModel::setSingleAttribute) - .withAttribute("division", UserModel::setSingleAttribute) - .withAttribute("department", UserModel::setSingleAttribute) - .withAttribute("manager.value", "manager", UserModel::setSingleAttribute) - .withAttribute("manager.displayName", UserModel::setSingleAttribute) - .build()).stream().collect(Collectors.toMap(Attribute::getName, Function.identity())); + protected boolean hasSchema(String attributeName) { + return getId().equals(Attribute.getSchema(attributeName)); } } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserExtensionModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserExtensionModelSchema.java new file mode 100644 index 00000000000..5d4c03268f4 --- /dev/null +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/UserExtensionModelSchema.java @@ -0,0 +1,225 @@ +package org.keycloak.scim.model.user; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.scim.resource.schema.attribute.Attribute; +import org.keycloak.scim.resource.user.User; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.Attributes; +import org.keycloak.userprofile.UserProfile; + +import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA; +import static org.keycloak.scim.resource.Scim.USER_CORE_SCHEMA; +import static org.keycloak.userprofile.UserProfileUtil.isRootAttribute; + +public class UserExtensionModelSchema extends AbstractUserModelSchema { + + public static final String KEYCLOAK_USER_SCHEMA = "urn:keycloak:params:scim:schemas:extension:realm:1.0:User"; + + private final KeycloakSession session; + + public UserExtensionModelSchema(KeycloakSession session) { + this(session, KEYCLOAK_USER_SCHEMA); + } + + public UserExtensionModelSchema(KeycloakSession session, String schema) { + super(session, schema); + this.session = session; + } + + @Override + public String getId() { + return KEYCLOAK_USER_SCHEMA; + } + + @Override + public String getName() { + return "RealmUser"; + } + + @Override + public String getDescription() { + return "Realm User"; + } + + @Override + public boolean isCore() { + return false; + } + + @Override + public boolean isInternal() { + return true; + } + + @Override + public boolean supports(Set schemas) { + for (Attribute value : getAttributes().values()) { + String schema = value.getSchema(); + + if (schema != null && schemas.contains(schema)) { + return true; + } + } + + return false; + } + + @Override + protected Set getModelAttributeNames() { + if (isCore()) { + return Set.of(); + } + + Set names = new HashSet<>(); + UserProfile profile = getUserProfile(); + + for (String name : profile.getAttributes().nameSet()) { + if (isRootAttribute(name)) { + continue; + } + + AttributeMetadata metadata = profile.getAttributes().getMetadata(name); + + if (metadata == null) { + continue; + } + + String scimName = (String) metadata.getAnnotations().get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE); + + if (scimName == null || !scimName.contains(":")) { + continue; + } + + names.add(name); + } + + return names; + } + + @Override + protected Attribute getAttributeMapperByModelAttribute(String name) { + UserProfile profile = getUserProfile(); + Attributes attributes = profile.getAttributes(); + AttributeMetadata metadata = attributes.getMetadata(name); + + if (metadata == null) { + return null; + } + + Map annotations = metadata.getAnnotations(); + + if (annotations == null) { + return null; + } + + String scimName = (String) annotations.get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE); + + if (scimName == null) { + return null; + } + + if (!hasSchema(scimName)) { + return null; + } + + return createCustomAttribute(scimName); + } + + @Override + protected boolean hasSchema(String attributeName) { + String schema = Attribute.getSchema(attributeName); + + // it should be possible to query other schemas from the providers + return schema != null && !List.of(USER_CORE_SCHEMA, ENTERPRISE_USER_SCHEMA).contains(schema); + } + + private Attribute createCustomAttribute(Object scimName) { + return Attribute.simple(scimName.toString()) + .modelAttributeResolver(attribute -> { + if (isCore()) { + return null; + } + UserProfile profile = getUserProfile(); + Attributes attributes = profile.getAttributes(); + + for (String modelName : attributes.nameSet()) { + AttributeMetadata metadata = attributes.getMetadata(modelName); + + if (metadata == null) { + return null; + } + + Map annotations = metadata.getAnnotations(); + + if (annotations == null) { + return null; + } + + Object modelScimAttributeName = annotations.get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE); + + if (attribute.getName().equals(modelScimAttributeName)) { + return modelName; + } + } + + return null; + }) + .withSetters((model, name, value) -> { + if (isCore()) { + return; + } + if (getAttributeMapperByModelAttribute(name) == null) { + return; + } + if (value == null) { + model.removeAttribute(name); + } else { + model.setSingleAttribute(name, value.toString()); + } + }, (attribute, user, value) -> { + if (isCore()) { + return; + } + + String schema = attribute.getSchema(); + + if (schema == null) { + return; + } + + String attributeName = attribute.getSimpleName(); + Map extensions = user.getExtensions(); + + if (extensions == null) { + extensions = new HashMap<>(); + user.setExtensions(extensions); + } + + Map subAttributes = (Map) extensions.get(schema); + + if (subAttributes == null) { + subAttributes = new HashMap<>(); + extensions.put(schema, subAttributes); + user.addSchema(schema); + } + + int subSubAttribute = attributeName.indexOf('.'); + + if (subSubAttribute != -1) { + String parentAttributeName = attributeName.substring(0, subSubAttribute); + subAttributes.put(parentAttributeName, new HashMap<>()); + subAttributes = (Map) subAttributes.get(parentAttributeName); + attributeName = attributeName.substring(parentAttributeName.length() + 1); + } + + subAttributes.put(attributeName, value); + }).build().get(0); + } +} diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserResourceTypeProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserResourceTypeProvider.java index 249d07dd775..c9fc2f5faf9 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/UserResourceTypeProvider.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/UserResourceTypeProvider.java @@ -49,7 +49,7 @@ import static org.keycloak.utils.StreamsUtil.closing; public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider implements ScimAttributeJpaExpressionResolver { public UserResourceTypeProvider(KeycloakSession session) { - super(session, new UserCoreModelSchema(session), List.of(new UserEnterpriseModelSchema(session))); + super(session, new UserCoreModelSchema(session), List.of(new UserEnterpriseModelSchema(session), new UserExtensionModelSchema(session))); } @Override diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/AbstractScimTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/AbstractScimTest.java index 1862a8d2e1c..8d9675c3c75 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/AbstractScimTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/AbstractScimTest.java @@ -10,6 +10,10 @@ package org.keycloak.tests.scim.tck; +import java.util.Map; + +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.scim.client.ScimClient; import org.keycloak.testframework.annotations.InjectAdminEvents; import org.keycloak.testframework.annotations.InjectRealm; @@ -17,6 +21,9 @@ import org.keycloak.testframework.events.AdminEvents; import org.keycloak.testframework.realm.ManagedRealm; import org.keycloak.testframework.scim.client.annotations.InjectScimClient; +import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE; +import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA; + public abstract class AbstractScimTest { @InjectRealm(config = ScimRealmConfig.class) @@ -27,4 +34,25 @@ public abstract class AbstractScimTest { @InjectAdminEvents AdminEvents adminEvents; + + protected void addEnterpriseUserUserProfileAttributes() { + UPConfig configuration = realm.admin().users().userProfile().getConfiguration(); + + configuration.addOrReplaceAttribute(new UPAttribute("department", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".department"))); + configuration.addOrReplaceAttribute(new UPAttribute("division", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".division"))); + configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".costCenter"))); + configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber"))); + configuration.addOrReplaceAttribute(new UPAttribute("organization", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".organization"))); + configuration.addOrReplaceAttribute(new UPAttribute("manager", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.value"))); + configuration.addOrReplaceAttribute(new UPAttribute("managerName", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.displayName"))); + realm.admin().users().userProfile().update(configuration); + } + } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java index 9d384bbbcf4..c839b33dcec 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java @@ -706,6 +706,8 @@ public class FilterTest extends AbstractScimTest { user.setFirstName(givenName); user.setLastName(familyName); user = client.users().create(user); + assertNotNull(user); + user = client.users().get(user.getId()); userIdsToRemove.add(user.getId()); return user; } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java index 1e302fb845a..419eca8a92f 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java @@ -389,7 +389,7 @@ public class GroupTest extends AbstractScimTest { } catch (ScimClientException e) { ErrorResponse error = e.getError(); assertNotNull(error); - assertEquals("Managing members on updates are not supported", error.getDetail()); + assertEquals("Managing members on updates is not supported", error.getDetail()); } } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java index fd6b41be16a..007d4224f7f 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java @@ -1,19 +1,27 @@ package org.keycloak.tests.scim.tck; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPAttributePermissions; +import org.keycloak.representations.userprofile.config.UPConfig; 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.keycloak.userprofile.config.UPConfigUtils; import org.junit.jupiter.api.Test; +import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE; + 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.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @KeycloakIntegrationTest(config = ScimServerConfig.class) @@ -21,12 +29,15 @@ public class SchemaTest extends AbstractScimTest { @Test public void testGetAllSchemas() { + String customSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User"; + addOrReplaceUPAttribute(customSchema, "myattribute"); + ListResponse response = client.schemas().getAll(); assertNotNull(response); assertNotNull(response.getResources()); - assertEquals(3, response.getTotalResults()); - assertEquals(3, response.getResources().size()); + assertEquals(4, response.getTotalResults()); + assertEquals(4, response.getResources().size()); // Verify all expected schemas are present List schemaIds = response.getResources().stream() @@ -36,6 +47,7 @@ public class SchemaTest extends AbstractScimTest { assertTrue(schemaIds.contains(Scim.USER_CORE_SCHEMA)); assertTrue(schemaIds.contains(Scim.GROUP_CORE_SCHEMA)); assertTrue(schemaIds.contains(Scim.ENTERPRISE_USER_SCHEMA)); + assertTrue(schemaIds.contains(customSchema)); } @Test @@ -111,6 +123,7 @@ public class SchemaTest extends AbstractScimTest { @Test public void testGetEnterpriseUserSchema() { + addEnterpriseUserUserProfileAttributes(); Schema schema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA); assertNotNull(schema); @@ -146,6 +159,37 @@ public class SchemaTest extends AbstractScimTest { } } + @Test + public void testGetCustomSchema() { + String fooSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User"; + addOrReplaceUPAttribute(fooSchema, "myattribute"); + addOrReplaceUPAttribute(fooSchema, "team"); + + String barSchema = "urn:my:params:scim:schemas:extension:other:1.0:User"; + addOrReplaceUPAttribute(barSchema, "other"); + + Schema schema = client.schemas().get(fooSchema); + assertNotNull(schema); + assertEquals(fooSchema, schema.getId()); + assertEquals(fooSchema, schema.getName()); + assertNull(schema.getDescription()); + assertNotNull(schema.getAttributes()); + Set attributeNames = schema.getAttributes().stream() + .map(Schema.Attribute::getName) + .collect(Collectors.toSet()); + assertEquals(2, attributeNames.size()); + assertAttribute(findAttribute(schema, "myattribute"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "team"), "string", false, false, true, "readWrite", "none"); + + schema = client.schemas().get(barSchema); + attributeNames = schema.getAttributes().stream() + .map(Schema.Attribute::getName) + .collect(Collectors.toSet()); + assertEquals(1, attributeNames.size()); + // Simple string attributes + assertAttribute(findAttribute(schema, "other"), "string", false, false, true, "readWrite", "none"); + } + @Test public void testNoDuplicateAttributes() { // Verify that attributes are not duplicated even when multiple SCIM paths @@ -203,4 +247,12 @@ public class SchemaTest extends AbstractScimTest { assertEquals(multiValued, subAttribute.getMultiValued(), subAttribute.getName() + " sub-attribute multiValued"); assertEquals(mutability, subAttribute.getMutability(), subAttribute.getName() + " sub-attribute mutability"); } + + private void addOrReplaceUPAttribute(String customSchema, String name) { + UPConfig upConfig = realm.admin().users().userProfile().getConfiguration(); + UPAttribute upAttribute = new UPAttribute("scim" + name, Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, customSchema + "." + name)); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + } } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java index 810bb513419..02f1580cd74 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java @@ -1,6 +1,7 @@ package org.keycloak.tests.scim.tck; import java.time.Instant; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -18,8 +19,10 @@ import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPAttributePermissions; import org.keycloak.representations.userprofile.config.UPAttributeRequired; import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.scim.client.ResourceFilter; import org.keycloak.scim.client.ScimClient; import org.keycloak.scim.client.ScimClientException; import org.keycloak.scim.protocol.request.PatchRequest; @@ -39,13 +42,17 @@ import org.keycloak.testframework.realm.GroupBuilder; import org.keycloak.testframework.realm.UserBuilder; import org.keycloak.testframework.scim.client.annotations.InjectScimClient; import org.keycloak.testframework.util.ApiUtil; +import org.keycloak.userprofile.config.UPConfigUtils; import com.fasterxml.jackson.annotation.JsonProperty; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import static java.util.Optional.ofNullable; + import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE; +import static org.keycloak.scim.model.user.UserExtensionModelSchema.KEYCLOAK_USER_SCHEMA; import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA; import static org.keycloak.scim.resource.Scim.USER_RESOURCE_TYPE; import static org.keycloak.scim.resource.Scim.getCoreSchema; @@ -53,6 +60,7 @@ import static org.keycloak.scim.resource.Scim.getCoreSchema; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -1079,6 +1087,186 @@ public class UserTest extends AbstractScimTest { assertNull(actual.getEnterpriseUser()); } + @Test + public void testCreateCustomAttribute() { + UPConfig upConfig = realm.admin().users().userProfile().getConfiguration(); + String fooSchema = "urn:my:params:scim:schemas:extension:foo:1.0:User"; + UPAttribute upAttribute = new UPAttribute("keycloak.team", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, fooSchema + ".memberOf")); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + + String barSchema = "urn:my:params:scim:schemas:extension:bar:1.0:User"; + upAttribute = new UPAttribute("keycloak.area", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, barSchema + ".myattribute")); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + + User user = new User(); + + user.addSchema(fooSchema); + user.addSchema(barSchema); + user.setUserName(KeycloakModelUtils.generateId()); + user.setExtensions(new HashMap<>()); + HashMap keycloakSchema = new HashMap<>(); + keycloakSchema.put("memberOf", "core-iam"); + user.getExtensions().put(fooSchema, keycloakSchema); + HashMap customSchemaValues = new HashMap<>(); + customSchemaValues.put("myattribute", "myvalue"); + user.getExtensions().put(barSchema, customSchemaValues); + + try { + user = client.users().create(user); + user = client.users().get(user.getId()); + Object value = ofNullable(user.getExtensions()).orElse(Map.of()).get(fooSchema); + assertInstanceOf(Map.class, value); + assertTrue(user.getSchemas().contains(fooSchema)); + assertEquals("core-iam", ((Map) value).get("memberOf")); + value = ofNullable(user.getExtensions()).orElse(Map.of()).get(barSchema); + assertInstanceOf(Map.class, value); + assertTrue(user.getSchemas().contains(barSchema)); + assertEquals("myvalue", ((Map) value).get("myattribute")); + } finally { + client.users().delete(user.getId()); + } + } + + @Test + public void testGetCustomAttribute() { + UserRepresentation existing = UserBuilder.create() + .username(KeycloakModelUtils.generateId()) + .email(KeycloakModelUtils.generateId() + "@keycloak.org") + .firstName("f") + .lastName("l") + .enabled(true) + .build(); + try (Response response = realm.admin().users().create(existing)) { + String id = ApiUtil.getCreatedId(response); + existing.setId(id); + } + + // adds a user profile attribute + UPConfig upConfig = realm.admin().users().userProfile().getConfiguration(); + UPAttribute upAttribute = new UPAttribute("keycloak.team", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, KEYCLOAK_USER_SCHEMA + ".memberOf")); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + existing = realm.admin().users().get(existing.getId()).toRepresentation(); + existing.singleAttribute(upAttribute.getName(), "core-iam"); + realm.admin().users().get(existing.getId()).update(existing); + + String customSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User"; + upAttribute = new UPAttribute("keycloak.area", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, customSchema + ".myattribute")); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + existing = realm.admin().users().get(existing.getId()).toRepresentation(); + existing.singleAttribute(upAttribute.getName(), "myvalue"); + realm.admin().users().get(existing.getId()).update(existing); + + existing = realm.admin().users().get(existing.getId()).toRepresentation(); + assertNotNull(ofNullable(existing.getAttributes()).orElse(Map.of()).get(upAttribute.getName())); + + User user = client.users().get(existing.getId()); + Object value = ofNullable(user.getExtensions()).orElse(Map.of()).get(KEYCLOAK_USER_SCHEMA); + assertInstanceOf(Map.class, value); + assertTrue(user.getSchemas().contains(KEYCLOAK_USER_SCHEMA)); + assertEquals("core-iam", ((Map) value).get("memberOf")); + value = ofNullable(user.getExtensions()).orElse(Map.of()).get(customSchema); + assertInstanceOf(Map.class, value); + assertTrue(user.getSchemas().contains(customSchema)); + assertEquals("myvalue", ((Map) value).get("myattribute")); + } + + @Test + public void testSearchCustomAttribute() { + UserRepresentation existing = UserBuilder.create() + .username(KeycloakModelUtils.generateId()) + .email(KeycloakModelUtils.generateId() + "@keycloak.org") + .firstName("f") + .lastName("l") + .enabled(true) + .build(); + try (Response response = realm.admin().users().create(existing)) { + String id = ApiUtil.getCreatedId(response); + existing.setId(id); + } + + // adds a user profile attribute + UPConfig upConfig = realm.admin().users().userProfile().getConfiguration(); + UPAttribute upAttribute = new UPAttribute("keycloak.team", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, KEYCLOAK_USER_SCHEMA + ".memberOf")); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + existing = realm.admin().users().get(existing.getId()).toRepresentation(); + existing.singleAttribute(upAttribute.getName(), "core-iam"); + realm.admin().users().get(existing.getId()).update(existing); + + String customSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User"; + upAttribute = new UPAttribute("keycloak.area", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, customSchema + ".myattribute")); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + existing = realm.admin().users().get(existing.getId()).toRepresentation(); + existing.singleAttribute(upAttribute.getName(), "myvalue"); + realm.admin().users().get(existing.getId()).update(existing); + + ListResponse result = client.users().search(ResourceFilter.filter().eq(KEYCLOAK_USER_SCHEMA + ".memberOf", "core-iam").build()); + assertEquals(1, result.getTotalResults()); + result = client.users().search(ResourceFilter.filter().eq(KEYCLOAK_USER_SCHEMA + ".memberOf", "non-existent").build()); + assertEquals(0, result.getTotalResults()); + + result = client.users().search(ResourceFilter.filter().eq(customSchema + ".myattribute", "myvalue").build()); + assertEquals(1, result.getTotalResults()); + } + + @Test + public void testPatchCustomAttribute() { + UPConfig upConfig = realm.admin().users().userProfile().getConfiguration(); + UPAttribute upAttribute = new UPAttribute("keycloak.team", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, KEYCLOAK_USER_SCHEMA + ".memberOf")); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + + String customSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User"; + upAttribute = new UPAttribute("keycloak.area", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, customSchema + ".myattribute")); + upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN))); + upConfig.addOrReplaceAttribute(upAttribute); + realm.admin().users().userProfile().update(upConfig); + + User user = new User(); + + user.addSchema(KEYCLOAK_USER_SCHEMA); + user.addSchema(customSchema); + user.setUserName(KeycloakModelUtils.generateId()); + user.setExtensions(new HashMap<>()); + HashMap keycloakSchema = new HashMap<>(); + keycloakSchema.put("memberOf", "core-iam"); + user.getExtensions().put(KEYCLOAK_USER_SCHEMA, keycloakSchema); + HashMap customSchemaValues = new HashMap<>(); + customSchemaValues.put("myattribute", "myvalue"); + user.getExtensions().put(customSchema, customSchemaValues); + + try { + user = client.users().create(user); + client.users().patch(user.getId(), PatchRequest.create() + .replace(KEYCLOAK_USER_SCHEMA + ":memberOf", "core-iam-updated") + .replace(customSchema + ":myattribute", "myvalue-updated") + .build()); + user = client.users().get(user.getId()); + Object value = ofNullable(user.getExtensions()).orElse(Map.of()).get(KEYCLOAK_USER_SCHEMA); + assertInstanceOf(Map.class, value); + assertTrue(user.getSchemas().contains(KEYCLOAK_USER_SCHEMA)); + assertEquals("core-iam-updated", ((Map) value).get("memberOf")); + value = ofNullable(user.getExtensions()).orElse(Map.of()).get(customSchema); + assertInstanceOf(Map.class, value); + assertTrue(user.getSchemas().contains(customSchema)); + assertEquals("myvalue-updated", ((Map) value).get("myattribute")); + } finally { + client.users().delete(user.getId()); + } + } + private static void assertGroup(List groups, GroupRepresentation group, String type) { assertTrue(groups.stream().anyMatch(membership -> { boolean found = group.getId().equals(membership.getValue()) && group.getName().equals(membership.getDisplay()); @@ -1197,6 +1385,7 @@ public class UserTest extends AbstractScimTest { return user; } + @Disabled("Update attribute validation after adding custom attributes") @Test public void testCreateWithInvalidAttribute() { User user = new User() { @@ -1240,24 +1429,4 @@ public class UserTest extends AbstractScimTest { assertNotNull(error.getDetail()); } } - - private void addEnterpriseUserUserProfileAttributes() { - UPConfig configuration = realm.admin().users().userProfile().getConfiguration(); - - configuration.addOrReplaceAttribute(new UPAttribute("department", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".department"))); - configuration.addOrReplaceAttribute(new UPAttribute("division", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".division"))); - configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".costCenter"))); - configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber"))); - configuration.addOrReplaceAttribute(new UPAttribute("organization", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".organization"))); - configuration.addOrReplaceAttribute(new UPAttribute("manager", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.value"))); - configuration.addOrReplaceAttribute(new UPAttribute("managerName", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.displayName"))); - realm.admin().users().userProfile().update(configuration); - } }