mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Support for PATCH operations (#46561)
Closes #46214 Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
+239
-32
@@ -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);
|
||||
}
|
||||
|
||||
+232
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -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);
|
||||
}
|
||||
+5
-2
@@ -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
|
||||
|
||||
+65
-31
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -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
|
||||
|
||||
+28
-18
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
+17
-59
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+20
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-22
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user