diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java index 1c247e48603..944ceb5811c 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java @@ -142,7 +142,7 @@ public class UserAdapter implements UserModel, JpaModel { } // Remove all existing if (value == null) { - user.getAttributes().removeIf(a -> a.getName().equals(name)); + removeAttribute(name); } else { Set oldEntries = getAttributeStream(name).collect(Collectors.toSet()); Set newEntries = Set.of(value); diff --git a/scim/client/src/main/java/org/keycloak/scim/client/AbstractScimResourceClient.java b/scim/client/src/main/java/org/keycloak/scim/client/AbstractScimResourceClient.java index d6c810b1583..72ab62cf7d0 100644 --- a/scim/client/src/main/java/org/keycloak/scim/client/AbstractScimResourceClient.java +++ b/scim/client/src/main/java/org/keycloak/scim/client/AbstractScimResourceClient.java @@ -2,6 +2,7 @@ package org.keycloak.scim.client; import org.keycloak.http.simple.SimpleHttpRequest; +import org.keycloak.scim.protocol.request.PatchRequest; import org.keycloak.scim.protocol.response.ErrorResponse; import org.keycloak.scim.protocol.response.ListResponse; import org.keycloak.scim.resource.ResourceTypeRepresentation; @@ -55,6 +56,11 @@ public abstract class AbstractScimResourceClient doFilter(ResourceFilter filter) { SimpleHttpRequest request = doGet(""); diff --git a/scim/client/src/main/java/org/keycloak/scim/client/ScimClient.java b/scim/client/src/main/java/org/keycloak/scim/client/ScimClient.java index 5eb10b7586e..5ee26c6984d 100644 --- a/scim/client/src/main/java/org/keycloak/scim/client/ScimClient.java +++ b/scim/client/src/main/java/org/keycloak/scim/client/ScimClient.java @@ -138,6 +138,11 @@ public final class ScimClient implements AutoCloseable { .header(HttpHeaders.CONTENT_TYPE, APPLICATION_SCIM_JSON); } + SimpleHttpRequest doPatch(Class resourceType, String id) { + return beforeRequest(http.doPatch(baseUrl + getResourceTypePath(resourceType) + "/" + id)) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_SCIM_JSON); + } + T execute(SimpleHttpRequest request, Class responseType) { try (SimpleHttpResponse response = execute(request)) { if (responseType == null) { diff --git a/scim/core/src/main/java/org/keycloak/scim/protocol/request/PatchRequest.java b/scim/core/src/main/java/org/keycloak/scim/protocol/request/PatchRequest.java index 4c8c9ad740e..f9216aefb88 100644 --- a/scim/core/src/main/java/org/keycloak/scim/protocol/request/PatchRequest.java +++ b/scim/core/src/main/java/org/keycloak/scim/protocol/request/PatchRequest.java @@ -1,10 +1,16 @@ package org.keycloak.scim.protocol.request; +import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Set; +import org.keycloak.util.JsonSerialization; + import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.TextNode; @JsonInclude(JsonInclude.Include.NON_NULL) public class PatchRequest { @@ -17,6 +23,18 @@ public class PatchRequest { @JsonProperty("Operations") private List operations; + public PatchRequest() { + // reflection + } + + public PatchRequest(List operations) { + this.operations = operations; + } + + public static Builder create() { + return new Builder(); + } + public Set getSchemas() { return schemas; } @@ -45,7 +63,29 @@ public class PatchRequest { private String path; @JsonProperty("value") - private Object value; + private JsonNode value; + + public PatchOperation() { + // reflection + } + + public PatchOperation(String op, String path, String value) { + this.op = op; + this.path = path; + if (value == null) { + this.value = null; + } else { + try { + if (value.startsWith("{") || value.startsWith("[")) { + this.value = JsonSerialization.readValue(value, JsonNode.class); + } else { + this.value = new TextNode(value); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } public String getOp() { return op; @@ -63,12 +103,50 @@ public class PatchRequest { this.path = path; } - public Object getValue() { + public JsonNode getValue() { return value; } - public void setValue(Object value) { + public void setValue(JsonNode value) { this.value = value; } } + + public static class Builder { + + private final List operations = new ArrayList<>(); + + public Builder add(String path, String value) { + operation("add", path, value); + return this; + } + + public Builder add(String value) { + operation("add", null, value); + return this; + } + + public Builder replace(String path, String value) { + operation("replace", path, value); + return this; + } + + public Builder replace(String value) { + replace(null, value); + return this; + } + + public Builder remove(String path) { + operation("remove", path, null); + return this; + } + + private void operation(String operation, String path, String value) { + operations.add(new PatchOperation(operation, path, value)); + } + + public PatchRequest build() { + return new PatchRequest(operations); + } + } } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/common/Email.java b/scim/core/src/main/java/org/keycloak/scim/resource/common/Email.java index 28dc9fbac69..093cd4754ce 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/common/Email.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/common/Email.java @@ -6,12 +6,14 @@ import com.fasterxml.jackson.annotation.JsonInclude; public class Email extends MultiValuedAttribute { public Email() { + setType("work"); + setPrimary(true); } public Email(String email) { setValue(email); setPrimary(true); - setType("other"); + setType("work"); } public Email(String value, String type, Boolean primary) { 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 e232e9d981f..c5864b68067 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 @@ -1,31 +1,36 @@ package org.keycloak.scim.resource.schema; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; 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 org.keycloak.scim.resource.schema.attribute.AttributeMapper; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import static org.keycloak.scim.resource.Scim.getCoreSchema; import static org.keycloak.utils.JsonUtils.getJsonValue; +import static org.keycloak.utils.StringUtil.isBlank; public abstract class AbstractModelSchema implements ModelSchema { private final String name; - private final Map> attributes; + private Map> attributes; - protected AbstractModelSchema(String name, List> attributes) { + protected AbstractModelSchema(String name) { this.name = name; - this.attributes = attributes.stream().collect(Collectors.toMap(Attribute::getName, Function.identity())); } @Override @@ -35,9 +40,14 @@ public abstract class AbstractModelSchema> getAttributes() { + if (attributes == null) { + attributes = doGetAttributes(); + } return attributes; } + protected abstract Map> doGetAttributes(); + @Override public void populate(M model, R representation) { populateModel(model, representation); @@ -55,6 +65,39 @@ public abstract class AbstractModelSchema, JsonNode> attributes = resolveAttributes(path, value); + + for (Entry, JsonNode> entry : attributes.entrySet()) { + setValue(model, entry.getKey(), entry.getValue()); + } + } + + @Override + public void replace(M model, String path, JsonNode value) { + add(model, path, value); + } + + @Override + public void remove(M model, String rawPath) { + Objects.requireNonNull(model, "model cannot be null"); + + if (isBlank(rawPath)) { + throw new ModelValidationException("Missing path for patch operation remove"); + } + + String path = formatPath(rawPath); + + for (Attribute attribute : resolveAttributes(path, null).keySet()) { + setValue(model, attribute, NullNode.getInstance()); + } + } + /** * Returns the names of the attributes from the given {@code model}. * @@ -81,16 +124,6 @@ public abstract class AbstractModelSchema attribute = getAttributeMapper(model, resource, name); + Attribute attribute = getAttributeMapper(model, name); if (attribute == null) { continue; @@ -115,20 +148,42 @@ public abstract class AbstractModelSchema 0) { + attributeName = attributeName.substring(attributeName.indexOf('.') + 1); + List paths = new ArrayList<>(); + paths.add(getName()); + paths.addAll(List.of(attributeName.split("\\."))); + value = getJsonValue(objectNode, paths); + } + } + if (value == null) { JsonNode schemaExtension = objectNode.get(getName()); value = getJsonValue(schemaExtension, attribute.getName()); } if (value != null) { - mapper.setValue(model, name, value.toString()); + if (value instanceof Collection values) { + for (Object v : values) { + if (v instanceof JsonNode jsonNode) { + setValue(model, attribute, resolveAttributeValue(attribute.getName(), jsonNode)); + } + // no support for multivalued attributes for now, so we take the first value as the value of the attribute + break; + } + } else { + mapper.setValue(model, name, value.toString()); + } } } } private void populateResourceType(R resource, M model) { for (String name : getAttributeNames(model)) { - Attribute attribute = getAttributeMapper(model, resource, name); + Attribute attribute = getAttributeMapper(model, name); if (attribute == null) { continue; @@ -146,23 +201,175 @@ public abstract class AbstractModelSchema getAttributeMapper(M model, R resource, String name) { - Object schema = getAttributeSchema(model, name); - - if (schema == null) { - schema = getCoreSchema(resource.getClass()); - } - - if (!this.name.equals(schema)) { - return null; - } - - Object scimName = getAttributeSchemaName(model, name); + private Attribute getAttributeMapper(M model, String name) { + String scimName = getAttributeSchemaName(model, name); if (scimName == null) { return null; } - return attributes.get(scimName.toString()); + Attribute attribute = getAttributes().get(scimName); + + if (attribute != null) { + return attribute; + } + + if (!isCore() && scimName.startsWith(getName())) { + scimName = scimName.substring(getName().length() + 1); + } + + for (Entry> entry : getAttributes().entrySet()) { + Attribute attr = entry.getValue(); + String parent = attr.getParentName(); + + if (parent != null && entry.getKey().equals(parent + "." + scimName)) { + return attr; + } + } + + return null; + } + + private Map, JsonNode> resolveAttributes(String path, JsonNode valueJson) { + Objects.requireNonNull(path, "path cannot be null"); + + if (valueJson == null) { + valueJson = NullNode.getInstance(); + } + + if (valueJson.isArray()) { + if (valueJson.isEmpty()) { + valueJson = NullNode.getInstance(); + } else { + // for now, only single-valued attributes are supported, so if the value is an array, we take the first element as the value of the attribute + valueJson = valueJson.get(0); + } + } + + Map, JsonNode> attributes = new HashMap<>(); + // try resolve a direct reference to an attribute first + Attribute attribute = getAttributes().get(path); + + if (attribute == null) { + for (Entry> entry : getAttributes().entrySet()) { + Attribute attr = entry.getValue(); + List paths = new ArrayList<>(); + + String parent = attr.getParentName(); + + if (parent != null) { + paths.add(getName() + entry.getKey().replace(parent + ".", ":")); + } + + if (attr.getAlias() != null) { + paths.add(getName() + ":" + attr.getAlias()); + } + + if (paths.contains(path)) { + return Map.of(attr, resolveAttributeValue(attr.getName(), valueJson)); + } + } + + if (valueJson.isObject()) { + for (Entry property : valueJson.properties()) { + Attribute attr = getAttributes().get(path + "." + property.getKey()); + + if (attr != null) { + attributes.put(attr, resolveAttributeValue(attr.getName(), property.getValue())); + } else if (isCore()) { + attributes.putAll(resolveAttributes(property.getKey(), property.getValue())); + } else { + String name = property.getKey(); + + if (!name.startsWith(getName())) { + name = getName() + ":" + name; + } + + attributes.putAll(resolveAttributes(name, property.getValue())); + } + } + } + } else { + if (valueJson.isObject()) { + // if the value is an object, we assume it is a complex attribute and we iterate over all properties of + // the object to find the specific value for each sub-attribute + for (Entry property : valueJson.properties()) { + attributes.putAll(resolveAttributes(attribute.getName() + "." + property.getKey(), property.getValue())); + } + return attributes; + } + + // path is an attribute, value must be the value of the attribute + return Map.of(attribute, resolveAttributeValue(attribute.getName(), valueJson)); + } + + return attributes; + } + + private String formatPath(String path) { + int filterStartIdx = path.indexOf("["); + + if (filterStartIdx > 0) { + int filterEndIdx = path.lastIndexOf("]"); + + if (filterEndIdx == -1) { + throw new RuntimeException("Invalid path: " + path); + } + + // for now, we do not support filters in the path + return new StringBuilder(path).delete(filterStartIdx, filterEndIdx + 1).toString(); + } + + return path; + } + + public void setValue(M model, Attribute resolved, JsonNode value) { + for (String name : getAttributeNames(model)) { + Attribute attribute = getAttributeMapper(model, name); + + // no mapper found, or not the same as resolved, or is not the parent of the resolved attribute, so we skip + if (attribute == null || !(attribute.getName().equals(resolved.getName()) || attribute.isParent(resolved))) { + continue; + } + + AttributeMapper mapper = resolved.getMapper(); + mapper.setValue(model, name, value.isNull() ? null : value.asText()); + } + } + + private JsonNode resolveAttributeValue(String name, JsonNode jsonNode) { + if (jsonNode.isValueNode()) { + // return fast if a value node + return jsonNode; + } + + if (jsonNode.isObject()) { + if (jsonNode.has("value")) { + // if there is a "value" property, we assume it is a multivalued attribute and we take the value of the "value" property as the value of the attribute + return jsonNode.get("value"); + } + // iterate of all properties of the object to find the specific value for the property with the given name + for (Entry property : jsonNode.properties()) { + if (property.getKey().equals(name)) { + return resolveAttributeValue(name, property.getValue()); + } + } + } else if (jsonNode.isArray() && !jsonNode.isEmpty()) { + // we do not support multivalued attributes for now, so if the value is an array, we take the first element as the value of the attribute + return resolveAttributeValue(name, jsonNode.get(0)); + } + + return NullNode.getInstance(); + } + + @Override + public Attribute resolveAttribute(String name) { + Map, JsonNode> attributes = resolveAttributes(name, NullNode.getInstance()); + + if (attributes.isEmpty()) { + return null; + } + + return attributes.keySet().iterator().next(); } } 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 c4389e508eb..79f2a9ae596 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 @@ -6,6 +6,8 @@ import org.keycloak.models.Model; import org.keycloak.scim.resource.ResourceTypeRepresentation; import org.keycloak.scim.resource.schema.attribute.Attribute; +import com.fasterxml.jackson.databind.JsonNode; + /** *

