Support for PATCH operations (#46561)

Closes #46214

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor
2026-03-03 05:00:28 -03:00
committed by GitHub
parent eea8babff7
commit d52e5a1234
30 changed files with 1439 additions and 442 deletions
@@ -142,7 +142,7 @@ public class UserAdapter implements UserModel, JpaModel<UserEntity> {
}
// Remove all existing
if (value == null) {
user.getAttributes().removeIf(a -> a.getName().equals(name));
removeAttribute(name);
} else {
Set<String> oldEntries = getAttributeStream(name).collect(Collectors.toSet());
Set<String> newEntries = Set.of(value);
@@ -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<R extends ResourceTypeRepresent
}
}
public void patch(String id, PatchRequest request) {
requireNonNull(request, "request must not be null");
client.execute(client.doPatch(resourceTypeClass, id).json(request));
}
@SuppressWarnings("unchecked")
protected ListResponse<R> doFilter(ResourceFilter filter) {
SimpleHttpRequest request = doGet("");
@@ -138,6 +138,11 @@ public final class ScimClient implements AutoCloseable {
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_SCIM_JSON);
}
SimpleHttpRequest doPatch(Class<? extends ResourceTypeRepresentation> resourceType, String id) {
return beforeRequest(http.doPatch(baseUrl + getResourceTypePath(resourceType) + "/" + id))
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_SCIM_JSON);
}
<T> T execute(SimpleHttpRequest request, Class<T> responseType) {
try (SimpleHttpResponse response = execute(request)) {
if (responseType == null) {
@@ -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<PatchOperation> operations;
public PatchRequest() {
// reflection
}
public PatchRequest(List<PatchOperation> operations) {
this.operations = operations;
}
public static Builder create() {
return new Builder();
}
public Set<String> 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<PatchOperation> 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);
}
}
}
@@ -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) {
@@ -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<M extends Model, R extends ResourceTypeRepresentation> implements ModelSchema<M, R> {
private final String name;
private final Map<String, Attribute<M, R>> attributes;
private Map<String, Attribute<M, R>> attributes;
protected AbstractModelSchema(String name, List<Attribute<M, R>> 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<M extends Model, R extends ResourceTyp
@Override
public Map<String, Attribute<M, R>> getAttributes() {
if (attributes == null) {
attributes = doGetAttributes();
}
return attributes;
}
protected abstract Map<String, Attribute<M,R>> doGetAttributes();
@Override
public void populate(M model, R representation) {
populateModel(model, representation);
@@ -55,6 +65,39 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
// validate here the schema
}
@Override
public void add(M model, String rawPath, JsonNode value) {
Objects.requireNonNull(model, "model cannot be null");
Objects.requireNonNull(value, "value cannot be null");
String path = Optional.ofNullable(rawPath).map(this::formatPath).orElse(getName());
Map<Attribute<M, R>, JsonNode> attributes = resolveAttributes(path, value);
for (Entry<Attribute<M, R>, 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<M, R> 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<M extends Model, R extends ResourceTyp
*/
protected abstract String getAttributeSchemaName(M model, String name);
/**
* Returns the name of the schema for the attribute with the given {@code name} from the given {@code model}.
* It is used to determine which schema the attribute belongs to.
*
* @param model the model to get the attribute schema from
* @param name the name of the attribute to get the schema from
* @return the name of the schema for the attribute with the given name from the model
*/
protected abstract String getAttributeSchema(M model, String name);
private void populateModel(M model, R resource) {
ObjectNode objectNode;
@@ -101,7 +134,7 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
}
for (String name : getAttributeNames(model)) {
Attribute<M, R> attribute = getAttributeMapper(model, resource, name);
Attribute<M, R> attribute = getAttributeMapper(model, name);
if (attribute == null) {
continue;
@@ -115,20 +148,42 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
Object value = getJsonValue(objectNode, attribute.getName());
if (value == null) {
String attributeName = attribute.getName();
if (attributeName.indexOf('.') > 0) {
attributeName = attributeName.substring(attributeName.indexOf('.') + 1);
List<String> 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<M, R> attribute = getAttributeMapper(model, resource, name);
Attribute<M, R> attribute = getAttributeMapper(model, name);
if (attribute == null) {
continue;
@@ -146,23 +201,175 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
}
}
private Attribute<M, R> 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<M, R> getAttributeMapper(M model, String name) {
String scimName = getAttributeSchemaName(model, name);
if (scimName == null) {
return null;
}
return attributes.get(scimName.toString());
Attribute<M, R> attribute = getAttributes().get(scimName);
if (attribute != null) {
return attribute;
}
if (!isCore() && scimName.startsWith(getName())) {
scimName = scimName.substring(getName().length() + 1);
}
for (Entry<String, Attribute<M, R>> entry : getAttributes().entrySet()) {
Attribute<M, R> attr = entry.getValue();
String parent = attr.getParentName();
if (parent != null && entry.getKey().equals(parent + "." + scimName)) {
return attr;
}
}
return null;
}
private Map<Attribute<M,R>, 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<Attribute<M, R>, JsonNode> attributes = new HashMap<>();
// try resolve a direct reference to an attribute first
Attribute<M, R> attribute = getAttributes().get(path);
if (attribute == null) {
for (Entry<String, Attribute<M, R>> entry : getAttributes().entrySet()) {
Attribute<M, R> attr = entry.getValue();
List<String> 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<String, JsonNode> property : valueJson.properties()) {
Attribute<M, R> 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<String, JsonNode> 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<M, R> resolved, JsonNode value) {
for (String name : getAttributeNames(model)) {
Attribute<M, R> 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<M, R> 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<String, JsonNode> 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<M, R> resolveAttribute(String name) {
Map<Attribute<M, R>, JsonNode> attributes = resolveAttributes(name, NullNode.getInstance());
if (attributes.isEmpty()) {
return null;
}
return attributes.keySet().iterator().next();
}
}
@@ -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;
/**
* <p>An interface that represents a schema for a resource type.
*
@@ -53,4 +55,42 @@ public interface ModelSchema<M extends Model, R extends ResourceTypeRepresentati
* @throws SchemaValidationException if the representation is not valid against the schema
*/
void validate(R representation) throws SchemaValidationException;
/**
* Performs a PATCH {@code add} operation on the given {@code model} for the attribute defined by the given {@code path} and the given {@code value}.
*
* @param model the model to perform the operation on
* @param path the path of the attribute to perform the operation on. It can be null if the operation is performed on the whole resource.
* @param value the value
*/
default void add(M model, String path, JsonNode value) {
throw new UnsupportedOperationException("Add operation is not supported for this schema");
}
/**
* Performs a PATCH {@code replace} operation on the given {@code model} for the attribute defined by the given {@code path} and the given {@code value}.
*
* @param model the model to perform the operation on
* @param path the path of the attribute to perform the operation on. It can be null if the operation is performed on the whole resource.
* @param value the value
*/
default void replace(M model, String path, JsonNode value) {
throw new UnsupportedOperationException("Add operation is not supported for this schema");
}
/**
* Performs a PATCH {@code remove} operation on the given {@code model} for the attribute defined by the given {@code path}.
*
* @param model the model to perform the operation on
* @param path the path of the attribute to perform the operation on
*/
default void remove(M model, String path) {
throw new UnsupportedOperationException("Add operation is not supported for this schema");
}
default boolean isCore() {
return true;
}
Attribute<M, R> resolveAttribute(String name);
}
@@ -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<M extends Model, R extends ResourceTypeRepresentation> {
private final String alias;
private BiFunction<KeycloakSession, Attribute<M, R>, 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 <M extends Model, R extends ResourceTypeRepresentation> Builder<M, R> simple(String name) {
return new Builder<>(name, null);
}
/**
* <p>Creates a complex attribute with the given {@code name} and {@code complexType}.
* <p>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 <M extends Model, R extends ResourceTypeRepresentation> Builder<M, R> complex(String name, Class<?> complexType) {
return new Builder<>(name, complexType);
}
/**
* <p>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 <M extends Model, R extends ResourceTypeRepresentation> Builder<M, R> complex(String name, TriConsumer<M, String, String> modelSetter, BiFunction<KeycloakSession, Attribute<M, R>, String> resolver, boolean primary) {
return (Builder<M, R>) new Builder<>(name, MultiValuedAttribute.class)
.primary(primary)
.modelAttributeResolver((session, attribute) -> resolver.apply(session, (Attribute<M, R>) attribute))
.withSetters((TriConsumer<Model, String, String>) modelSetter)
.withAttribute("value", name, (TriConsumer<Model, String, String>) modelSetter, primary)
.withAttribute("primary", (model, subName, value) -> {return;});
}
private final String name;
private final AttributeMapper<M, R> mapper;
private String parentName;
public Attribute(String name, AttributeMapper<M, R> mapper) {
private Attribute(String name, AttributeMapper<M, R> mapper, String parentName) {
this(name, mapper, parentName, null);
}
private Attribute(String name, AttributeMapper<M, R> mapper, String parentName, String alias) {
this.name = name;
this.mapper = mapper;
this.parentName = parentName;
this.alias = alias;
}
/**
@@ -28,6 +90,10 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
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<M extends Model, R extends ResourceTypeRepresentation> {
public AttributeMapper<M, R> 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<M, R> 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<KeycloakSession, Attribute<M, R>, 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<M extends Model, R extends ResourceTypeRepresentation> {
private final Class<?> complexType;
private final String name;
private TriConsumer<M, String, String> modelSetter;
private BiConsumer<R, String> representationSetter;
List<Attribute<M, R>> attributes = new ArrayList<>();
private BiFunction<KeycloakSession, Attribute<M, R>, 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 <C extends BiConsumer<R, String>> Builder<M, R> withSetters(TriConsumer<M, String, String> modelSetter, C representationSetter) {
this.modelSetter = modelSetter;
this.representationSetter = representationSetter;
return this;
}
public Builder<M, R> withSetters(TriConsumer<M, String, String> modelSetter) {
this.modelSetter = modelSetter;
this.representationSetter = new ComplexAttributeSetter<>(name, complexType);
return this;
}
public Builder<M, R> withSetters(BiConsumer<M, String> modelSetter) {
this.modelSetter = (model, name, value) -> modelSetter.accept(model, value);
this.representationSetter = new ComplexAttributeSetter<>(name, complexType);
return this;
}
public Builder<M, R> withAttribute(String name, TriConsumer<M, String, String> modelSetter) {
return withAttribute(name, null, modelSetter);
}
public Builder<M, R> withAttribute(String name, TriConsumer<M, String, String> modelSetter, boolean primary) {
return withAttribute(name, null, modelSetter, primary);
}
public Builder<M, R> withAttribute(String name, String alias, TriConsumer<M, String, String> modelSetter) {
return withAttribute(name, alias, modelSetter, false);
}
public Builder<M, R> withAttribute(String name, String alias, TriConsumer<M, String, String> modelSetter, boolean primary) {
String subName = this.name + "." + name;
Attribute<M, R> attribute = new Attribute<>(subName, new AttributeMapper<>(modelSetter, new ComplexAttributeSetter<>(this.name, name, complexType)), this.name, alias);
attribute.setModelAttributeResolver(modelAttributeResolver);
attribute.setPrimary(primary);
attributes.add(attribute);
return this;
}
public Builder<M, R> modelAttributeResolver(BiFunction<KeycloakSession, Attribute<M, R>, String> resolver) {
this.modelAttributeResolver = resolver;
return this;
}
public Builder<M, R> primary() {
return primary(true);
}
private Builder<M, R> primary(boolean primary) {
this.primary = primary;
return this;
}
public Builder<M, R> timestamp() {
this.type = "timestamp";
return this;
}
public Builder<M, R> bool() {
this.type = "boolean";
return this;
}
public Builder<M, R> immutable() {
this.mutability = "immutable";
return this;
}
public List<Attribute<M, R>> build() {
Attribute<M, R> 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;
}
}
}
@@ -31,15 +31,15 @@ public class AttributeMapper<M extends Model, R extends ResourceTypeRepresentati
this.representationSetter = representationSetter;
}
public AttributeMapper(BiConsumer<M, String> modelSetter, BiConsumer<R, String> 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);
}
}
}
@@ -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<R extends ResourceTypeRepresentation> implements BiConsumer<R, String> {
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<Object> 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);
}
}
}
@@ -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<M extends Model, R extends ResourceTypeRepresentation> implements ScimResourceTypeProvider<R> {
protected final KeycloakSession session;
@@ -109,11 +115,47 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
return onDelete(id);
}
@Override
public void patch(R existing, List<PatchOperation> 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<M, R> 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<ModelSchema<M, R>> getSchemas() {
return schemas;
}
@Override
public List<String> getSchemaExtensions() {
return schemaExtensions.stream().map(ModelSchema::getName).toList();
@@ -154,7 +196,7 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
private R createResourceTypeInstance() {
try {
return (R) getResourceType().getDeclaredConstructor().newInstance();
return getResourceType().getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Could not create instance of resource type " + getResourceType(), e);
}
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.stream.Stream;
import org.keycloak.provider.Provider;
import org.keycloak.scim.protocol.request.PatchRequest.PatchOperation;
import org.keycloak.scim.protocol.request.SearchRequest;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
@@ -99,4 +100,8 @@ public interface ScimResourceTypeProvider<R extends ResourceTypeRepresentation>
* @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<PatchOperation> operations) {
throw new UnsupportedOperationException("Add operation is not supported for resource type " + getName());
}
}
@@ -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");
}
}
@@ -1,11 +0,0 @@
package org.keycloak.scim.model.filter;
/**
* Resolves SCIM attribute paths to Keycloak attribute names.
*
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
*/
public interface AttributeNameResolver {
AttributeInfo resolve(String scimAttrPath);
}
@@ -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<JPAFi
private final CriteriaBuilder cb;
private final ScimJPAPredicateProvider predicateProvider;
public ScimJPAPredicateEvaluator(AttributeNameResolver resolver, CriteriaBuilder cb, CriteriaQuery<?> 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
@@ -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<ModelSchema<?, ?>> 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<ModelSchema<?, ?>> 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<String> attrExpr = getAttributeExpression(attrInfo);
Expression<String> 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<String> attrExpr = getAttributeExpression(attrInfo);
Expression<String> 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<String> getAttributeExpression(AttributeInfo attrInfo) {
private Expression<String> 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;
}
}
@@ -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<GroupModel, Group> {
private static final List<Attribute<GroupModel, Group>> 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<GroupModel,
return null;
}
@Override
protected String getAttributeSchema(GroupModel model, String name) {
return "urn:ietf:params:scim:schemas:core:2.0:Group";
}
@Override
protected String getAttributeSchemaName(GroupModel model, String name) {
if (name.equals("name")) {
@@ -52,4 +42,18 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
}
return null;
}
@Override
protected Map<String, Attribute<GroupModel, Group>> doGetAttributes() {
return new ArrayList<>((Attribute.<GroupModel, Group>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()));
}
}
@@ -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<GroupEntity> 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
@@ -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<UserModel ,User> {
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<Attribute<UserModel, User>> 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<UserMo
return names;
}
@Override
protected String getAttributeSchema(UserModel model, String name) {
Object schema = getAttributeAnnotations(model, name).get(ANNOTATION_SCIM_SCHEMA);
if (schema == null) {
return null;
}
return String.valueOf(schema);
}
@Override
protected String getAttributeSchemaName(UserModel model, String name) {
Object schema = getAttributeAnnotations(model, name).get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
@@ -72,11 +60,33 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema<UserMo
return Map.of();
}
return Optional.ofNullable(metadata.getAnnotations()).orElse(Map.of());
return ofNullable(metadata.getAnnotations()).orElse(Map.of());
}
private Attributes getAttributes(UserModel model) {
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, model);
return profile.getAttributes();
}
protected String createModelAttributeResolver(KeycloakSession session, Attribute<UserModel, User> 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<String, Object> 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;
}
}
@@ -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<UserModel, User> {
public UserAttributeMapper(BiConsumer<User, String> setter) {
super(UserModel::setSingleAttribute, setter);
}
}
@@ -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<UserModel, User>> 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<String, Attribute<UserModel, User>> doGetAttributes() {
List<Attribute<UserModel, User>> attributes = new ArrayList<>();
attributes.addAll(Attribute.<UserModel, User>simple("userName")
.primary()
.withSetters(UserModel::setSingleAttribute)
.modelAttributeResolver(this::createModelAttributeResolver)
.build());
attributes.addAll(Attribute.complex("emails", UserModel::setSingleAttribute, this::createModelAttributeResolver, true)
.build());
attributes.addAll(Attribute.<UserModel, User>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.<UserModel, User>simple("displayName")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("title")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("externalId")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("userType")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("nickName")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("locale")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("timezone")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("preferredLanguage")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("profileUrl")
.modelAttributeResolver(this::createModelAttributeResolver)
.withSetters(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>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.<UserModel, User>simple("meta.created")
.primary()
.timestamp()
.immutable()
.modelAttributeResolver(this::createModelAttributeResolver)
.build());
return attributes.stream().collect(Collectors.toMap(Attribute::getName, Function.identity()));
}
}
@@ -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<UserModel, User>> 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<String, Attribute<UserModel, User>> getAttributes() {
return Map.of();
public boolean isCore() {
return false;
}
private static class EnterpriseUserResourceTypeAttributeMapper implements BiConsumer<User, String> {
private final BiConsumer<EnterpriseUser, String> setter;
public EnterpriseUserResourceTypeAttributeMapper(BiConsumer<EnterpriseUser, String> 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<String, Attribute<UserModel, User>> doGetAttributes() {
return new ArrayList<>(Attribute.<UserModel, User>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()));
}
}
@@ -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<UserModel, User> {
public UserNameAttributeMapper(BiConsumer<Name, String> 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);
});
}
}
@@ -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<UserModel, User> {
@@ -105,7 +99,7 @@ public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<U
Root<UserEntity> 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<U
return exception;
}
private static class UserAttributeNameResolver implements AttributeNameResolver {
private KeycloakSession session;
private UserResourceTypeProvider provider;
public UserAttributeNameResolver(KeycloakSession session, UserResourceTypeProvider provider) {
this.session = session;
this.provider = provider;
}
@Override
public AttributeInfo resolve(String 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 = provider.splitScimAttribute(scimAttrPath);
// iterate through user profile attributes, finding one whose scim.schema.attribute annotation matches the given scimAttrPath
Attributes attributes = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, Map.of()).getAttributes();
Set<String> 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;
}
}
}
@@ -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<R extends ResourceTypeRepresentation> {
(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<R extends ResourceTypeRepresentation> {
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);
@@ -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<User> 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<User> response = client.users().search(filter);
assertThat(response, is(not(nullValue())));
@@ -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();
@@ -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");
@@ -149,66 +149,75 @@ public class JsonUtils {
*/
public static Object getJsonValue(JsonNode node, String claim) {
if (node != null) {
List<String> fields = splitClaimPath(claim);
if (fields.isEmpty() || claim.endsWith(".")) {
List<String> 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<String> 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<String> 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<Object> 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;
}
}
@@ -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<UserProfileContext, UserProfileMetadata> 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();