An interface that represents a schema for a resource type. * @@ -53,4 +55,42 @@ public interface ModelSchema resolveAttribute(String name); } 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 581c0ce9285..269b640399c 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 @@ -1,22 +1,84 @@ package org.keycloak.scim.resource.schema.attribute; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; + +import org.keycloak.common.util.TriConsumer; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.Model; import org.keycloak.scim.resource.ResourceTypeRepresentation; +import org.keycloak.scim.resource.common.MultiValuedAttribute; +import org.keycloak.scim.resource.schema.ModelSchema; /** - * Represents an attribute from a {@link org.keycloak.scim.resource.schema.ModelSchema}, its metadata and the mapper + * Represents an attribute from a {@link ModelSchema}, its metadata and the mapper * that is used to map the attribute from a {@link ResourceTypeRepresentation} to a {@link Model} and vice versa. * - * @see org.keycloak.scim.resource.schema.ModelSchema + * @see ModelSchema */ public class Attribute { + private final String alias; + private BiFunction, String> modelAttributeResolver; + private boolean primary; + private String type; + private String mutability; + + /** + * Creates a simple attribute with the given {@code name}. + * + * @param name the name of the attribute from the {@link R} representation. It should be a simple attribute, meaning that it is not a complex attribute and does not have sub-attributes. + * @return the builder + */ + public static Builder simple(String name) { + return new Builder<>(name, null); + } + + /** + *

Creates a complex attribute with the given {@code name} and {@code complexType}. + *

The {@code complexType} is used to determine the type of the complex attribute and to create the corresponding setter + * for the representation. + * + * @param name the name of the attribute from the {@link R} representation. It should be a complex attribute, meaning that it has sub-attributes. + * @param complexType the type of the complex attribute. + * @return the builder + */ + public static Builder complex(String name, Class complexType) { + return new Builder<>(name, complexType); + } + + /** + *

Creates a complex attribute with the given {@code name} as a {@link MultiValuedAttribute} attribute. There is no + * need to define sub-attributes for this type of attribute as they will be automatically mapped from {@link MultiValuedAttribute}. + * + * @param name the name of the attribute from the {@link R} representation. + * @return the builder + */ + public static Builder complex(String name, TriConsumer modelSetter, BiFunction, String> resolver, boolean primary) { + return (Builder) new Builder<>(name, MultiValuedAttribute.class) + .primary(primary) + .modelAttributeResolver((session, attribute) -> resolver.apply(session, (Attribute) attribute)) + .withSetters((TriConsumer) modelSetter) + .withAttribute("value", name, (TriConsumer) modelSetter, primary) + .withAttribute("primary", (model, subName, value) -> {return;}); + } + private final String name; private final AttributeMapper mapper; + private String parentName; - public Attribute(String name, AttributeMapper mapper) { + private Attribute(String name, AttributeMapper mapper, String parentName) { + this(name, mapper, parentName, null); + } + + private Attribute(String name, AttributeMapper mapper, String parentName, String alias) { this.name = name; this.mapper = mapper; + this.parentName = parentName; + this.alias = alias; } /** @@ -28,6 +90,10 @@ public class Attribute { return name; } + public String getAlias() { + return alias; + } + /** * The mapper that is used to map the attribute from a {@link ResourceTypeRepresentation} to a {@link Model} and vice versa. * @@ -36,4 +102,167 @@ public class Attribute { public AttributeMapper getMapper() { return mapper; } + + /** + * Returns if this attribute is a parent attribute of the given {@code resolved} attribute. + * A parent attribute is usually an attribute that has sub-attributes, meaning that the name of the parent attribute is a + * prefix of the name of the resolved attribute. + * + * @param sub the sub attribute to check if this attribute is its parent + * @return {@code true} if this attribute is a parent attribute of the given {@code resolved} attribute. Otherwise, {@code false} + */ + public boolean isParent(Attribute sub) { + return name.equals(sub.parentName); + } + + /** + * Returns the name of the parent attribute if this attribute is a sub-attribute. Otherwise, returns {@code null}. + * + * @return the name of the parent attribute or {@code null} if this attribute is not a sub-attribute + */ + public String getParentName() { + return parentName; + } + + /** + * Returns the name of the attribute from the {@link Model} associated with this attribute. + * + * @param session the session + * @return the name of the attribute from the {@link Model} associated with this attribute or {@code null} if there is no mapping to this attribute + */ + public String getModelAttributeName(KeycloakSession session) { + if (modelAttributeResolver != null) { + return modelAttributeResolver.apply(session, this); + } + return null; + } + + private void setModelAttributeResolver(BiFunction, String> resolver) { + this.modelAttributeResolver = resolver; + } + + public boolean isPrimary() { + return primary; + } + + private void setPrimary(boolean primary) { + this.primary = primary; + } + + public boolean isTimestamp() { + return Objects.equals(type, "timestamp"); + } + + public boolean isBoolean() { + return Objects.equals(type, "boolean"); + } + + private void setType(String type) { + this.type = type; + } + + private void setMutability(String mutability) { + this.mutability = mutability; + } + + public boolean isImmutable() { + return Objects.equals(mutability, "immutable"); + } + + public static class Builder { + + private final Class complexType; + private final String name; + private TriConsumer modelSetter; + private BiConsumer representationSetter; + List> attributes = new ArrayList<>(); + private BiFunction, String> modelAttributeResolver; + private boolean primary; + private String type; + private String mutability; + + private Builder(String name, Class complexType) { + Objects.requireNonNull(name, "name cannot be null"); + this.complexType = complexType; + this.name = name; + } + + public > Builder withSetters(TriConsumer modelSetter, C representationSetter) { + this.modelSetter = modelSetter; + this.representationSetter = representationSetter; + return this; + } + + public Builder withSetters(TriConsumer modelSetter) { + this.modelSetter = modelSetter; + this.representationSetter = new ComplexAttributeSetter<>(name, complexType); + return this; + } + + public Builder withSetters(BiConsumer modelSetter) { + this.modelSetter = (model, name, value) -> modelSetter.accept(model, value); + this.representationSetter = new ComplexAttributeSetter<>(name, complexType); + return this; + } + + public Builder withAttribute(String name, TriConsumer modelSetter) { + return withAttribute(name, null, modelSetter); + } + + public Builder withAttribute(String name, TriConsumer modelSetter, boolean primary) { + return withAttribute(name, null, modelSetter, primary); + } + + public Builder withAttribute(String name, String alias, TriConsumer modelSetter) { + return withAttribute(name, alias, modelSetter, false); + } + + public Builder withAttribute(String name, String alias, TriConsumer modelSetter, boolean primary) { + String subName = this.name + "." + name; + Attribute attribute = new Attribute<>(subName, new AttributeMapper<>(modelSetter, new ComplexAttributeSetter<>(this.name, name, complexType)), this.name, alias); + attribute.setModelAttributeResolver(modelAttributeResolver); + attribute.setPrimary(primary); + attributes.add(attribute); + return this; + } + + public Builder modelAttributeResolver(BiFunction, String> resolver) { + this.modelAttributeResolver = resolver; + return this; + } + + public Builder primary() { + return primary(true); + } + + private Builder primary(boolean primary) { + this.primary = primary; + return this; + } + + public Builder timestamp() { + this.type = "timestamp"; + return this; + } + + public Builder bool() { + this.type = "boolean"; + return this; + } + + public Builder immutable() { + this.mutability = "immutable"; + return this; + } + + public List> build() { + Attribute attribute = new Attribute<>(name, new AttributeMapper<>(modelSetter, representationSetter), this.name); + attribute.setModelAttributeResolver(modelAttributeResolver); + attribute.setPrimary(primary); + attribute.setType(type); + attribute.setMutability(mutability); + attributes.add(attribute); + return attributes; + } + } } 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 baa85f7ce28..905dd7d2be8 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 @@ -31,15 +31,15 @@ public class AttributeMapper modelSetter, BiConsumer representationSetter) { - this((model, name, value) -> modelSetter.accept(model, value), representationSetter); - } - public void setValue(R representation, String value) { - representationSetter.accept(representation, value); + if (representationSetter != null) { + representationSetter.accept(representation, value); + } } public void setValue(M model, String name, String value) { - modelSetter.accept(model, name, value); + if (modelSetter != null) { + modelSetter.accept(model, name, 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 new file mode 100644 index 00000000000..dfa6d7fe7ee --- /dev/null +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/ComplexAttributeSetter.java @@ -0,0 +1,103 @@ +package org.keycloak.scim.resource.schema.attribute; + +import java.lang.reflect.Method; +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.scim.resource.ResourceTypeRepresentation; +import org.keycloak.scim.resource.common.MultiValuedAttribute; + +public class ComplexAttributeSetter implements BiConsumer { + + private final String name; + private final String subName; + private final Class complexType; + + public ComplexAttributeSetter(String name, Class complexType) { + this(name, null, complexType); + } + + public ComplexAttributeSetter(String name, String subName, Class complexType) { + Objects.requireNonNull(name); + this.name = name; + this.subName = subName; + this.complexType = complexType; + } + + @Override + public void accept(R representation, String newValue) { + try { + Method declaredMethod = representation.getClass().getMethod("get" + Character.toUpperCase(name.charAt(0)) + name.substring(1)); + Class returnType = declaredMethod.getReturnType(); + Object value = declaredMethod.invoke(representation); + Method setter = representation.getClass().getMethod("set" + Character.toUpperCase(name.charAt(0)) + name.substring(1), returnType); + + if (value == null) { + // no value yet, need to create it + if (Collection.class.isAssignableFrom(returnType)) { + // if the return type is a collection, we need to create a new collection and add the new value to it + Collection values = new ArrayList<>(); + + setter.invoke(representation, values); + + returnType = declaredMethod.getGenericReturnType() instanceof ParameterizedType ? (Class) ((ParameterizedType) declaredMethod.getGenericReturnType()).getActualTypeArguments()[0] : Object.class; + + // for now lists can only be of complex types, so we need to check if the return type is assignable from the complex type + if (!complexType.isAssignableFrom(returnType)) { + throw new IllegalStateException("Return type of getter for attribute " + name + " must be a " + complexType.getName()); + } + + // if the complex type is a multivalued attribute, we need to create a new instance of the multi-valued attribute and add the new value to it + if (MultiValuedAttribute.class.isAssignableFrom(complexType)) { + Object item = returnType.getDeclaredConstructor().newInstance(); + values.add(item); + item.getClass().getMethod("setValue", String.class).invoke(item, newValue); + } + + // Currently only multivalued attributes are supported for complex attributes + return; + } else if (complexType != null) { + // not multivalued, but still a complex type, so we need to create a new instance of the complex type and set it on the representation + value = complexType.getDeclaredConstructor().newInstance(); + setter.invoke(representation, value); + } else { + // if no complex type is defined, we assume operation in the representation itself, so we can just set the value on the representation + value = representation; + } + } + + if (String.class.isAssignableFrom(returnType) || Number.class.isAssignableFrom(returnType) || Boolean.class.isAssignableFrom(returnType)) { + // simple value, just set it + setter.invoke(representation, newValue); + } else if (subName != null) { + // nested attribute, we need to get the sub attribute and set the value on it + if (subName.contains(".")) { + String[] parts = subName.split("\\.", 2); + + if (parts.length > 2) { + throw new IllegalStateException("Can only handle one level of nesting for sub attributes, but got " + subName); + } + + String subName = parts[1]; + Method getSubMethod = value.getClass().getMethod("get" + Character.toUpperCase(parts[0].charAt(0)) + parts[0].substring(1)); + Object subValue = getSubMethod.invoke(value); + + if (subValue == null) { + subValue = getSubMethod.getReturnType().getDeclaredConstructor().newInstance(); + Method setSubMethod = value.getClass().getMethod("set" + Character.toUpperCase(parts[0].charAt(0)) + parts[0].substring(1), subValue.getClass()); + setSubMethod.invoke(value, subValue); + } + + subValue.getClass().getMethod("set" + Character.toUpperCase(subName.charAt(0)) + subName.substring(1), String.class).invoke(subValue, newValue); + } else { + value.getClass().getMethod("set" + Character.toUpperCase(subName.charAt(0)) + subName.substring(1), String.class).invoke(value, newValue); + } + } + } catch (Exception e) { + throw new IllegalStateException("Could not set attribute " + name + " on representation " + representation.getClass().getName(), e); + } + } +} 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 b418e5a2501..e8c7c9979dd 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 @@ -9,11 +9,17 @@ import org.keycloak.authorization.fgap.AdminPermissionsSchema; import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.Model; +import org.keycloak.models.ModelValidationException; import org.keycloak.scim.protocol.ForbiddenException; +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; +import com.fasterxml.jackson.databind.JsonNode; + +import static org.keycloak.utils.StringUtil.isBlank; + public abstract class AbstractScimResourceTypeProvider implements ScimResourceTypeProvider { protected final KeycloakSession session; @@ -109,11 +115,47 @@ public abstract class AbstractScimResourceTypeProvider operations) { + Objects.requireNonNull(existing, "existing cannot be null"); + Objects.requireNonNull(operations, "operations cannot be null"); + M model = getModel(existing.getId()); + KeycloakContext context = session.getContext(); + + if (!context.hasPermission(model, getRealmResourceType(), AdminPermissionsSchema.MANAGE)) { + throw new ForbiddenException(); + } + + for (PatchOperation operation : operations) { + String op = operation.getOp(); + + if (isBlank(op)) { + throw new ModelValidationException("Missing operation for patch operation"); + } + + String path = operation.getPath(); + JsonNode value = operation.getValue(); + + for (ModelSchema schema : schemas) { + switch (op.toLowerCase()) { + case "add" -> schema.add(model, path, value); + case "replace" -> schema.replace(model, path, value); + case "remove" -> schema.remove(model, path); + default -> throw new RuntimeException("Unsupported patch operation " + op); + } + } + } + } + @Override public String getSchema() { return schema.getName(); } + public List> getSchemas() { + return schemas; + } + @Override public List getSchemaExtensions() { return schemaExtensions.stream().map(ModelSchema::getName).toList(); @@ -154,7 +196,7 @@ public abstract class AbstractScimResourceTypeProvider * @return true if the resource was successfully deleted, false if the resource was not found or could not be deleted */ boolean delete(String id); + + default void patch(R existing, List operations) { + throw new UnsupportedOperationException("Add operation is not supported for resource type " + getName()); + } } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/filter/AttributeInfo.java b/scim/model/src/main/java/org/keycloak/scim/model/filter/AttributeInfo.java deleted file mode 100644 index f33e195a612..00000000000 --- a/scim/model/src/main/java/org/keycloak/scim/model/filter/AttributeInfo.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.keycloak.scim.model.filter; - -import java.util.Objects; - -public class AttributeInfo { - - private final String keycloakName; - private final boolean primary; // true = belong to the main resource entity, false = belong to a related entity (e.g. user attributes) - private final String attributeType; - - public AttributeInfo(String keycloakName, boolean primary, String attributeType) { - this.keycloakName = keycloakName; - this.primary = primary; - this.attributeType = attributeType; - } - - public String getKeycloakName() { - return keycloakName; - } - - public boolean isPrimary() { - return primary; - } - - public boolean isTimestamp() { - return Objects.equals(attributeType, "timestamp"); - } - - public boolean isBoolean() { - return Objects.equals(attributeType, "boolean"); - } -} diff --git a/scim/model/src/main/java/org/keycloak/scim/model/filter/AttributeNameResolver.java b/scim/model/src/main/java/org/keycloak/scim/model/filter/AttributeNameResolver.java deleted file mode 100644 index c24bb90b597..00000000000 --- a/scim/model/src/main/java/org/keycloak/scim/model/filter/AttributeNameResolver.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.keycloak.scim.model.filter; - -/** - * Resolves SCIM attribute paths to Keycloak attribute names. - * - * @author Stefan Guilhen - */ -public interface AttributeNameResolver { - - AttributeInfo resolve(String scimAttrPath); -} diff --git a/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateEvaluator.java b/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateEvaluator.java index 01c75e3c834..58715c8f61b 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateEvaluator.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateEvaluator.java @@ -1,9 +1,12 @@ package org.keycloak.scim.model.filter; +import java.util.List; + import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Root; +import org.keycloak.models.KeycloakSession; import org.keycloak.scim.filter.ScimFilterParser; import org.keycloak.scim.filter.ScimFilterParserBaseVisitor; @@ -17,9 +20,9 @@ public class ScimJPAPredicateEvaluator extends ScimFilterParserBaseVisitor query, Root root) { + public ScimJPAPredicateEvaluator(KeycloakSession session, List schemas, CriteriaBuilder cb, CriteriaQuery query, Root root) { this.cb = cb; - this.predicateProvider = new ScimJPAPredicateProvider(resolver, cb, query, root); + this.predicateProvider = new ScimJPAPredicateProvider(session, schemas, cb, query, root); } @Override diff --git a/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java index cd391bd5b42..ba2f1202b43 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java @@ -2,6 +2,7 @@ package org.keycloak.scim.model.filter; import java.time.Instant; import java.time.format.DateTimeParseException; +import java.util.List; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; @@ -10,7 +11,11 @@ import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Root; +import org.keycloak.models.KeycloakSession; import org.keycloak.scim.filter.ScimFilterException; +import org.keycloak.scim.resource.schema.ModelSchema; +import org.keycloak.scim.resource.schema.attribute.Attribute; +import org.keycloak.utils.KeycloakSessionUtil; /** * Creates JPA predicates for SCIM filter operators. Handles both direct root entity fields and custom attributes stored @@ -20,106 +25,110 @@ import org.keycloak.scim.filter.ScimFilterException; */ public class ScimJPAPredicateProvider { + private final KeycloakSession session; + private final List> schemas; private final CriteriaBuilder cb; private final CriteriaQuery query; private final Root root; - private final AttributeNameResolver nameResolver; // Cache joins to avoid creating duplicate joins for the same filter private Join attributeJoin; - public ScimJPAPredicateProvider(AttributeNameResolver resolver, CriteriaBuilder cb, CriteriaQuery query, Root root) { + public ScimJPAPredicateProvider(KeycloakSession session, List> schemas, CriteriaBuilder cb, CriteriaQuery query, Root root) { + this.session = session; + this.schemas = schemas; this.cb = cb; this.query = query; this.root = root; - this.nameResolver = resolver; } - public JPAFilterResult createPresentPredicate(String scimAttrPath) { - AttributeInfo attrInfo = nameResolver.resolve(scimAttrPath); + public JPAFilterResult createPresentPredicate(String path) { + Attribute attrInfo = resolve(path); if (attrInfo == null) { return JPAFilterResult.unsupported(cb.disjunction()); } - + KeycloakSession session = KeycloakSessionUtil.getKeycloakSession(); + String modelAttributeName = attrInfo.getModelAttributeName(session); if (attrInfo.isPrimary()) { // Direct field: check not null - return JPAFilterResult.valid(cb.isNotNull(root.get(attrInfo.getKeycloakName()))); + return JPAFilterResult.valid(cb.isNotNull(root.get(modelAttributeName))); } else { // Custom attribute: must exist in attributes collection with non-null value Join join = getOrCreateAttributeJoin(); return JPAFilterResult.valid(cb.and( - cb.equal(join.get("name"), attrInfo.getKeycloakName()), + cb.equal(join.get("name"), modelAttributeName), cb.isNotNull(join.get("value")) )); } } - public JPAFilterResult createComparisonPredicate(String scimAttrPath, String operator, String value) { - AttributeInfo attrInfo = nameResolver.resolve(scimAttrPath); + public JPAFilterResult createComparisonPredicate(String path, String operator, String value) { + Attribute attrInfo = resolve(path); if (attrInfo == null) { return JPAFilterResult.unsupported(cb.disjunction()); } - + KeycloakSession session = KeycloakSessionUtil.getKeycloakSession(); // Determine if this is a temporal (timestamp) field that needs date conversion boolean isTimestampField = attrInfo.isTimestamp(); boolean isBooleanField = attrInfo.isBoolean(); + String modelAttributeName = attrInfo.getModelAttributeName(session); switch (operator.toLowerCase()) { case "eq": if (isTimestampField) { Long timestamp = parseDateTime(value); - return JPAFilterResult.valid(cb.equal(root.get(attrInfo.getKeycloakName()), timestamp)); + return JPAFilterResult.valid(cb.equal(root.get(modelAttributeName), timestamp)); } else if (isBooleanField) { Boolean boolValue = Boolean.parseBoolean(value); - return JPAFilterResult.valid(cb.equal(root.get(attrInfo.getKeycloakName()), boolValue)); + return JPAFilterResult.valid(cb.equal(root.get(modelAttributeName), boolValue)); } else { - Expression attrExpr = getAttributeExpression(attrInfo); + Expression attrExpr = getAttributeExpression(session, modelAttributeName, attrInfo); return JPAFilterResult.valid(cb.equal(attrExpr, value)); } case "ne": if (isTimestampField) { Long timestamp = parseDateTime(value); - return JPAFilterResult.valid(cb.notEqual(root.get(attrInfo.getKeycloakName()), timestamp)); + return JPAFilterResult.valid(cb.notEqual(root.get(modelAttributeName), timestamp)); } else if (isBooleanField) { Boolean boolValue = Boolean.parseBoolean(value); - return JPAFilterResult.valid(cb.notEqual(root.get(attrInfo.getKeycloakName()), boolValue)); + return JPAFilterResult.valid(cb.notEqual(root.get(modelAttributeName), boolValue)); } else { - Expression attrExpr = getAttributeExpression(attrInfo); + Expression attrExpr = getAttributeExpression(session, modelAttributeName, attrInfo); return JPAFilterResult.valid(cb.notEqual(attrExpr, value)); } case "co": - return JPAFilterResult.valid(cb.like(getAttributeExpression(attrInfo), "%" + escapeLike(value) + "%", '\\')); + return JPAFilterResult.valid(cb.like(getAttributeExpression(session, modelAttributeName, attrInfo), "%" + escapeLike(value) + "%", '\\')); case "sw": - return JPAFilterResult.valid(cb.like(getAttributeExpression(attrInfo), escapeLike(value) + "%", '\\')); + return JPAFilterResult.valid(cb.like(getAttributeExpression(session, modelAttributeName, attrInfo), escapeLike(value) + "%", '\\')); case "ew": - return JPAFilterResult.valid(cb.like(getAttributeExpression(attrInfo), "%" + escapeLike(value), '\\')); + return JPAFilterResult.valid(cb.like(getAttributeExpression(session, modelAttributeName, attrInfo), "%" + escapeLike(value), '\\')); case "gt": if (isTimestampField) { Long timestamp = parseDateTime(value); - return JPAFilterResult.valid(cb.greaterThan(root.get(attrInfo.getKeycloakName()), timestamp)); + return JPAFilterResult.valid(cb.greaterThan(root.get(modelAttributeName), timestamp)); } else { - return JPAFilterResult.valid(cb.greaterThan(getAttributeExpression(attrInfo), value)); + return JPAFilterResult.valid(cb.greaterThan(getAttributeExpression(session, modelAttributeName, attrInfo), value)); } case "ge": if (isTimestampField) { Long timestamp = parseDateTime(value); - return JPAFilterResult.valid(cb.greaterThanOrEqualTo(root.get(attrInfo.getKeycloakName()), timestamp)); + return JPAFilterResult.valid(cb.greaterThanOrEqualTo(root.get(modelAttributeName), timestamp)); } else { - return JPAFilterResult.valid(cb.greaterThanOrEqualTo(getAttributeExpression(attrInfo), value)); + return JPAFilterResult.valid(cb.greaterThanOrEqualTo(getAttributeExpression(session, modelAttributeName, attrInfo), value)); } case "lt": if (isTimestampField) { Long timestamp = parseDateTime(value); - return JPAFilterResult.valid(cb.lessThan(root.get(attrInfo.getKeycloakName()), timestamp)); + return JPAFilterResult.valid(cb.lessThan(root.get(modelAttributeName), timestamp)); } else { - return JPAFilterResult.valid(cb.lessThan(getAttributeExpression(attrInfo), value)); + return JPAFilterResult.valid(cb.lessThan(getAttributeExpression(session, modelAttributeName, attrInfo), value)); } case "le": if (isTimestampField) { Long timestamp = parseDateTime(value); - return JPAFilterResult.valid(cb.lessThanOrEqualTo(root.get(attrInfo.getKeycloakName()), timestamp)); + return JPAFilterResult.valid(cb.lessThanOrEqualTo(root.get(modelAttributeName), timestamp)); } else { - return JPAFilterResult.valid(cb.lessThanOrEqualTo(getAttributeExpression(attrInfo), value)); + return JPAFilterResult.valid(cb.lessThanOrEqualTo(getAttributeExpression(session, modelAttributeName, attrInfo), value)); } default: throw new ScimFilterException("Unknown operator: " + operator); @@ -147,13 +156,13 @@ public class ScimJPAPredicateProvider { } } - private Expression getAttributeExpression(AttributeInfo attrInfo) { + private Expression getAttributeExpression(KeycloakSession session, String modelAttributeName, Attribute attrInfo) { if (attrInfo.isPrimary()) { - return root.get(attrInfo.getKeycloakName()); + return root.get(modelAttributeName); } else { Join join = getOrCreateAttributeJoin(); // Add name filter to join - query.where(cb.equal(join.get("name"), attrInfo.getKeycloakName())); + query.where(cb.equal(join.get("name"), modelAttributeName)); return join.get("value"); } } @@ -171,4 +180,29 @@ public class ScimJPAPredicateProvider { .replace("%", "\\%") .replace("_", "\\_"); } + + public Attribute resolve(String path) { + Attribute metadata = null; + + for (ModelSchema schema : schemas) { + metadata = schema.resolveAttribute(path); + + if (metadata != null ) { + break; + } + + } + if (metadata == null) { + return null; + } + + String modelAttributeName = metadata.getModelAttributeName(session); + + if (modelAttributeName != null) { + return metadata; + } + + // haven't found the attribute in the user profile, so return null to indicate that this is an unknown attribute. + return null; + } } 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 03cc3c0c436..190865a0767 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 @@ -1,25 +1,20 @@ package org.keycloak.scim.model.group; import java.util.ArrayList; -import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.keycloak.models.GroupModel; import org.keycloak.scim.resource.group.Group; import org.keycloak.scim.resource.schema.AbstractModelSchema; import org.keycloak.scim.resource.schema.attribute.Attribute; -import org.keycloak.scim.resource.schema.attribute.AttributeMapper; public final class GroupCoreModelSchema extends AbstractModelSchema { - private static final List> ATTRIBUTE_MAPPERS = new ArrayList<>(); - - static { - ATTRIBUTE_MAPPERS.add(new Attribute<>("displayName", new AttributeMapper<>(GroupModel::setName, Group::setDisplayName))); - } - public GroupCoreModelSchema() { - super(Group.SCHEMA, ATTRIBUTE_MAPPERS); + super(Group.SCHEMA); } @Override @@ -40,11 +35,6 @@ public final class GroupCoreModelSchema extends AbstractModelSchema> doGetAttributes() { + return new ArrayList<>((Attribute.simple("displayName") + .primary() + .modelAttributeResolver((session, attribute) -> { + if (attribute.getName().equals("displayName")) { + return "name"; + } + return null; + }) + .withSetters(GroupModel::setName) + .build())).stream().collect(Collectors.toMap(Attribute::getName, Function.identity())); + } } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupResourceTypeProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupResourceTypeProvider.java index c4c65aad8c3..acfd4239888 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupResourceTypeProvider.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupResourceTypeProvider.java @@ -27,10 +27,8 @@ import org.keycloak.models.jpa.GroupAdapter; import org.keycloak.models.jpa.entities.GroupEntity; import org.keycloak.scim.filter.FilterUtils; import org.keycloak.scim.filter.ScimFilterParser; -import org.keycloak.scim.model.filter.AttributeInfo; import org.keycloak.scim.model.filter.ScimJPAPredicateEvaluator; import org.keycloak.scim.protocol.request.SearchRequest; -import org.keycloak.scim.resource.Scim; import org.keycloak.scim.resource.group.Group; import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider; import org.keycloak.utils.StringUtil; @@ -85,15 +83,7 @@ public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider< Root root = query.from(GroupEntity.class); // Create filter predicate using the same query and root that will be used for execution - ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(scimAttrPath -> { - // first split the attribute path into schema and attribute name. If no schema is specified, use the core user schema by default - String[] splitAttrPath = splitScimAttribute(scimAttrPath); - - if (Scim.GROUP_CORE_SCHEMA.equals(splitAttrPath[0]) && "displayName".equalsIgnoreCase(splitAttrPath[1])) {; - return new AttributeInfo("name", true, null); - } - return null; - }, cb, query, root); + ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(session, getSchemas(), cb, query, root); Predicate filterPredicate = evaluator.visit(filterContext).predicate(); // Apply realm restriction 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 22ad1fb2717..6d01c503890 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 @@ -1,9 +1,7 @@ package org.keycloak.scim.model.user; import java.util.HashSet; -import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import org.keycloak.models.KeycloakSession; @@ -17,14 +15,15 @@ import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; +import static java.util.Optional.ofNullable; + public abstract class AbstractUserModelSchema extends AbstractModelSchema { - public static final String ANNOTATION_SCIM_SCHEMA = "scim.schema"; - public static final String ANNOTATION_SCIM_SCHEMA_ATTRIBUTE = "scim.schema.attribute"; + public static final String ANNOTATION_SCIM_SCHEMA_ATTRIBUTE = "kc.scim.schema.attribute"; private final KeycloakSession session; - public AbstractUserModelSchema(KeycloakSession session, String name, List> attributeMappers) { - super(name, attributeMappers); + public AbstractUserModelSchema(KeycloakSession session, String name) { + super(name); this.session = session; } @@ -35,17 +34,6 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema attribute) { + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(UserProfileContext.SCIM, Map.of()); + Attributes attributes = profile.getAttributes(); + + for (String name : attributes.nameSet()) { + Map annotations = attributes.getAnnotations(name); + + if (annotations == null) { + continue; + } + + Object scimName = annotations.get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE); + + if (attribute.getName().equals(scimName) || ofNullable(attribute.getAlias()).orElse("").equals(scimName)) { + return name; + } + } + + return null; + } } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserAttributeMapper.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserAttributeMapper.java deleted file mode 100644 index 6943d835364..00000000000 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/UserAttributeMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.keycloak.scim.model.user; - -import java.util.function.BiConsumer; - -import org.keycloak.models.UserModel; -import org.keycloak.scim.resource.schema.attribute.AttributeMapper; -import org.keycloak.scim.resource.user.User; - -public class UserAttributeMapper extends AttributeMapper { - - public UserAttributeMapper(BiConsumer setter) { - super(UserModel::setSingleAttribute, setter); - } -} diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java index 5404a91d8a4..0f0ca8a6153 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java @@ -2,36 +2,95 @@ package org.keycloak.scim.model.user; import java.util.ArrayList; import java.util.List; +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.Scim; import org.keycloak.scim.resource.common.Name; import org.keycloak.scim.resource.schema.attribute.Attribute; -import org.keycloak.scim.resource.schema.attribute.AttributeMapper; import org.keycloak.scim.resource.user.User; public final class UserCoreModelSchema extends AbstractUserModelSchema { - private static final List> ATTRIBUTE_MAPPERS = new ArrayList<>(); - - static { - ATTRIBUTE_MAPPERS.add(new Attribute<>("userName", new UserAttributeMapper(User::setUserName))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("emails[0].value", new UserAttributeMapper(User::setEmail))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("name.givenName", new UserAttributeMapper(User::setFirstName))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("name.familyName", new UserAttributeMapper(User::setLastName))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("name.middleName", new UserNameAttributeMapper(Name::setMiddleName))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("name.honorificPrefix", new UserNameAttributeMapper(Name::setHonorificPrefix))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("name.honorificSuffix", new UserNameAttributeMapper(Name::setHonorificSuffix))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("externalId", new UserAttributeMapper(User::setExternalId))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("nickName", new UserAttributeMapper(User::setNickName))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("locale", new UserAttributeMapper(User::setLocale))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("active", new AttributeMapper<>( - (model, value) -> model.setEnabled(Boolean.parseBoolean(value)), - (user, value) -> user.setActive(Boolean.parseBoolean(value))))); + public UserCoreModelSchema(KeycloakSession session) { + super(session, Scim.getCoreSchema(User.class)); } - public UserCoreModelSchema(KeycloakSession session) { - super(session, Scim.getCoreSchema(User.class), ATTRIBUTE_MAPPERS); + @Override + protected Map> doGetAttributes() { + List> attributes = new ArrayList<>(); + + attributes.addAll(Attribute.simple("userName") + .primary() + .withSetters(UserModel::setSingleAttribute) + .modelAttributeResolver(this::createModelAttributeResolver) + .build()); + attributes.addAll(Attribute.complex("emails", UserModel::setSingleAttribute, this::createModelAttributeResolver, true) + .build()); + attributes.addAll(Attribute.complex("name", Name.class) + .modelAttributeResolver(this::createModelAttributeResolver) + .withAttribute("givenName", UserModel::setSingleAttribute, true) + .withAttribute("formatted", UserModel::setSingleAttribute) + .withAttribute("familyName", UserModel::setSingleAttribute, true) + .withAttribute("middleName", UserModel::setSingleAttribute) + .withAttribute("honorificPrefix", UserModel::setSingleAttribute) + .withAttribute("honorificSuffix", UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("displayName") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("title") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("externalId") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("userType") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("nickName") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("locale") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("timezone") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("preferredLanguage") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("profileUrl") + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters(UserModel::setSingleAttribute) + .build()); + attributes.addAll(Attribute.simple("active") + .primary() + .bool() + .modelAttributeResolver(this::createModelAttributeResolver) + .withSetters( + (model, name, value) -> model.setEnabled(Boolean.parseBoolean(value)) + , (user, value) -> user.setActive(Boolean.parseBoolean(value)) + ) + .build()); + attributes.addAll(Attribute.simple("meta.created") + .primary() + .timestamp() + .immutable() + .modelAttributeResolver(this::createModelAttributeResolver) + .build()); + + return attributes.stream().collect(Collectors.toMap(Attribute::getName, Function.identity())); } } 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 97e4a23681c..0654daedd8a 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,53 +1,22 @@ package org.keycloak.scim.model.user; import java.util.ArrayList; -import java.util.List; import java.util.Map; -import java.util.function.BiConsumer; +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.EnterpriseUser.Manager; import org.keycloak.scim.resource.user.User; import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA; public final class UserEnterpriseModelSchema extends AbstractUserModelSchema { - private static final List> ATTRIBUTE_MAPPERS = new ArrayList<>(); - - static { - ATTRIBUTE_MAPPERS.add(new Attribute<>("employeeNumber", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setEmployeeNumber)))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("costCenter", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setCostCenter)))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("organization", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setOrganization)))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("division", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setDivision)))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("department", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setDepartment)))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("manager.value", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper((user, value) -> { - Manager manager = user.getManager(); - - if (manager == null) { - manager = new Manager(); - user.setManager(manager); - } - - manager.setValue(value); - })))); - ATTRIBUTE_MAPPERS.add(new Attribute<>("manager.displayName", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper((user, value) -> { - Manager manager = user.getManager(); - - if (manager == null) { - manager = new Manager(); - user.setManager(manager); - } - - manager.setDisplayName(value); - })))); - } - public UserEnterpriseModelSchema(KeycloakSession session) { - super(session, ENTERPRISE_USER_SCHEMA, ATTRIBUTE_MAPPERS); + super(session, ENTERPRISE_USER_SCHEMA); } @Override @@ -56,32 +25,21 @@ public final class UserEnterpriseModelSchema extends AbstractUserModelSchema { } @Override - public Map> getAttributes() { - return Map.of(); + public boolean isCore() { + return false; } - private static class EnterpriseUserResourceTypeAttributeMapper implements BiConsumer { - - private final BiConsumer setter; - - public EnterpriseUserResourceTypeAttributeMapper(BiConsumer setter) { - this.setter = setter; - } - - @Override - public void accept(User user, String value) { - if (value == null) { - return; - } - - EnterpriseUser enterpriseUser = user.getEnterpriseUser(); - - if (enterpriseUser == null) { - enterpriseUser = new EnterpriseUser(); - user.setEnterpriseUser(enterpriseUser); - } - - setter.accept(enterpriseUser, value); - } + @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())); } } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserNameAttributeMapper.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserNameAttributeMapper.java deleted file mode 100644 index 31c9d3c60af..00000000000 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/UserNameAttributeMapper.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.keycloak.scim.model.user; - -import java.util.function.BiConsumer; - -import org.keycloak.models.UserModel; -import org.keycloak.scim.resource.common.Name; -import org.keycloak.scim.resource.schema.attribute.AttributeMapper; -import org.keycloak.scim.resource.user.User; - -public class UserNameAttributeMapper extends AttributeMapper { - - public UserNameAttributeMapper(BiConsumer setter) { - super(UserModel::setSingleAttribute, (user, value) -> { - if (value == null) { - return; - } - - Name name = user.getName(); - - if (name == null) { - name = new Name(); - user.setName(name); - } - - setter.accept(name, value); - }); - } -} 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 78ddde10e3c..037f77ea42c 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 @@ -2,7 +2,6 @@ package org.keycloak.scim.model.user; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Stream; import jakarta.persistence.EntityManager; @@ -21,13 +20,10 @@ import org.keycloak.models.jpa.UserAdapter; import org.keycloak.models.jpa.entities.UserEntity; import org.keycloak.scim.filter.FilterUtils; import org.keycloak.scim.filter.ScimFilterParser; -import org.keycloak.scim.model.filter.AttributeInfo; -import org.keycloak.scim.model.filter.AttributeNameResolver; import org.keycloak.scim.model.filter.ScimJPAPredicateEvaluator; import org.keycloak.scim.protocol.request.SearchRequest; import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider; import org.keycloak.scim.resource.user.User; -import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; @@ -36,8 +32,6 @@ import org.keycloak.userprofile.ValidationException.Error; import org.keycloak.utils.StringUtil; import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; -import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA; -import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE; import static org.keycloak.utils.StreamsUtil.closing; public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider { @@ -105,7 +99,7 @@ public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider root = query.from(UserEntity.class); // Create filter predicate using the same query and root that will be used for execution - ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(new UserAttributeNameResolver(session, this), cb, query, root); + ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(session, getSchemas(), cb, query, root); Predicate filterPredicate = evaluator.visit(filterContext).predicate(); // Apply realm restriction @@ -152,42 +146,4 @@ public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider allAttrNames = attributes.toMap().keySet(); - for (String attrName : allAttrNames) { - var annotations = attributes.getMetadata(attrName).getAnnotations(); - if (annotations != null) { - String scimAttr = (String) annotations.get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE); - String scimAttrSchema = (String) annotations.get(ANNOTATION_SCIM_SCHEMA); - if (splitAttrPath[0].equals(scimAttrSchema) && splitAttrPath[1].equals(scimAttr)) { - // we found the attribute with the matching SCIM attribute path and schema, so return it - boolean primary = Boolean.parseBoolean((String) annotations.get("primary")); - String attrType = (String) annotations.get("type"); - return new AttributeInfo(attrName, primary, attrType); - } - } - } - // haven't found the attribute in the user profile, so return null to indicate that this is an unknown attribute. - return null; - } - } - } diff --git a/scim/services/src/main/java/org/keycloak/scim/services/ScimResourceTypeResource.java b/scim/services/src/main/java/org/keycloak/scim/services/ScimResourceTypeResource.java index e7c4ca5e1ca..96fd293b458 100644 --- a/scim/services/src/main/java/org/keycloak/scim/services/ScimResourceTypeResource.java +++ b/scim/services/src/main/java/org/keycloak/scim/services/ScimResourceTypeResource.java @@ -14,6 +14,7 @@ import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; @@ -31,6 +32,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelValidationException; import org.keycloak.scim.filter.ScimFilterException; import org.keycloak.scim.protocol.ForbiddenException; +import org.keycloak.scim.protocol.request.PatchRequest; import org.keycloak.scim.protocol.request.SearchRequest; import org.keycloak.scim.protocol.response.ErrorResponse; import org.keycloak.scim.protocol.response.ListResponse; @@ -166,6 +168,23 @@ public class ScimResourceTypeResource { (rScimResourceTypeProvider, r) -> resourceTypeProvider.update(r)); } + @Path("{id}") + @PATCH + @Consumes({APPLICATION_SCIM_JSON, MediaType.APPLICATION_JSON}) + @Produces(APPLICATION_SCIM_JSON) + public Response patch(@PathParam("id") String id, PatchRequest request) { + R existing = getResource(id); + + if (existing == null) { + return resourceNotFound(id); + } + + return onPersist(existing, Status.OK, (rScimResourceTypeProvider, r) -> { + resourceTypeProvider.patch(existing, request.getOperations()); + return getResource(id); + }); + } + @SuppressWarnings("unchecked") private R parseResourceTypePayload(InputStream is) { try { @@ -204,7 +223,7 @@ public class ScimResourceTypeResource { setMetadata(resource, Time.currentTimeMillis()); - return Response.status(status).entity(resource).build(); + return Response.status(status).entity(r).build(); } catch (ModelValidationException mve) { String language = session.getContext().getRequestHeaders().getHeaderString(HttpHeaders.ACCEPT_LANGUAGE); Properties messages = getMessageBundle(language); 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 7318f0c66ac..3ce756b760d 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 @@ -447,12 +447,19 @@ public class FilterTest extends AbstractScimTest { user = client.users().create(user); final String userName = user.getUserName(); - String filter = ResourceFilter.filter().eq("emails[0].value", emailValue).build(); + String filter = ResourceFilter.filter().eq("emails", emailValue).build(); ListResponse response = client.users().getAll(filter); assertThat(response, is(not(nullValue()))); assertThat(response.getTotalResults(), greaterThanOrEqualTo(1)); assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(userName)), is(true)); + + filter = ResourceFilter.filter().eq("emails.value", emailValue).build(); + response = client.users().getAll(filter); + + assertThat(response, is(not(nullValue()))); + assertThat(response.getTotalResults(), greaterThanOrEqualTo(1)); + assertThat(response.getResources().stream().anyMatch(u -> u.getUserName().equals(userName)), is(true)); } @Test @@ -601,7 +608,7 @@ public class FilterTest extends AbstractScimTest { final String userName = user.getUserName(); // Test POST search with email contains filter - String filter = ResourceFilter.filter().co("emails[0].value", "postemailtest").build(); + String filter = ResourceFilter.filter().co("emails", "postemailtest").build(); ListResponse response = client.users().search(filter); assertThat(response, is(not(nullValue()))); 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 19b21057200..b3a01fb44ea 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 @@ -2,6 +2,7 @@ package org.keycloak.tests.scim.tck; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.scim.protocol.request.PatchRequest; import org.keycloak.scim.resource.group.Group; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; @@ -52,6 +53,22 @@ public class GroupTest extends AbstractScimTest { assertEquals(expected.getDisplayName(), actual.getDisplayName()); } + @Test + public void testPatch() { + Group expected = new Group(); + expected.setDisplayName(KeycloakModelUtils.generateId()); + expected = client.groups().create(expected); + assertNotNull(expected); + + expected = client.groups().get(expected.getId()); + expected.setDisplayName("Updated " + expected.getDisplayName()); + client.groups().patch(expected.getId(), PatchRequest.create() + .replace("displayName", expected.getDisplayName()) + .build()); + Group actual = client.groups().get(expected.getId()); + assertEquals(expected.getDisplayName(), actual.getDisplayName()); + } + @Test public void testGetExisting() { GroupRepresentation rep = new GroupRepresentation(); 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 d228941db8d..683bff2b787 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 @@ -16,6 +16,7 @@ import org.keycloak.representations.userprofile.config.UPAttributeRequired; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.scim.client.ScimClient; import org.keycloak.scim.client.ScimClientException; +import org.keycloak.scim.protocol.request.PatchRequest; import org.keycloak.scim.protocol.response.ErrorResponse; import org.keycloak.scim.resource.common.Email; import org.keycloak.scim.resource.common.Name; @@ -32,7 +33,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA; import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE; import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA; import static org.keycloak.scim.resource.Scim.USER_RESOURCE_TYPE; @@ -82,7 +82,7 @@ public class UserTest extends AbstractScimTest { public void testCreateWithSingleEmail() { User expected = new User(); expected.setUserName(KeycloakModelUtils.generateId()); - expected.setEmail(expected.getEmail() + "@keycloak.org"); + expected.setEmail(expected.getUserName() + "@keycloak.org"); User actual = client.users().create(expected); actual = client.users().get(actual.getId()); @@ -183,26 +183,19 @@ public class UserTest extends AbstractScimTest { UPConfig configuration = realm.admin().users().userProfile().getConfiguration(); configuration.addOrReplaceAttribute(new UPAttribute("department", Map.of( - ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA, - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "department"))); + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".department"))); configuration.addOrReplaceAttribute(new UPAttribute("division", Map.of( - ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA, - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "division"))); + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".division"))); configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of( - ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA, - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "costCenter"))); + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".costCenter"))); configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of( - ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA, - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "employeeNumber"))); + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber"))); configuration.addOrReplaceAttribute(new UPAttribute("organization", Map.of( - ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA, - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "organization"))); + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".organization"))); configuration.addOrReplaceAttribute(new UPAttribute("manager", Map.of( - ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA, - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "manager.value"))); + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.value"))); configuration.addOrReplaceAttribute(new UPAttribute("managerName", Map.of( - ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA, - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "manager.dispayName"))); + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.dispayName"))); realm.admin().users().userProfile().update(configuration); User expected = createUser(); @@ -222,8 +215,8 @@ public class UserTest extends AbstractScimTest { assertEquals(2, actual.getSchemas().size()); assertRootAttributes(actual, expected); assertNotNull(actual.getEnterpriseUser()); - assertEquals(enterpriseUser.getDepartment(), actual.getEnterpriseUser().getDepartment()); assertEquals(enterpriseUser.getDivision(), actual.getEnterpriseUser().getDivision()); + assertEquals(enterpriseUser.getDepartment(), actual.getEnterpriseUser().getDepartment()); assertEquals(enterpriseUser.getCostCenter(), actual.getEnterpriseUser().getCostCenter()); assertEquals(enterpriseUser.getOrganization(), actual.getEnterpriseUser().getOrganization()); assertEquals(enterpriseUser.getEmployeeNumber(), actual.getEnterpriseUser().getEmployeeNumber()); @@ -350,6 +343,334 @@ public class UserTest extends AbstractScimTest { } } + @Test + public void testPatchAdd() { + User expected = client.users().create(createUser()); + UPConfig configuration = realm.admin().users().userProfile().getConfiguration(); + configuration.addOrReplaceAttribute(new UPAttribute("middleName", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.middleName"))); + configuration.addOrReplaceAttribute(new UPAttribute("honorificPrefix", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.honorificPrefix"))); + configuration.addOrReplaceAttribute(new UPAttribute("honorificSuffix", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.honorificSuffix"))); + realm.admin().users().userProfile().update(configuration); + + // patch multiple attributes in a single request + client.users().patch(expected.getId(), PatchRequest.create() + .add("name", "{\"givenName\": \"PatchedGivenName\"}") + .add("name.middleName", "MiddleName") + .add("name.honorificPrefix", "HonorificPrefix") + .add("name.honorificSuffix", "HonorificSuffix") + .add("active", "false") + .build()); + User actual = client.users().get(expected.getId()); + expected.setFirstName("PatchedGivenName"); + expected.getName().setMiddleName("MiddleName"); + expected.getName().setHonorificPrefix("HonorificPrefix"); + expected.getName().setHonorificSuffix("HonorificSuffix"); + expected.setActive(false); + assertRootAttributes(actual, expected); + + // patch a specific attribute by providing its path and the value as a JSON object + // this is needed to patch complex attributes like "emails" which is a multi-valued attribute with sub-attributes + // for now, we're only mapping the "value" from a complex attribute as the value to be patched + client.users().patch(expected.getId(), PatchRequest.create() + .add("emails", "{\"value\": \"" + expected.getEmail().replace("keycloak.org", "patched.org") + "\", \"type\": \"work\", \"primary\": true}") + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("keycloak.org", "patched.org")); + assertRootAttributes(actual, expected); + + // patch a specific attribute by providing its path and the value as a JSON array + client.users().patch(expected.getId(), PatchRequest.create() + .add("emails", "[{\"value\": \"" + expected.getEmail().replace("patched.org", "patched2.org") + "\", \"type\": \"work\", \"primary\": true}]") + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("patched.org", "patched2.org")); + assertRootAttributes(actual, expected); + + // patch a complex attribute by providing only the value as a JSON object without a path. + // in this case, the path is derived from the structure of the JSON object. + client.users().patch(expected.getId(), PatchRequest.create() + .add("{\"emails\": [{\"value\": \"" + expected.getEmail().replace("patched2.org", "patched3.org") + "\", \"type\": \"work\", \"primary\": true}]}") + .add("{\"name\": {\"givenName\": \"PatchedGivenName2\"}}") + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("patched2.org", "patched3.org")); + expected.setFirstName("PatchedGivenName2"); + assertRootAttributes(actual, expected); + + // patch multiple attributes by providing only the value as a JSON object without a path. + client.users().patch(expected.getId(), PatchRequest.create() + .add("{\"emails\": [{\"value\": \"" + expected.getEmail().replace("patched3.org", "patched4.org") + "\", \"type\": \"work\", \"primary\": true}], \"name\": {\"givenName\": \"PatchedGivenName3\"}, \"active\": false}") + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("patched3.org", "patched4.org")); + expected.setFirstName("PatchedGivenName3"); + expected.setActive(false); + assertRootAttributes(actual, expected); + + // patch a multivalued attribute by using the value subattribute in the path + expected.setEmail(expected.getEmail().replace("patched4.org", "patched5.org")); + client.users().patch(expected.getId(), PatchRequest.create() + .add("emails.value", expected.getEmail()) + .build()); + actual = client.users().get(expected.getId()); + assertRootAttributes(actual, expected); + + // patch a multivalued attribute using a filter in the path, path filtering is not yet supported + expected.setEmail(expected.getEmail().replace("patched5.org", "patched6.org")); + client.users().patch(expected.getId(), PatchRequest.create() + .add("emails[type eq \"work\"].value", expected.getEmail()) + .build()); + actual = client.users().get(expected.getId()); + assertRootAttributes(actual, expected); + + // patch a multivalued attribute using a filter in the path and the primary subattribute, which is not supported yet + client.users().patch(expected.getId(), PatchRequest.create() + .add("emails[type eq \"work\"].primary", "false") + .build()); + actual = client.users().get(expected.getId()); + assertTrue(actual.getEmails().get(0).getPrimary()); + assertRootAttributes(actual, expected); + + // patch a simple attribute by providing only the value as a JSON object without a path. + client.users().patch(expected.getId(), PatchRequest.create() + .add("{\"active\": true}") + .build()); + actual = client.users().get(expected.getId()); + expected.setActive(true); + assertRootAttributes(actual, expected); + + // patch an attribute from an extension schema + configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber"))); + realm.admin().users().userProfile().update(configuration); + assertNull(actual.getEnterpriseUser()); + client.users().patch(expected.getId(), PatchRequest.create() + .add(ENTERPRISE_USER_SCHEMA + ":" + "employeeNumber", "1234") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + expected.setEnterpriseUser(new EnterpriseUser()); + expected.getEnterpriseUser().setEmployeeNumber("1234"); + assertEquals("1234", actual.getEnterpriseUser().getEmployeeNumber()); + + client.users().patch(expected.getId(), PatchRequest.create() + .add(ENTERPRISE_USER_SCHEMA, "{\"employeeNumber\": \"4321\"}") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + expected.setEnterpriseUser(new EnterpriseUser()); + expected.getEnterpriseUser().setEmployeeNumber("4321"); + assertEquals("4321", actual.getEnterpriseUser().getEmployeeNumber()); + + client.users().patch(expected.getId(), PatchRequest.create() + .add("{\"" + ENTERPRISE_USER_SCHEMA + "\":{\"employeeNumber\": \"1234\"}}") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + assertEquals("1234", actual.getEnterpriseUser().getEmployeeNumber()); + + // patch attributes from the core schema and an extension schema in a single request by providing the values as a JSON object without a path. + client.users().patch(expected.getId(), PatchRequest.create() + .add("{\"name.givenName\": \"Amanda\", \"" + ENTERPRISE_USER_SCHEMA + ":employeeNumber\": \"321\"}}") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + assertEquals("321", actual.getEnterpriseUser().getEmployeeNumber()); + assertEquals("Amanda", actual.getFirstName()); + + configuration.addOrReplaceAttribute(new UPAttribute("managerId", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.value"))); + realm.admin().users().userProfile().update(configuration); + // patch a sub attribute of a complex attribute using a direct path + client.users().patch(expected.getId(), PatchRequest.create() + .add("{\"name.givenName\": \"Alice\", \"" + ENTERPRISE_USER_SCHEMA + ":manager\": \"321\"}}") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + assertNotNull(actual.getEnterpriseUser().getManager()); + assertEquals("321", actual.getEnterpriseUser().getManager().getValue()); + assertEquals("Alice", actual.getFirstName()); + + client.users().patch(expected.getId(), PatchRequest.create() + .add("{\"name.givenName\": \"Amanda\", \"" + ENTERPRISE_USER_SCHEMA + "\": {\"manager\": {\"value\": \"567\"}}}") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + assertNotNull(actual.getEnterpriseUser().getManager()); + assertEquals("567", actual.getEnterpriseUser().getManager().getValue()); + assertEquals("Amanda", actual.getFirstName()); + } + + @Test + public void testPatchReplace() { + User expected = client.users().create(createUser()); + UPConfig configuration = realm.admin().users().userProfile().getConfiguration(); + configuration.addOrReplaceAttribute(new UPAttribute("middleName", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.middleName"))); + configuration.addOrReplaceAttribute(new UPAttribute("honorificPrefix", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.honorificPrefix"))); + configuration.addOrReplaceAttribute(new UPAttribute("honorificSuffix", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.honorificSuffix"))); + realm.admin().users().userProfile().update(configuration); + + // patch multiple attributes in a single request + client.users().patch(expected.getId(), PatchRequest.create() + .replace("name", "{\"givenName\": \"PatchedGivenName\"}") + .replace("name.middleName", "MiddleName") + .replace("name.honorificPrefix", "HonorificPrefix") + .replace("name.honorificSuffix", "HonorificSuffix") + .replace("active", "false") + .build()); + User actual = client.users().get(expected.getId()); + expected.setFirstName("PatchedGivenName"); + expected.getName().setMiddleName("MiddleName"); + expected.getName().setHonorificPrefix("HonorificPrefix"); + expected.getName().setHonorificSuffix("HonorificSuffix"); + expected.setActive(false); + assertRootAttributes(actual, expected); + + // patch a specific attribute by providing its path and the value as a JSON object + // this is needed to patch complex attributes like "emails" which is a multi-valued attribute with sub-attributes + // for now, we're only mapping the "value" from a complex attribute as the value to be patched + client.users().patch(expected.getId(), PatchRequest.create() + .replace("emails", "{\"value\": \"" + expected.getEmail().replace("keycloak.org", "patched.org") + "\", \"type\": \"work\", \"primary\": true}") + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("keycloak.org", "patched.org")); + assertRootAttributes(actual, expected); + + // patch a specific attribute by providing its path and the value as a JSON array + client.users().patch(expected.getId(), PatchRequest.create() + .replace("emails", "[{\"value\": \"" + expected.getEmail().replace("patched.org", "patched2.org") + "\", \"type\": \"work\", \"primary\": true}]") + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("patched.org", "patched2.org")); + assertRootAttributes(actual, expected); + + // patch a complex attribute by providing only the value as a JSON object without a path. + // in this case, the path is derived from the structure of the JSON object. + client.users().patch(expected.getId(), PatchRequest.create() + .replace("{\"emails\": [{\"value\": \"" + expected.getEmail().replace("patched2.org", "patched3.org") + "\", \"type\": \"work\", \"primary\": true}]}") + .replace("{\"name\": {\"givenName\": \"PatchedGivenName2\"}}") + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("patched2.org", "patched3.org")); + expected.setFirstName("PatchedGivenName2"); + assertRootAttributes(actual, expected); + + // patch a simple attribute by providing only the value as a JSON object without a path. + client.users().patch(expected.getId(), PatchRequest.create() + .replace("{\"active\": true}") + .build()); + actual = client.users().get(expected.getId()); + expected.setActive(true); + assertRootAttributes(actual, expected); + + // patch multiple attributes by providing only the value as a JSON object without a path. + client.users().patch(expected.getId(), PatchRequest.create() + .replace("{\"emails\": [{\"value\": \"" + expected.getEmail().replace("patched3.org", "patched4.org") + "\", \"type\": \"work\", \"primary\": true}], \"name\": {\"givenName\": \"PatchedGivenName3\"}, \"active\": false}") + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("patched3.org", "patched4.org")); + expected.setFirstName("PatchedGivenName3"); + expected.setActive(false); + assertRootAttributes(actual, expected); + + // patch an attribute from an extension schema + configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber"))); + realm.admin().users().userProfile().update(configuration); + assertNull(actual.getEnterpriseUser()); + client.users().patch(expected.getId(), PatchRequest.create() + .replace(ENTERPRISE_USER_SCHEMA + ":" + "employeeNumber", "1234") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + expected.setEnterpriseUser(new EnterpriseUser()); + expected.getEnterpriseUser().setEmployeeNumber("1234"); + assertEquals("1234", actual.getEnterpriseUser().getEmployeeNumber()); + + client.users().patch(expected.getId(), PatchRequest.create() + .replace(ENTERPRISE_USER_SCHEMA, "{\"employeeNumber\": \"4321\"}") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + expected.setEnterpriseUser(new EnterpriseUser()); + expected.getEnterpriseUser().setEmployeeNumber("4321"); + assertEquals("4321", actual.getEnterpriseUser().getEmployeeNumber()); + + client.users().patch(expected.getId(), PatchRequest.create() + .replace("{\"" + ENTERPRISE_USER_SCHEMA + "\":{\"employeeNumber\": \"1234\"}}") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + expected.setEnterpriseUser(new EnterpriseUser()); + expected.getEnterpriseUser().setEmployeeNumber("1234"); + assertEquals("1234", actual.getEnterpriseUser().getEmployeeNumber()); + } + + @Test + public void testPatchRemove() { + User expected = client.users().create(createUser()); + UPConfig configuration = realm.admin().users().userProfile().getConfiguration(); + configuration.addOrReplaceAttribute(new UPAttribute("middleName", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.middleName"))); + configuration.addOrReplaceAttribute(new UPAttribute("honorificPrefix", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.honorificPrefix"))); + configuration.addOrReplaceAttribute(new UPAttribute("honorificSuffix", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.honorificSuffix"))); + realm.admin().users().userProfile().update(configuration); + + // patch multiple attributes in a single request + client.users().patch(expected.getId(), PatchRequest.create() + .add("name", "{\"givenName\": \"PatchedGivenName\"}") + .add("name.middleName", "MiddleName") + .add("name.honorificPrefix", "HonorificPrefix") + .add("name.honorificSuffix", "HonorificSuffix") + .build()); + User actual = client.users().get(expected.getId()); + expected.setFirstName("PatchedGivenName"); + expected.getName().setMiddleName("MiddleName"); + expected.getName().setHonorificPrefix("HonorificPrefix"); + expected.getName().setHonorificSuffix("HonorificSuffix"); + expected.setActive(true); + assertRootAttributes(actual, expected); + + client.users().patch(expected.getId(), PatchRequest.create() + .remove("name.honorificPrefix") + .remove("name.honorificSuffix") + .build()); + actual = client.users().get(expected.getId()); + expected.getName().setHonorificPrefix(null); + expected.getName().setHonorificSuffix(null); + assertRootAttributes(actual, expected); + + assertNull(actual.getEnterpriseUser()); + configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber"))); + configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".costCenter"))); + realm.admin().users().userProfile().update(configuration); + client.users().patch(expected.getId(), PatchRequest.create() + .add(ENTERPRISE_USER_SCHEMA + ":" + "employeeNumber", "1234") + .add(ENTERPRISE_USER_SCHEMA + ":" + "costCenter", "5678") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + assertEquals("1234", actual.getEnterpriseUser().getEmployeeNumber()); + assertEquals("5678", actual.getEnterpriseUser().getCostCenter()); + client.users().patch(expected.getId(), PatchRequest.create() + .remove(ENTERPRISE_USER_SCHEMA + ":" + "employeeNumber") + .build()); + actual = client.users().get(expected.getId()); + assertNotNull(actual.getEnterpriseUser()); + assertNull(actual.getEnterpriseUser().getEmployeeNumber()); + assertEquals("5678", actual.getEnterpriseUser().getCostCenter()); + } + private void assertRootAttributes(User actual, User expected) { assertNotNull(actual); assertTrue(actual.hasSchema(getCoreSchema(expected.getClass()))); @@ -364,13 +685,10 @@ public class UserTest extends AbstractScimTest { assertEquals(expected.getActive(), actual.getActive()); } - if (expected.getEmail() != null) { - assertNotNull(actual.getEmails()); - assertEquals(expected.getEmails().size(), actual.getEmails().size()); - + if (expected.getEmails() != null) { for (Email email : expected.getEmails()) { Email actualEmail = actual.getEmails().stream() - .filter((e) -> email.getValue().equals(e.getValue())) + .filter((e) -> email.getValue() != null && email.getValue().equals(e.getValue())) .findFirst() .orElse(null); assertNotNull(actualEmail); @@ -384,11 +702,10 @@ public class UserTest extends AbstractScimTest { if (name != null) { assertEquals(name.getFamilyName(), actual.getName().getFamilyName()); assertEquals(name.getGivenName(), actual.getName().getGivenName()); -// TODO: support for middleName, formatted, honorificPrefix, honorificSuffix -// assertEquals(name.getMiddleName(), actual.getName().getMiddleName()); + assertEquals(name.getMiddleName(), actual.getName().getMiddleName()); // assertEquals(name.getFormatted(), actual.getName().getFormatted()); -// assertEquals(name.getHonorificPrefix(), actual.getName().getHonorificPrefix()); -// assertEquals(name.getHonorificSuffix(), actual.getName().getHonorificSuffix()); + assertEquals(name.getHonorificPrefix(), actual.getName().getHonorificPrefix()); + assertEquals(name.getHonorificSuffix(), actual.getName().getHonorificSuffix()); } // assertEquals(expected.getNickName(), actual.getNickName()); @@ -404,11 +721,7 @@ public class UserTest extends AbstractScimTest { Name name = new Name(); name.setGivenName(user.getUserName() + "_Given"); - name.setMiddleName(user.getUserName() + "_Middle"); name.setFamilyName(user.getUserName() + "_Family"); - name.setFormatted(name.getGivenName() + " " + name.getMiddleName() + " " + name.getFamilyName()); - name.setHonorificPrefix("Mr."); - name.setHonorificSuffix("Jr."); user.setName(name); user.setNickName("mynickname"); diff --git a/server-spi-private/src/main/java/org/keycloak/utils/JsonUtils.java b/server-spi-private/src/main/java/org/keycloak/utils/JsonUtils.java index 9ffc83ad8a1..2ab53dee9f6 100644 --- a/server-spi-private/src/main/java/org/keycloak/utils/JsonUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/utils/JsonUtils.java @@ -149,66 +149,75 @@ public class JsonUtils { */ public static Object getJsonValue(JsonNode node, String claim) { if (node != null) { - List fields = splitClaimPath(claim); - if (fields.isEmpty() || claim.endsWith(".")) { + List paths = splitClaimPath(claim); + if (paths.isEmpty() || claim.endsWith(".")) { return null; } - JsonNode currentNode = node; - for (String currentFieldName : fields) { - - // if array path, retrieve field name and index - String currentNodeName = currentFieldName; - int arrayIndex = -1; - if (currentFieldName.endsWith("]")) { - int bi = currentFieldName.indexOf("["); - if (bi == -1) { - return null; - } - try { - String is = currentFieldName.substring(bi + 1, currentFieldName.length() - 1).trim(); - arrayIndex = Integer.parseInt(is); - if( arrayIndex < 0) throw new ArrayIndexOutOfBoundsException(); - } catch (Exception e) { - return null; - } - currentNodeName = currentFieldName.substring(0, bi).trim(); - } - - currentNode = currentNode.get(currentNodeName); - - if (currentNode != null && arrayIndex > -1 && currentNode.isArray()) { - currentNode = currentNode.get(arrayIndex); - } - - if (currentNode == null) { - return null; - } - - if (currentNode.isArray()) { - List values = new ArrayList<>(); - for (JsonNode childNode : currentNode) { - if (childNode.isTextual()) { - values.add(childNode.textValue()); - } - } - if (values.isEmpty()) { - return null; - } - return values ; - } else if (currentNode.isNull()) { - return null; - } else if (currentNode.isValueNode()) { - String ret = currentNode.asText(); - if (ret != null && !ret.trim().isEmpty()) - return ret.trim(); - else - return null; - } - - } - return currentNode; + return getJsonValue(node, paths); } + return null; } + + public static Object getJsonValue(JsonNode node, List paths) { + JsonNode currentNode = node; + for (String currentFieldName : paths) { + + // if array path, retrieve field name and index + String currentNodeName = currentFieldName; + int arrayIndex = -1; + if (currentFieldName.endsWith("]")) { + int bi = currentFieldName.indexOf("["); + if (bi == -1) { + return null; + } + try { + String is = currentFieldName.substring(bi + 1, currentFieldName.length() - 1).trim(); + arrayIndex = Integer.parseInt(is); + if( arrayIndex < 0) throw new ArrayIndexOutOfBoundsException(); + } catch (Exception e) { + return null; + } + currentNodeName = currentFieldName.substring(0, bi).trim(); + } + + currentNode = currentNode.get(currentNodeName); + + if (currentNode != null && arrayIndex > -1 && currentNode.isArray()) { + currentNode = currentNode.get(arrayIndex); + } + + if (currentNode == null) { + return null; + } + + if (currentNode.isArray()) { + List values = new ArrayList<>(); + for (JsonNode childNode : currentNode) { + if (childNode.isTextual()) { + values.add(childNode.textValue()); + } else if (childNode.isValueNode()) { + values.add(childNode.asText()); + } else { + values.add(childNode); + } + } + if (values.isEmpty()) { + return null; + } + return values ; + } else if (currentNode.isNull()) { + return null; + } else if (currentNode.isValueNode()) { + String ret = currentNode.asText(); + if (ret != null && !ret.trim().isEmpty()) + return ret.trim(); + else + return null; + } + + } + return currentNode; + } } diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java index 64e7ea5ca1b..9d8befeeb49 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java @@ -104,6 +104,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide private static final String[] DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp" }; private static final Pattern readOnlyAttributesPattern = getRegexPatternString(DEFAULT_READ_ONLY_ATTRIBUTES); private static final Pattern adminReadOnlyAttributesPattern = getRegexPatternString(DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES); + private static final String ANNOTATION_SCIM_SCHEMA_ATTRIBUTE = "kc.scim.schema.attribute"; private static volatile UPConfig PARSED_DEFAULT_RAW_CONFIG; private final Map contextualMetadataRegistry = new HashMap<>(); @@ -278,36 +279,21 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide String coreSchema = "urn:ietf:params:scim:schemas:core:2.0:User"; metadata.getAttribute(UserModel.USERNAME).get(0) - .addAnnotations(Map.of("scim.schema", coreSchema, - "scim.schema.attribute", "userName", - "primary", "true")); + .addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "userName")); metadata.getAttribute(UserModel.EMAIL).get(0) - .addAnnotations(Map.of("scim.schema", coreSchema, - "scim.schema.attribute", "emails[0].value", - "primary", "true")); + .addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "emails")); metadata.addAttribute(UserModel.FIRST_NAME, -1, AttributeMetadata.ALWAYS_TRUE, AttributeMetadata.ALWAYS_TRUE) - .addAnnotations(Map.of("scim.schema", coreSchema, - "scim.schema.attribute", "name.givenName", - "primary", "true")); + .addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.givenName")); metadata.addAttribute(UserModel.LAST_NAME, -1, AttributeMetadata.ALWAYS_TRUE, AttributeMetadata.ALWAYS_TRUE) - .addAnnotations(Map.of("scim.schema", coreSchema, - "scim.schema.attribute", "name.familyName", - "primary", "true")); + .addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.familyName")); metadata.addAttribute(UserModel.ENABLED, -1, AttributeMetadata.ALWAYS_TRUE, AttributeMetadata.ALWAYS_TRUE) - .addAnnotations(Map.of("scim.schema", coreSchema, - "scim.schema.attribute", "active", - "primary", "true", - "type", "boolean")); + .addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "active")); metadata.addAttribute(UserModel.CREATED_TIMESTAMP, -1, AttributeMetadata.ALWAYS_FALSE, AttributeMetadata.ALWAYS_TRUE) .setRequired(AttributeMetadata.ALWAYS_FALSE) - .addAnnotations(Map.of("scim.schema", coreSchema, - "scim.schema.attribute", "meta.created", - "primary", "true", - "type", "timestamp")); + .addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "meta.created")); metadata.addAttribute(UserModel.LOCALE, -1, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled) .setRequired(AttributeMetadata.ALWAYS_FALSE) - .addAnnotations(Map.of("scim.schema", coreSchema, - "scim.schema.attribute", "locale")) + .addAnnotations(Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "locale")) .setSelector(c -> { RealmModel realm = c.getSession().getContext().getRealm(); return realm.isInternationalizationEnabled();