mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Support for groups attribute from User core schema
Closes #46215 Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
@@ -1188,6 +1188,27 @@ jobs:
|
||||
- name: Run tests
|
||||
run: ./mvnw package -f tests/pom.xml -Dtest=${{ matrix.suite }}
|
||||
|
||||
scim-integration-tests:
|
||||
name: SCIM IT
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- id: integration-test-setup
|
||||
name: Integration test setup
|
||||
uses: ./.github/actions/integration-test-setup
|
||||
|
||||
# This step is necessary because test/clustering requires building a new Keycloak image built from tar.gz
|
||||
# file that is not part of m2-keycloak.tzts archive
|
||||
- name: Build tar keycloak-quarkus-dist
|
||||
run: ./mvnw package -pl quarkus/server/,quarkus/dist/
|
||||
|
||||
- name: Run tests
|
||||
run: ./mvnw package -f scim/tests/pom.xml
|
||||
|
||||
admin-v2-tests:
|
||||
name: Admin v2
|
||||
if: needs.conditional.outputs.ci-admin-v2 == 'true'
|
||||
|
||||
+150
-117
@@ -1,5 +1,6 @@
|
||||
package org.keycloak.scim.resource.schema;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
@@ -7,25 +8,36 @@ 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 org.keycloak.models.Model;
|
||||
import org.keycloak.models.ModelException;
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
import org.keycloak.scim.resource.common.MultiValuedAttribute;
|
||||
import org.keycloak.scim.resource.schema.attribute.Attribute;
|
||||
import org.keycloak.scim.resource.schema.attribute.AttributeMapper;
|
||||
import org.keycloak.scim.resource.schema.path.Path;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.NullNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
|
||||
import static org.keycloak.scim.resource.schema.AbstractModelSchema.Operation.ADD;
|
||||
import static org.keycloak.scim.resource.schema.AbstractModelSchema.Operation.REMOVE;
|
||||
import static org.keycloak.scim.resource.schema.AbstractModelSchema.Operation.SET;
|
||||
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> {
|
||||
|
||||
enum Operation {
|
||||
SET, ADD, REMOVE
|
||||
}
|
||||
|
||||
private final String name;
|
||||
private Map<String, Attribute<M, R>> attributes;
|
||||
|
||||
@@ -70,41 +82,59 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
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);
|
||||
String path = new Path(this, rawPath).getPath();
|
||||
|
||||
for (Entry<Attribute<M, R>, JsonNode> entry : attributes.entrySet()) {
|
||||
setValue(model, entry.getKey(), entry.getValue());
|
||||
for (Entry<Attribute<M, R>, JsonNode> entry : resolveAttributes(path, value).entrySet()) {
|
||||
setValue(model, entry.getKey(), entry.getValue(), ADD);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replace(M model, String path, JsonNode value) {
|
||||
add(model, path, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(M model, String rawPath) {
|
||||
public void remove(R resource, 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);
|
||||
Path path = new Path(this, rawPath);
|
||||
JsonNode value = NullNode.getInstance();
|
||||
|
||||
for (Attribute<M, R> attribute : resolveAttributes(path, null).keySet()) {
|
||||
setValue(model, attribute, NullNode.getInstance());
|
||||
if (path.hasFilter()) {
|
||||
try {
|
||||
value = JsonSerialization.createObjectNode(resource);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
for (Entry<Attribute<M, R>, JsonNode> entry : resolveAttributes(path.getPath(), value).entrySet()) {
|
||||
JsonNode attrValue = path.getValue(entry.getKey(), entry.getValue());
|
||||
setValue(model, entry.getKey(), attrValue, REMOVE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Attribute<M, R> getAttributeByPath(String path) {
|
||||
Map<Attribute<M, R>, JsonNode> attributes = resolveAttributes(path, NullNode.getInstance());
|
||||
|
||||
if (attributes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attributes.size() == 1) {
|
||||
return attributes.keySet().iterator().next();
|
||||
}
|
||||
|
||||
throw new ModelValidationException("Multiple attributes found for path " + path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the names of the attributes from the given {@code model}.
|
||||
*
|
||||
* @param model the model to get the attribute names from
|
||||
* @return the names of the attributes defined in the model
|
||||
*/
|
||||
protected abstract Set<String> getAttributeNames(M model);
|
||||
protected abstract Set<String> getModelAttributeNames();
|
||||
|
||||
/**
|
||||
* Returns the value of the attribute with the given {@code name} from the given {@code model}.
|
||||
@@ -113,16 +143,15 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
* @param name the name of the attribute to get the value from
|
||||
* @return the value of the attribute with the given name from the model
|
||||
*/
|
||||
protected abstract String getAttributeValue(M model, String name);
|
||||
protected abstract Object getAttributeValue(M model, String name);
|
||||
|
||||
/**
|
||||
* Returns the name of the attribute in the schema for the attribute with the given {@code name} from the given {@code model}.
|
||||
*
|
||||
* @param model the model to get the attribute schema name from
|
||||
* @param name the name of the attribute to get the schema name from
|
||||
* @return the name of the attribute in the schema for the attribute with the given name from the model
|
||||
*/
|
||||
protected abstract String getAttributeSchemaName(M model, String name);
|
||||
protected abstract String getAttributeSchemaName(String name);
|
||||
|
||||
private void populateModel(M model, R resource) {
|
||||
ObjectNode objectNode;
|
||||
@@ -133,16 +162,10 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
throw new RuntimeException("Failed to convert representation to JSON", e);
|
||||
}
|
||||
|
||||
for (String name : getAttributeNames(model)) {
|
||||
Attribute<M, R> attribute = getAttributeMapper(model, name);
|
||||
for (String name : getModelAttributeNames()) {
|
||||
Attribute<M, R> attribute = getAttributeMapperByModelAttribute(name);
|
||||
|
||||
if (attribute == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AttributeMapper<M, R> mapper = attribute.getMapper();
|
||||
|
||||
if (mapper == null) {
|
||||
if (attribute == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -167,42 +190,50 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
|
||||
if (value != null) {
|
||||
if (value instanceof Collection<?> values) {
|
||||
for (Object v : values) {
|
||||
if (v instanceof JsonNode jsonNode) {
|
||||
setValue(model, attribute, resolveAttributeValue(attribute.getName(), jsonNode));
|
||||
if (attribute.isMultivalued()) {
|
||||
ArrayNode nodes = JsonNodeFactory.instance.arrayNode();
|
||||
|
||||
for (Object v : values) {
|
||||
if (v instanceof JsonNode jsonNode) {
|
||||
nodes.add(jsonNode);
|
||||
} else {
|
||||
nodes.add(TextNode.valueOf(v.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
setValue(model, attribute, nodes);
|
||||
} else {
|
||||
for (Object v : values) {
|
||||
if (v instanceof JsonNode jsonNode) {
|
||||
setValue(model, attribute, resolveAttributeValue(attribute, jsonNode));
|
||||
}
|
||||
// no support for multivalued attributes for now, so we take the first value as the value of the attribute
|
||||
break;
|
||||
}
|
||||
// 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());
|
||||
attribute.set(model, TextNode.valueOf(value.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void populateResourceType(R resource, M model) {
|
||||
for (String name : getAttributeNames(model)) {
|
||||
Attribute<M, R> attribute = getAttributeMapper(model, name);
|
||||
for (String name : getModelAttributeNames()) {
|
||||
Attribute<M, R> attribute = getAttributeMapperByModelAttribute(name);
|
||||
|
||||
if (attribute == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AttributeMapper<M, R> mapper = attribute.getMapper();
|
||||
|
||||
if (mapper == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String value = getAttributeValue(model, name);
|
||||
mapper.setValue(resource, value);
|
||||
Object value = getAttributeValue(model, name);
|
||||
attribute.set(resource, value);
|
||||
resource.addSchema(this.name);
|
||||
}
|
||||
}
|
||||
|
||||
private Attribute<M, R> getAttributeMapper(M model, String name) {
|
||||
String scimName = getAttributeSchemaName(model, name);
|
||||
private Attribute<M, R> getAttributeMapperByModelAttribute(String name) {
|
||||
String scimName = getAttributeSchemaName(name);
|
||||
|
||||
if (scimName == null) {
|
||||
return null;
|
||||
@@ -237,15 +268,6 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
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);
|
||||
@@ -256,7 +278,7 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
List<String> paths = getPaths(attr);
|
||||
|
||||
if (paths.contains(path)) {
|
||||
return Map.of(attr, resolveAttributeValue(attr.getName(), valueJson));
|
||||
return Map.of(attr, resolveAttributeValue(attr, valueJson));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,10 +287,13 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
Attribute<M, R> attr = getAttributes().get(path + "." + property.getKey());
|
||||
|
||||
if (attr != null) {
|
||||
attributes.put(attr, resolveAttributeValue(attr.getName(), property.getValue()));
|
||||
} else if (isCore()) {
|
||||
// found sub-attribute withing the path
|
||||
attributes.put(attr, property.getValue());
|
||||
} else if (isCore() && getName().equals(path)) {
|
||||
// if core schema, resolve all its attributes based on the properties of the value JSON node
|
||||
attributes.putAll(resolveAttributes(property.getKey(), property.getValue()));
|
||||
} else {
|
||||
// fallback to resolve the attribute from an extension schema
|
||||
String name = property.getKey();
|
||||
|
||||
if (!name.startsWith(getName())) {
|
||||
@@ -281,58 +306,95 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
}
|
||||
} 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()));
|
||||
if (valueJson.has(path)) {
|
||||
return resolveAttributes(path, valueJson.get(path));
|
||||
}
|
||||
|
||||
Class<?> complexType = attribute.getComplexType();
|
||||
|
||||
if (complexType != null && attribute.isMultivalued()) {
|
||||
attributes.put(attribute, valueJson);
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
// path is an attribute, value must be the value of the attribute
|
||||
return Map.of(attribute, resolveAttributeValue(attribute.getName(), valueJson));
|
||||
return Map.of(attribute, resolveAttributeValue(attribute, valueJson));
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private String formatPath(String path) {
|
||||
int filterStartIdx = path.indexOf("[");
|
||||
protected List<String> getPaths(Attribute<M, R> attr) {
|
||||
List<String> paths = new ArrayList<>();
|
||||
|
||||
if (filterStartIdx > 0) {
|
||||
int filterEndIdx = path.lastIndexOf("]");
|
||||
// the name of the attribute itself is always a valid path
|
||||
paths.add(attr.getName());
|
||||
|
||||
if (filterEndIdx == -1) {
|
||||
throw new RuntimeException("Invalid path: " + path);
|
||||
if (!isCore()) {
|
||||
// if processing an extension schema try to resolve the attribute based on parent name and alias as well
|
||||
String parent = attr.getParentName();
|
||||
|
||||
if (parent != null) {
|
||||
paths.add(getName() + attr.getName().replace(parent + ".", ":"));
|
||||
paths.add(getName() + attr.getName().replace(parent, ""));
|
||||
}
|
||||
|
||||
// for now, we do not support filters in the path
|
||||
return new StringBuilder(path).delete(filterStartIdx, filterEndIdx + 1).toString();
|
||||
if (attr.getAlias() != null) {
|
||||
paths.add(getName() + ":" + attr.getAlias());
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
Class<?> complexType = attr.getComplexType();
|
||||
|
||||
if (complexType != null) {
|
||||
if (MultiValuedAttribute.class.isAssignableFrom(complexType)) {
|
||||
paths.add(attr.getName() + ".value");
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
public void setValue(M model, Attribute<M, R> resolved, JsonNode value) {
|
||||
for (String name : getAttributeNames(model)) {
|
||||
Attribute<M, R> attribute = getAttributeMapper(model, name);
|
||||
setValue(model, resolved, value, SET);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
private void setValue(M model, Attribute<M, R> attribute, JsonNode value, Operation operation) {
|
||||
Objects.requireNonNull(model, "model cannot be null");
|
||||
Objects.requireNonNull(attribute, "attribute cannot be null");
|
||||
Objects.requireNonNull(value, "value cannot be null");
|
||||
Objects.requireNonNull(operation, "operation cannot be null");
|
||||
|
||||
AttributeMapper<M, R> mapper = resolved.getMapper();
|
||||
mapper.setValue(model, name, value.isNull() ? null : value.asText());
|
||||
switch (operation) {
|
||||
case SET -> attribute.set(model, value);
|
||||
case ADD -> attribute.add(model, value);
|
||||
case REMOVE -> attribute.remove(model, value);
|
||||
default -> throw new ModelException("Invalid operation: " + operation);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode resolveAttributeValue(String name, JsonNode jsonNode) {
|
||||
private JsonNode resolveAttributeValue(Attribute<M, R> attribute,JsonNode jsonNode) {
|
||||
if (jsonNode.isValueNode()) {
|
||||
Class<?> complexType = attribute.getComplexType();
|
||||
|
||||
if (complexType != null) {
|
||||
if (MultiValuedAttribute.class.isAssignableFrom(complexType)) {
|
||||
ObjectNode objectNode = JsonSerialization.createObjectNode();
|
||||
objectNode.set("value", jsonNode);
|
||||
return objectNode;
|
||||
}
|
||||
|
||||
throw new ModelValidationException("Unsupported complex type for attribute: " + attribute.getName());
|
||||
}
|
||||
|
||||
// return fast if a value node
|
||||
return jsonNode;
|
||||
}
|
||||
|
||||
String name = attribute.getName();
|
||||
|
||||
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
|
||||
@@ -341,46 +403,17 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
// 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());
|
||||
return resolveAttributeValue(attribute, 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));
|
||||
if (attribute.isMultivalued()) {
|
||||
return jsonNode;
|
||||
}
|
||||
// single valued attribute, we take the first value of the array as the value of the attribute
|
||||
return resolveAttributeValue(attribute, 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();
|
||||
}
|
||||
|
||||
public List<String> getPaths(Attribute<M, R> attr) {
|
||||
List<String> paths = new ArrayList<>();
|
||||
|
||||
String parent = attr.getParentName();
|
||||
|
||||
if (parent != null && !isCore()) {
|
||||
paths.add(getName() + attr.getName().replace(parent + ".", ":"));
|
||||
}
|
||||
|
||||
if (attr.getAlias() != null) {
|
||||
if(!isCore()) {
|
||||
paths.add(getName() + ":" + attr.getAlias());
|
||||
}
|
||||
paths.add(attr.getParentName());
|
||||
}
|
||||
|
||||
paths.add(attr.getName());
|
||||
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,29 +68,46 @@ public interface ModelSchema<M extends Model, R extends ResourceTypeRepresentati
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
* Performs a PATCH {@code remove} operation on the given {@code model} for the attribute defined by the given {@code path}.
|
||||
*
|
||||
* @param resource the resource to perform the operation on
|
||||
* @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
|
||||
* @param path the path of the attribute to perform the operation on
|
||||
*/
|
||||
default void replace(M model, String path, JsonNode value) {
|
||||
default void remove(R resource, M model, String path) {
|
||||
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}.
|
||||
* Performs a PATCH {@code replace} operation on the given {@code model} for the attribute defined by the given {@code path}.
|
||||
*
|
||||
* @param resource the resource to perform the operation on
|
||||
* @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 void replace(R resource, M model, String path, JsonNode value) {
|
||||
if (path != null) {
|
||||
remove(resource, model, path);
|
||||
}
|
||||
add(model, path, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if this schema is a core schema.
|
||||
*
|
||||
* @return {@code true} if this schema is a core schema, {@code false} otherwise
|
||||
*/
|
||||
default boolean isCore() {
|
||||
return true;
|
||||
}
|
||||
|
||||
Attribute<M, R> resolveAttribute(String name);
|
||||
/**
|
||||
* Returns an {@link Attribute} defined by this schema for the given {@code path}.
|
||||
* The path can be {@code null} if the attribute is the whole resource.
|
||||
* It can also be a dot-separated path to a sub-attribute, e.g. "name.familyName".
|
||||
*
|
||||
* @param path the path
|
||||
* @return the attribute for the given path, or {@code null} if no attribute is defined for the given path
|
||||
*/
|
||||
Attribute<M, R> getAttributeByPath(String path);
|
||||
}
|
||||
|
||||
+89
-61
@@ -3,16 +3,17 @@ package org.keycloak.scim.resource.schema.attribute;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
|
||||
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;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -22,10 +23,12 @@ import org.keycloak.scim.resource.schema.ModelSchema;
|
||||
public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
|
||||
private final String alias;
|
||||
private BiFunction<KeycloakSession, Attribute<M, R>, String> modelAttributeResolver;
|
||||
private Function<Attribute<M, R>, String> modelAttributeResolver;
|
||||
private boolean primary;
|
||||
private String type;
|
||||
private String mutability;
|
||||
private boolean multivalued;
|
||||
private Class<?> complexType;
|
||||
|
||||
/**
|
||||
* Creates a simple attribute with the given {@code name}.
|
||||
@@ -50,25 +53,9 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
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;
|
||||
private final String parentName;
|
||||
|
||||
private Attribute(String name, AttributeMapper<M, R> mapper, String parentName) {
|
||||
this(name, mapper, parentName, null);
|
||||
@@ -79,6 +66,7 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
this.mapper = mapper;
|
||||
this.parentName = parentName;
|
||||
this.alias = alias;
|
||||
this.mapper.setAttribute(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,27 +82,6 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
return alias;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapper that is used to map the attribute from a {@link ResourceTypeRepresentation} to a {@link Model} and vice versa.
|
||||
*
|
||||
* @return the mapper
|
||||
*/
|
||||
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}.
|
||||
*
|
||||
@@ -127,17 +94,16 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
/**
|
||||
* 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) {
|
||||
public String getModelAttributeName() {
|
||||
if (modelAttributeResolver != null) {
|
||||
return modelAttributeResolver.apply(session, this);
|
||||
return modelAttributeResolver.apply(this);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void setModelAttributeResolver(BiFunction<KeycloakSession, Attribute<M, R>, String> resolver) {
|
||||
private void setModelAttributeResolver(Function<Attribute<M, R>, String> resolver) {
|
||||
this.modelAttributeResolver = resolver;
|
||||
}
|
||||
|
||||
@@ -169,17 +135,63 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
return Objects.equals(mutability, "immutable");
|
||||
}
|
||||
|
||||
private void setMultivalued(boolean multivalued) {
|
||||
this.multivalued = multivalued;
|
||||
}
|
||||
|
||||
public boolean isMultivalued() {
|
||||
return multivalued;
|
||||
}
|
||||
|
||||
private void setComplexType(Class<?> complexType) {
|
||||
this.complexType = complexType;
|
||||
}
|
||||
|
||||
public Class<?> getComplexType() {
|
||||
return complexType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof Attribute<?, ?> attribute)) return false;
|
||||
return Objects.equals(name, attribute.name) && Objects.equals(parentName, attribute.parentName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, parentName);
|
||||
}
|
||||
|
||||
public void set(M model, JsonNode value) {
|
||||
mapper.setValue(model, value);
|
||||
}
|
||||
|
||||
public void set(R resource, Object value) {
|
||||
mapper.setValue(resource, value);
|
||||
}
|
||||
|
||||
public void add(M model, JsonNode value) {
|
||||
mapper.addValue(model, value);
|
||||
}
|
||||
|
||||
public void remove(M model, JsonNode value) {
|
||||
mapper.removeValue(model, value);
|
||||
}
|
||||
|
||||
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;
|
||||
private TriConsumer<M, String, ?> modelSetter;
|
||||
private BiConsumer<R, ?> representationSetter;
|
||||
List<Attribute<M, R>> attributes = new ArrayList<>();
|
||||
private BiFunction<KeycloakSession, Attribute<M, R>, String> modelAttributeResolver;
|
||||
private Function<Attribute<M, R>, String> modelAttributeResolver;
|
||||
private boolean primary;
|
||||
private String type;
|
||||
private String mutability;
|
||||
private boolean multivalued;
|
||||
private TriConsumer<M, String, Set<?>> modelRemover;
|
||||
private TriConsumer<M, String, Set<?>> modelAdder;
|
||||
|
||||
private Builder(String name, Class<?> complexType) {
|
||||
Objects.requireNonNull(name, "name cannot be null");
|
||||
@@ -187,20 +199,20 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public <C extends BiConsumer<R, String>> Builder<M, R> withSetters(TriConsumer<M, String, String> modelSetter, C representationSetter) {
|
||||
public <C extends BiConsumer<R, ?>> Builder<M, R> withModelSetter(TriConsumer<M, String, ?> modelSetter, C representationSetter) {
|
||||
this.modelSetter = modelSetter;
|
||||
this.representationSetter = representationSetter;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder<M, R> withSetters(TriConsumer<M, String, String> modelSetter) {
|
||||
public Builder<M, R> withModelSetter(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);
|
||||
public Builder<M, R> withModelSetter(BiConsumer<M, String> modelSetter) {
|
||||
this.modelSetter = (model, name, value) -> modelSetter.accept(model, (String) value);
|
||||
this.representationSetter = new ComplexAttributeSetter<>(name, complexType);
|
||||
return this;
|
||||
}
|
||||
@@ -226,17 +238,13 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder<M, R> modelAttributeResolver(BiFunction<KeycloakSession, Attribute<M, R>, String> resolver) {
|
||||
public Builder<M, R> modelAttributeResolver(Function<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;
|
||||
this.primary = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -256,13 +264,33 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
}
|
||||
|
||||
public List<Attribute<M, R>> build() {
|
||||
Attribute<M, R> attribute = new Attribute<>(name, new AttributeMapper<>(modelSetter, representationSetter), this.name);
|
||||
Attribute<M, R> attribute = new Attribute<>(name, new AttributeMapper<>(modelSetter, representationSetter, modelRemover, modelAdder), this.name);
|
||||
attribute.setModelAttributeResolver(modelAttributeResolver);
|
||||
attribute.setPrimary(primary);
|
||||
attribute.setType(type);
|
||||
attribute.setMutability(mutability);
|
||||
attributes.add(attribute);
|
||||
attribute.setMultivalued(multivalued);
|
||||
attribute.setComplexType(complexType);
|
||||
if (attributes.isEmpty()) {
|
||||
// do not add the root attribute if there are subattributes
|
||||
attributes.add(attribute);
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
public Builder<M, R> multivalued() {
|
||||
this.multivalued = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public <C> Builder<M, R> withModelRemover(TriConsumer<M, String, Set<C>> remover) {
|
||||
this.modelRemover = (m, s, objects) -> remover.accept(m, s, (Set<C>) objects);
|
||||
return this;
|
||||
}
|
||||
|
||||
public <C> Builder<M, R> withModelAdder(TriConsumer<M, String, Set<C>> adder) {
|
||||
this.modelAdder = (m, s, objects) -> adder.accept(m, s, (Set<C>) objects);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+100
-18
@@ -1,20 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.scim.resource.schema.attribute;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.common.util.TriConsumer;
|
||||
import org.keycloak.models.Model;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* <p>An attribute mapper defines how to set an attribute to a {@link Model} and its corresponding {@link ResourceTypeRepresentation}.
|
||||
@@ -23,23 +20,108 @@ import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
*/
|
||||
public class AttributeMapper<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
|
||||
private final TriConsumer<M, String, String> modelSetter;
|
||||
private final BiConsumer<R, String> representationSetter;
|
||||
private Attribute<M, R> attribute;
|
||||
private final TriConsumer<M, String, ?> modelSetter;
|
||||
private TriConsumer<M, String, ?> modelRemover;
|
||||
private TriConsumer<M, String, ?> modelAdder;
|
||||
private final BiConsumer<R, ?> representationSetter;
|
||||
|
||||
public AttributeMapper(TriConsumer<M, String, String> modelSetter, BiConsumer<R, String> representationSetter) {
|
||||
AttributeMapper(TriConsumer<M, String, ?> modelSetter, BiConsumer<R, ?> representationSetter) {
|
||||
this(modelSetter, representationSetter, null, null);
|
||||
}
|
||||
|
||||
AttributeMapper(TriConsumer<M, String, ?> modelSetter, BiConsumer<R, ?> representationSetter, TriConsumer<M, String, ?> modelRemover, TriConsumer<M, String, ?> modelAdder) {
|
||||
this.modelSetter = modelSetter;
|
||||
this.representationSetter = representationSetter;
|
||||
this.modelRemover = modelRemover;
|
||||
this.modelAdder = modelAdder;
|
||||
}
|
||||
|
||||
public void setValue(R representation, String value) {
|
||||
public void setValue(R representation, Object value) {
|
||||
if (representationSetter != null) {
|
||||
representationSetter.accept(representation, value);
|
||||
((BiConsumer<R, Object>) representationSetter).accept(representation, value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setValue(M model, String name, String value) {
|
||||
if (modelSetter != null) {
|
||||
modelSetter.accept(model, name, value);
|
||||
public void setValue(M model, JsonNode value) {
|
||||
setValue(model, value, (TriConsumer<M, String, Object>) modelSetter);
|
||||
}
|
||||
|
||||
public void addValue(M model, JsonNode value) {
|
||||
if (modelAdder == null) {
|
||||
setValue(model, value);
|
||||
} else {
|
||||
setValue(model, value, (TriConsumer<M, String, Object>) modelAdder);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeValue(M model, JsonNode value) {
|
||||
if (modelRemover == null) {
|
||||
setValue(model, null);
|
||||
} else {
|
||||
setValue(model, value, (TriConsumer<M, String, Object>) modelRemover);
|
||||
}
|
||||
}
|
||||
|
||||
private void setValue(M model, JsonNode value, TriConsumer<M, String, Object> modelSetter) {
|
||||
if (modelSetter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String name = attribute.getModelAttributeName();
|
||||
|
||||
if (name == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attribute != null && attribute.isMultivalued()) {
|
||||
Class<?> complexType = attribute.getComplexType();
|
||||
|
||||
if (complexType == null) {
|
||||
Set<String> values;
|
||||
|
||||
if (value.isArray()) {
|
||||
values = value.valueStream().map(JsonNode::asText).collect(Collectors.toSet());
|
||||
} else {
|
||||
values = Set.of(value.asText());
|
||||
}
|
||||
|
||||
modelSetter.accept(model, name, values);
|
||||
} else if (value != null) {
|
||||
Set<Object> values = new HashSet<>();
|
||||
|
||||
if (value.isArray()) {
|
||||
for (JsonNode v : value) {
|
||||
if (v.isValueNode()) {
|
||||
values.add(v.textValue());
|
||||
} else {
|
||||
try {
|
||||
values.add(JsonSerialization.readValue(v.toString(), complexType));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (value.isTextual()) {
|
||||
values.add(value.textValue());
|
||||
} else if (!value.isNull()) {
|
||||
try {
|
||||
values.add(JsonSerialization.readValue(value.toString(), complexType));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
modelSetter.accept(model, name, values);
|
||||
}
|
||||
} else if (value != null && !value.isNull()) {
|
||||
modelSetter.accept(model, name, value.asText());
|
||||
} else {
|
||||
modelSetter.accept(model, name, null);
|
||||
}
|
||||
}
|
||||
|
||||
void setAttribute(Attribute<M, R> attribute) {
|
||||
this.attribute = attribute;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.keycloak.scim.resource.schema.path;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.keycloak.scim.resource.common.MultiValuedAttribute;
|
||||
import org.keycloak.scim.resource.schema.attribute.Attribute;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.NullNode;
|
||||
|
||||
record EqualExpression(Attribute<?, ?> attribute, String attributeName,
|
||||
String value) implements Function<JsonNode, JsonNode> {
|
||||
|
||||
@Override
|
||||
public JsonNode apply(JsonNode rawValue) {
|
||||
Class<?> complexType = attribute.getComplexType();
|
||||
|
||||
if (complexType != null) {
|
||||
if (MultiValuedAttribute.class.isAssignableFrom(complexType)) {
|
||||
if (rawValue.isArray()) {
|
||||
for (JsonNode node : rawValue) {
|
||||
if (node.isObject()) {
|
||||
if ("value".equals(attributeName)) {
|
||||
JsonNode value = node.get(attributeName);
|
||||
|
||||
if (value != null && value.asText().equals(this.value.replaceAll("\"", ""))) {
|
||||
return node;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NullNode.getInstance();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.keycloak.scim.resource.schema.path;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.scim.resource.ResourceTypeRepresentation;
|
||||
import org.keycloak.scim.resource.schema.ModelSchema;
|
||||
import org.keycloak.scim.resource.schema.attribute.Attribute;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
public final class Path {
|
||||
|
||||
private final String path;
|
||||
private final String filter;
|
||||
|
||||
public <R extends ResourceTypeRepresentation> Path(ModelSchema<?, ?> schema, String rawPath) {
|
||||
if (rawPath == null) {
|
||||
this.path = schema.getName();
|
||||
this.filter = null;
|
||||
} else {
|
||||
int filterStartIdx = rawPath.indexOf("[");
|
||||
|
||||
if (filterStartIdx > 0) {
|
||||
int filterEndIdx = rawPath.lastIndexOf("]");
|
||||
|
||||
if (filterEndIdx == -1) {
|
||||
throw new RuntimeException("Invalid path: " + rawPath);
|
||||
}
|
||||
|
||||
// expects the attribute to filter in the beginning
|
||||
String path = rawPath.substring(0, filterStartIdx);
|
||||
|
||||
if (rawPath.indexOf('.', filterEndIdx) != -1) {
|
||||
// append any sub-attribute after the filter, e.g. "emails[type eq "work"].value"
|
||||
path = path + rawPath.substring(filterEndIdx + 1);
|
||||
}
|
||||
|
||||
this.path = path;
|
||||
this.filter = rawPath.substring(filterStartIdx + 1, filterEndIdx);
|
||||
} else {
|
||||
this.path = rawPath;
|
||||
this.filter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public JsonNode getValue(Attribute<?, ?> attribute, JsonNode rawValue) {
|
||||
if (filter == null) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
return parseFilter(attribute).apply(rawValue);
|
||||
}
|
||||
|
||||
private Function<JsonNode, JsonNode> parseFilter(Attribute<?, ?> attribute) {
|
||||
String[] parts = filter.trim().split(" ");
|
||||
|
||||
if (parts.length == 3) {
|
||||
String leftOperand = parts[0];
|
||||
String operator = parts[1];
|
||||
String rightOperand = parts[2];
|
||||
|
||||
if ("eq".equals(operator)) {
|
||||
return new EqualExpression(attribute, leftOperand, rightOperand);
|
||||
}
|
||||
|
||||
// for now, we only support equality filter in the path, and we assume the filter is always in the format "attribute eq "value""
|
||||
throw new ModelValidationException("Unsupported filter operator: " + operator);
|
||||
}
|
||||
|
||||
throw new ModelValidationException("Unsupported filter format: " + filter);
|
||||
}
|
||||
|
||||
public boolean hasFilter() {
|
||||
return filter != null;
|
||||
}
|
||||
|
||||
}
|
||||
+2
-15
@@ -139,8 +139,8 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
|
||||
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);
|
||||
case "replace" -> schema.replace(existing, model, path, value);
|
||||
case "remove" -> schema.remove(existing, model, path);
|
||||
default -> throw new RuntimeException("Unsupported patch operation " + op);
|
||||
}
|
||||
}
|
||||
@@ -181,19 +181,6 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
|
||||
}
|
||||
}
|
||||
|
||||
protected String[] splitScimAttribute(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 schemaName;
|
||||
int lastColon = scimAttrPath.lastIndexOf(':');
|
||||
if (lastColon > 0 && (scimAttrPath.contains("://") || scimAttrPath.startsWith("urn:"))) {
|
||||
schemaName = scimAttrPath.substring(0, lastColon);
|
||||
scimAttrPath = scimAttrPath.substring(lastColon + 1);
|
||||
} else {
|
||||
schemaName = this.schema.getName();
|
||||
}
|
||||
return new String[] {schemaName, scimAttrPath};
|
||||
}
|
||||
|
||||
private R createResourceTypeInstance() {
|
||||
try {
|
||||
return getResourceType().getDeclaredConstructor().newInstance();
|
||||
|
||||
@@ -1,52 +1,10 @@
|
||||
package org.keycloak.scim.resource.user;
|
||||
|
||||
import org.keycloak.scim.resource.common.MultiValuedAttribute;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class GroupMembership {
|
||||
public class GroupMembership extends MultiValuedAttribute {
|
||||
|
||||
@JsonProperty("value")
|
||||
private String value;
|
||||
|
||||
@JsonProperty("$ref")
|
||||
private String ref;
|
||||
|
||||
@JsonProperty("display")
|
||||
private String display;
|
||||
|
||||
@JsonProperty("type")
|
||||
private String type;
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void setValue(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getRef() {
|
||||
return ref;
|
||||
}
|
||||
|
||||
public void setRef(String ref) {
|
||||
this.ref = ref;
|
||||
}
|
||||
|
||||
public String getDisplay() {
|
||||
return display;
|
||||
}
|
||||
|
||||
public void setDisplay(String display) {
|
||||
this.display = display;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.keycloak.scim.resource.user;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -305,4 +306,19 @@ public class User extends ResourceTypeRepresentation {
|
||||
}
|
||||
return schemas;
|
||||
}
|
||||
|
||||
public void addGroup(String id) {
|
||||
GroupMembership membership = new GroupMembership();
|
||||
|
||||
membership.setValue(id);
|
||||
|
||||
addGroup(membership);
|
||||
}
|
||||
|
||||
public void addGroup(GroupMembership membership) {
|
||||
if (groups == null) {
|
||||
groups = new ArrayList<>();
|
||||
}
|
||||
groups.add(membership);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -5,7 +5,6 @@ import java.util.List;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.scim.filter.ScimFilterParser;
|
||||
import org.keycloak.scim.filter.ScimFilterParserBaseVisitor;
|
||||
|
||||
@@ -20,9 +19,9 @@ public class ScimJPAPredicateEvaluator extends ScimFilterParserBaseVisitor<JPAFi
|
||||
private final ScimJPAPredicateProvider predicateProvider;
|
||||
|
||||
@SuppressWarnings("unchecked,rawtypes")
|
||||
public ScimJPAPredicateEvaluator(KeycloakSession session, List schemas, CriteriaBuilder cb, Root<?> root) {
|
||||
public ScimJPAPredicateEvaluator(List schemas, CriteriaBuilder cb, Root<?> root) {
|
||||
this.cb = cb;
|
||||
this.predicateProvider = new ScimJPAPredicateProvider(session, schemas, cb, root);
|
||||
this.predicateProvider = new ScimJPAPredicateProvider(schemas, cb, root);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+5
-7
@@ -27,7 +27,6 @@ import org.keycloak.utils.KeycloakSessionUtil;
|
||||
*/
|
||||
public class ScimJPAPredicateProvider {
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final List<ModelSchema<?, ?>> schemas;
|
||||
private final CriteriaBuilder cb;
|
||||
private final Root<?> root;
|
||||
@@ -48,8 +47,7 @@ public class ScimJPAPredicateProvider {
|
||||
// cache joins to avoid creating duplicate joins for the same filter
|
||||
private Join<?, ?> attributeJoin;
|
||||
|
||||
public ScimJPAPredicateProvider(KeycloakSession session, List<ModelSchema<?, ?>> schemas, CriteriaBuilder cb, Root<?> root) {
|
||||
this.session = session;
|
||||
public ScimJPAPredicateProvider(List<ModelSchema<?, ?>> schemas, CriteriaBuilder cb, Root<?> root) {
|
||||
this.schemas = schemas;
|
||||
this.cb = cb;
|
||||
this.root = root;
|
||||
@@ -69,7 +67,7 @@ public class ScimJPAPredicateProvider {
|
||||
return JPAFilterResult.unsupported(cb.disjunction());
|
||||
}
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
String modelAttributeName = attrInfo.getModelAttributeName(session);
|
||||
String modelAttributeName = attrInfo.getModelAttributeName();
|
||||
if (attrInfo.isPrimary()) {
|
||||
// direct field: check not null
|
||||
return JPAFilterResult.valid(cb.isNotNull(root.get(modelAttributeName)));
|
||||
@@ -139,7 +137,7 @@ public class ScimJPAPredicateProvider {
|
||||
private Predicate getAttributePredicate(Attribute<?,?> attrInfo, String operation, Object value) {
|
||||
Expression<?> path;
|
||||
Predicate basePredicate = null;
|
||||
String modelAttributeName = attrInfo.getModelAttributeName(session);
|
||||
String modelAttributeName = attrInfo.getModelAttributeName();
|
||||
|
||||
if (attrInfo.isPrimary()) {
|
||||
path = root.get(modelAttributeName);
|
||||
@@ -263,13 +261,13 @@ public class ScimJPAPredicateProvider {
|
||||
Attribute<?, ?> metadata = null;
|
||||
|
||||
for (ModelSchema<?, ?> schema : schemas) {
|
||||
metadata = schema.resolveAttribute(path);
|
||||
metadata = schema.getAttributeByPath(path);
|
||||
if (metadata != null ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (metadata != null) {
|
||||
String modelAttributeName = metadata.getModelAttributeName(session);
|
||||
String modelAttributeName = metadata.getModelAttributeName();
|
||||
if (modelAttributeName != null) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Set<String> getAttributeNames(GroupModel model) {
|
||||
protected Set<String> getModelAttributeNames() {
|
||||
return Set.of("name");
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getAttributeSchemaName(GroupModel model, String name) {
|
||||
protected String getAttributeSchemaName(String name) {
|
||||
if (name.equals("name")) {
|
||||
return "displayName";
|
||||
}
|
||||
@@ -47,13 +47,13 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
|
||||
protected Map<String, Attribute<GroupModel, Group>> doGetAttributes() {
|
||||
return new ArrayList<>((Attribute.<GroupModel, Group>simple("displayName")
|
||||
.primary()
|
||||
.modelAttributeResolver((session, attribute) -> {
|
||||
.modelAttributeResolver((attribute) -> {
|
||||
if (attribute.getName().equals("displayName")) {
|
||||
return "name";
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.withSetters(GroupModel::setName)
|
||||
.withModelSetter(GroupModel::setName)
|
||||
.build())).stream().collect(Collectors.toMap(Attribute::getName, Function.identity()));
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -84,7 +84,7 @@ public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<GroupEntity> query = cb.createQuery(GroupEntity.class);
|
||||
Root<GroupEntity> root = query.from(GroupEntity.class);
|
||||
List<Predicate> predicates = getGroupPredicates(filterContext, cb, root);
|
||||
List<Predicate> predicates = getGroupPredicates(filterContext, cb, query, root);
|
||||
|
||||
// apply distinct and order by name to ensure consistency with no-filter case
|
||||
query.where(predicates).distinct(true).orderBy(cb.asc(root.get("name")));
|
||||
@@ -110,7 +110,7 @@ public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<
|
||||
CriteriaQuery<Long> query = cb.createQuery(Long.class);
|
||||
Root<GroupEntity> root = query.from(GroupEntity.class);
|
||||
|
||||
List<Predicate> predicates = this.getGroupPredicates(filterContext, cb, root);
|
||||
List<Predicate> predicates = this.getGroupPredicates(filterContext, cb, query, root);
|
||||
query.select(cb.countDistinct(root)).where(predicates);
|
||||
return em.createQuery(query).getSingleResult();
|
||||
} else {
|
||||
@@ -133,17 +133,22 @@ public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<
|
||||
public void close() {
|
||||
}
|
||||
|
||||
private List<Predicate> getGroupPredicates(ScimFilterParser.FilterContext filterContext, CriteriaBuilder cb, Root<GroupEntity> root) {
|
||||
private List<Predicate> getGroupPredicates(ScimFilterParser.FilterContext filterContext, CriteriaBuilder cb, CriteriaQuery<?> query, Root<GroupEntity> root) {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// create filter predicate using the same query and root that will be used for execution
|
||||
ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(session, getSchemas(), cb, root);
|
||||
ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(getSchemas(), cb, root);
|
||||
predicates.add(evaluator.visit(filterContext).predicate());
|
||||
|
||||
// apply realm restriction and group type restrictions
|
||||
predicates.add(cb.equal(root.get("realm"), session.getContext().getRealm().getId()));
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
|
||||
predicates.add(cb.equal(root.get("realm"), realm.getId()));
|
||||
predicates.add(cb.equal(root.get("type"), GroupModel.Type.REALM.intValue()));
|
||||
predicates.add(cb.equal(root.get("parentId"), GroupEntity.TOP_PARENT_ID));
|
||||
|
||||
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.GROUPS, realm, cb, query, root));
|
||||
|
||||
return predicates;
|
||||
}
|
||||
}
|
||||
|
||||
+31
-27
@@ -1,6 +1,7 @@
|
||||
package org.keycloak.scim.model.user;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -28,15 +29,22 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema<UserMo
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Set<String> getAttributeNames(UserModel model) {
|
||||
Set<String> names = new HashSet<>(getAttributes(model).nameSet());
|
||||
protected Set<String> getModelAttributeNames() {
|
||||
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, Map.of());
|
||||
Attributes attributes = profile.getAttributes();
|
||||
Set<String> names = new HashSet<>(attributes.nameSet());
|
||||
names.add(UserModel.ENABLED);
|
||||
names.add("groups");
|
||||
return names;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getAttributeSchemaName(UserModel model, String name) {
|
||||
Object schema = getAttributeAnnotations(model, name).get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
|
||||
protected String getAttributeSchemaName(String name) {
|
||||
if ("groups".equals(name)) {
|
||||
return name;
|
||||
}
|
||||
|
||||
Object schema = getAttributeAnnotations(name).get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
|
||||
|
||||
if (schema == null) {
|
||||
return null;
|
||||
@@ -46,15 +54,23 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema<UserMo
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getAttributeValue(UserModel model, String name) {
|
||||
protected Object getAttributeValue(UserModel model, String name) {
|
||||
if (UserModel.ENABLED.equals(name)) {
|
||||
return String.valueOf(model.isEnabled());
|
||||
}
|
||||
return getAttributes(model).getFirst(name);
|
||||
if ("groups".equals(name)) {
|
||||
return model.getGroupsStream().toList();
|
||||
}
|
||||
if (UserModel.EMAIL.equals(name)) {
|
||||
return model.getEmail() == null ? List.of() : List.of(model.getEmail());
|
||||
}
|
||||
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, model);
|
||||
Attributes attributes = profile.getAttributes();
|
||||
return attributes.getFirst(name);
|
||||
}
|
||||
|
||||
private Map<String, Object> getAttributeAnnotations(UserModel model, String name) {
|
||||
AttributeMetadata metadata = getAttributes(model).getMetadata(name);
|
||||
private Map<String, Object> getAttributeAnnotations(String name) {
|
||||
AttributeMetadata metadata = getProfileAttributes().getMetadata(name);
|
||||
|
||||
if (metadata == null) {
|
||||
return Map.of();
|
||||
@@ -63,29 +79,17 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema<UserMo
|
||||
return ofNullable(metadata.getAnnotations()).orElse(Map.of());
|
||||
}
|
||||
|
||||
private Attributes getAttributes(UserModel model) {
|
||||
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, model);
|
||||
private Attributes getProfileAttributes() {
|
||||
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, Map.of());
|
||||
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();
|
||||
protected String createModelAttributeResolver(Attribute<UserModel, User> attribute) {
|
||||
for (String name : getModelAttributeNames()) {
|
||||
Object scimName = getAttributeSchemaName(name);
|
||||
List<String> paths = getPaths(attribute);
|
||||
|
||||
for (String name : attributes.nameSet()) {
|
||||
Map<String, Object> annotations = attributes.getAnnotations(name);
|
||||
|
||||
if (annotations == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String scimName = (String) annotations.get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
|
||||
if (scimName != null && scimName.startsWith(this.getName()) && !isCore()) {
|
||||
scimName = scimName.replace(this.getName() + ".", this.getName() + ":");
|
||||
}
|
||||
|
||||
if (getPaths(attribute).contains(scimName)) {
|
||||
if (paths.contains(scimName)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
package org.keycloak.scim.model.user;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.common.util.TriConsumer;
|
||||
import org.keycloak.models.GroupModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.scim.resource.Scim;
|
||||
import org.keycloak.scim.resource.common.Email;
|
||||
import org.keycloak.scim.resource.common.Name;
|
||||
import org.keycloak.scim.resource.schema.attribute.Attribute;
|
||||
import org.keycloak.scim.resource.user.GroupMembership;
|
||||
import org.keycloak.scim.resource.user.User;
|
||||
import org.keycloak.utils.GroupUtils;
|
||||
import org.keycloak.utils.KeycloakSessionUtil;
|
||||
|
||||
public final class UserCoreModelSchema extends AbstractUserModelSchema {
|
||||
|
||||
@@ -24,11 +36,34 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema {
|
||||
List<Attribute<UserModel, User>> attributes = new ArrayList<>();
|
||||
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("userName")
|
||||
.primary()
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.primary()
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.complex("emails", UserModel::setSingleAttribute, this::createModelAttributeResolver, true)
|
||||
attributes.addAll(Attribute.<UserModel, User>complex("emails", Email.class)
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.primary()
|
||||
.multivalued()
|
||||
.withModelSetter((TriConsumer<UserModel, String, Set<Email>>) (model, name, values) -> {
|
||||
for (Email value : values) {
|
||||
model.setEmail(value.getValue());
|
||||
break;
|
||||
}
|
||||
}, (BiConsumer<User, Collection<String>>) (user, emails) -> {
|
||||
if (emails == null || emails.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
user.setEmail(emails.iterator().next());
|
||||
})
|
||||
.withModelRemover((TriConsumer<UserModel, String, Set<Email>>) (model, name, values) -> {
|
||||
model.setEmail(null);
|
||||
})
|
||||
.withModelAdder((TriConsumer<UserModel, String, Set<Email>>) (model, name, values) -> {
|
||||
for (Email value : values) {
|
||||
model.setEmail(value.getValue());
|
||||
break;
|
||||
}
|
||||
})
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>complex("name", Name.class)
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
@@ -41,47 +76,47 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema {
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("displayName")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("title")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("externalId")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("userType")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("nickName")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("locale")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("timezone")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("preferredLanguage")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("profileUrl")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(UserModel::setSingleAttribute)
|
||||
.withModelSetter(UserModel::setSingleAttribute)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("active")
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.primary()
|
||||
.bool()
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.withSetters(
|
||||
(model, name, value) -> model.setEnabled(Boolean.parseBoolean(value))
|
||||
, (user, value) -> user.setActive(Boolean.parseBoolean(value))
|
||||
.withModelSetter(
|
||||
(model, name, value) -> model.setEnabled(Boolean.parseBoolean(Optional.ofNullable(value).orElse("").toString()))
|
||||
, (user, value) -> user.setActive(Boolean.parseBoolean(value.toString()))
|
||||
)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>simple("meta.created")
|
||||
@@ -90,6 +125,75 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema {
|
||||
.immutable()
|
||||
.modelAttributeResolver(this::createModelAttributeResolver)
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>complex("groups", GroupMembership.class)
|
||||
.modelAttributeResolver(Attribute::getName)
|
||||
.multivalued()
|
||||
.withModelSetter((TriConsumer<UserModel, String, Set<GroupMembership>>) (model, name, values) -> {
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
List<GroupModel> remove = new ArrayList<>();
|
||||
|
||||
for (GroupUtils.GroupMembership membership : GroupUtils.getAllMemberships(session, model.getGroupsStream().toList())) {
|
||||
if (values.stream().noneMatch(m -> m.getValue().equals(membership.group().getId()))) {
|
||||
remove.add(membership.group());
|
||||
}
|
||||
}
|
||||
|
||||
for (GroupMembership membership : values) {
|
||||
GroupModel group = session.groups().getGroupById(realm, membership.getValue());
|
||||
|
||||
if (group == null) {
|
||||
throw new ModelValidationException("Group with id " + membership.getValue() + " not found");
|
||||
}
|
||||
|
||||
model.joinGroup(group);
|
||||
}
|
||||
|
||||
for (GroupModel group : remove) {
|
||||
model.leaveGroup(group);
|
||||
}
|
||||
}, (BiConsumer<User, Collection<GroupModel>>) (user, groups) -> {
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
|
||||
for (GroupUtils.GroupMembership membership : GroupUtils.getAllMemberships(session, groups)) {
|
||||
GroupMembership rep = new GroupMembership();
|
||||
|
||||
rep.setValue(membership.group().getId());
|
||||
rep.setDisplay(membership.group().getName());
|
||||
rep.setType(membership.direct() ? "direct" : "indirect");
|
||||
|
||||
user.addGroup(rep);
|
||||
}
|
||||
})
|
||||
.withModelRemover((TriConsumer<UserModel, String, Set<GroupMembership>>) (model, name, values) -> {
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
|
||||
for (GroupMembership membership : values) {
|
||||
GroupModel group = session.groups().getGroupById(realm, membership.getValue());
|
||||
|
||||
if (group == null) {
|
||||
throw new ModelValidationException("Group with id " + membership.getValue() + " not found");
|
||||
}
|
||||
|
||||
model.leaveGroup(group);
|
||||
}
|
||||
})
|
||||
.withModelAdder((TriConsumer<UserModel, String, Set<GroupMembership>>) (model, name, values) -> {
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
|
||||
for (GroupMembership membership : values) {
|
||||
GroupModel group = session.groups().getGroupById(realm, membership.getValue());
|
||||
|
||||
if (group == null) {
|
||||
throw new ModelValidationException("Group with id " + membership.getValue() + " not found");
|
||||
}
|
||||
|
||||
model.joinGroup(group);
|
||||
}
|
||||
})
|
||||
.build());
|
||||
|
||||
return attributes.stream().collect(Collectors.toMap(Attribute::getName, Function.identity()));
|
||||
}
|
||||
|
||||
+12
-5
@@ -12,15 +12,18 @@ import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
|
||||
import org.keycloak.authorization.fgap.evaluation.partial.PartialEvaluationStorageProvider;
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserProvider;
|
||||
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.filter.ScimFilterParser.FilterContext;
|
||||
import org.keycloak.scim.model.filter.ScimJPAPredicateEvaluator;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider;
|
||||
@@ -100,7 +103,7 @@ public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<U
|
||||
CriteriaQuery<UserEntity> query = cb.createQuery(UserEntity.class);
|
||||
Root<UserEntity> root = query.from(UserEntity.class);
|
||||
|
||||
List<Predicate> predicates = this.getUserPredicates(filterContext, cb, root);
|
||||
List<Predicate> predicates = getUserPredicates(filterContext, cb, query, root);
|
||||
|
||||
// apply distinct and order by username to ensure consistency with no-filter case
|
||||
query.where(predicates).distinct(true).orderBy(cb.asc(root.get("username")));
|
||||
@@ -126,7 +129,7 @@ public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<U
|
||||
CriteriaQuery<Long> query = cb.createQuery(Long.class);
|
||||
Root<UserEntity> root = query.from(UserEntity.class);
|
||||
|
||||
List<Predicate> predicates = this.getUserPredicates(filterContext, cb, root);
|
||||
List<Predicate> predicates = this.getUserPredicates(filterContext, cb, query, root);
|
||||
query.select(cb.countDistinct(root)).where(predicates);
|
||||
return em.createQuery(query).getSingleResult();
|
||||
} else {
|
||||
@@ -165,18 +168,22 @@ public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<U
|
||||
return exception;
|
||||
}
|
||||
|
||||
private List<Predicate> getUserPredicates(ScimFilterParser.FilterContext filterContext, CriteriaBuilder cb, Root<UserEntity> root) {
|
||||
private List<Predicate> getUserPredicates(FilterContext filterContext, CriteriaBuilder cb, CriteriaQuery<?> query, Root<UserEntity> root) {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// create filter predicate using the same query and root that will be used for execution
|
||||
ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(session, getSchemas(), cb, root);
|
||||
ScimJPAPredicateEvaluator evaluator = new ScimJPAPredicateEvaluator(getSchemas(), cb, root);
|
||||
predicates.add(evaluator.visit(filterContext).predicate());
|
||||
|
||||
// apply service account restriction
|
||||
predicates.add(root.get("serviceAccountClientLink").isNull());
|
||||
|
||||
// apply realm restriction
|
||||
predicates.add(cb.equal(root.get("realmId"), session.getContext().getRealm().getId()));
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
predicates.add(cb.equal(root.get("realmId"), realm.getId()));
|
||||
|
||||
UserProvider userProvider = session.getProvider(UserProvider.class, "jpa");
|
||||
predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters(session, AdminPermissionsSchema.USERS, (PartialEvaluationStorageProvider) userProvider, realm, cb, query, root));
|
||||
|
||||
return predicates;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package org.keycloak.tests.scim.tck;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
|
||||
import org.keycloak.admin.client.resource.GroupResource;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.representations.idm.GroupRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.representations.userprofile.config.UPAttribute;
|
||||
@@ -22,9 +25,11 @@ import org.keycloak.scim.resource.common.Email;
|
||||
import org.keycloak.scim.resource.common.Name;
|
||||
import org.keycloak.scim.resource.user.EnterpriseUser;
|
||||
import org.keycloak.scim.resource.user.EnterpriseUser.Manager;
|
||||
import org.keycloak.scim.resource.user.GroupMembership;
|
||||
import org.keycloak.scim.resource.user.User;
|
||||
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
|
||||
import org.keycloak.testframework.realm.ClientConfigBuilder;
|
||||
import org.keycloak.testframework.realm.GroupConfigBuilder;
|
||||
import org.keycloak.testframework.realm.UserConfigBuilder;
|
||||
import org.keycloak.testframework.scim.client.annotations.InjectScimClient;
|
||||
import org.keycloak.testframework.util.ApiUtil;
|
||||
@@ -601,6 +606,16 @@ public class UserTest extends AbstractScimTest {
|
||||
expected.setActive(false);
|
||||
assertRootAttributes(actual, expected);
|
||||
|
||||
// patch a multivalued attribute using a filter in the path that does not resolve to any value, no update should be performed
|
||||
String expectedEmail = expected.getEmail();
|
||||
expected.setEmail(expected.getEmail().replace("patched4.org", "patched5.org"));
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.replace("emails[type eq \"work\"].primary", expected.getEmail())
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
expected.setEmail(expectedEmail);
|
||||
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")));
|
||||
@@ -693,6 +708,110 @@ public class UserTest extends AbstractScimTest {
|
||||
assertEquals("5678", actual.getEnterpriseUser().getCostCenter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUserMembership() {
|
||||
GroupRepresentation groupA = createGroup("Group A");
|
||||
GroupRepresentation groupA1 = createSubGroup(groupA, "Group A1");
|
||||
GroupRepresentation groupA2 = createSubGroup(groupA, "Group A2");
|
||||
GroupRepresentation groupA21 = createSubGroup(groupA2, "Group A21");
|
||||
GroupRepresentation groupB = createGroup("Group B");
|
||||
GroupRepresentation groupC = createGroup("Group C");
|
||||
GroupRepresentation groupC1 = createSubGroup(groupC, "Group C1");
|
||||
|
||||
User user = createUser();
|
||||
|
||||
user.addGroup(groupA.getId());
|
||||
user.addGroup(groupA1.getId());
|
||||
user.addGroup(groupA2.getId());
|
||||
user.addGroup(groupA21.getId());
|
||||
user.addGroup(groupB.getId());
|
||||
user.addGroup(groupC1.getId());
|
||||
|
||||
User expected = client.users().create(user);
|
||||
User actual = client.users().get(expected.getId());
|
||||
|
||||
List<GroupMembership> groups = actual.getGroups();
|
||||
|
||||
assertNotNull(groups);
|
||||
assertEquals(7, groups.size());
|
||||
assertGroup(groups, groupA, "direct");
|
||||
assertGroup(groups, groupC, "indirect");
|
||||
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.remove("groups[value eq \"" + groupC1.getId() + "\"]")
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(5, groups.size());
|
||||
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.remove("groups[value eq \"" + groupA1.getId() + "\"]")
|
||||
.remove("groups[value eq \"" + groupB.getId() + "\"]")
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(3, groups.size());
|
||||
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.add("groups", groupC1.getId())
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(5, groups.size());
|
||||
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.add("groups", groupA1.getId())
|
||||
.add("groups", groupB.getId())
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(7, groups.size());
|
||||
|
||||
expected = actual;
|
||||
expected.getGroups().clear();
|
||||
expected.addGroup(groupA.getId());
|
||||
client.users().update(expected);
|
||||
actual = client.users().get(expected.getId());
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(1, groups.size());
|
||||
}
|
||||
|
||||
private static void assertGroup(List<GroupMembership> groups, GroupRepresentation group, String type) {
|
||||
assertTrue(groups.stream().anyMatch(membership -> {
|
||||
boolean found = group.getId().equals(membership.getValue()) && group.getName().equals(membership.getDisplay());
|
||||
|
||||
if (found) {
|
||||
return type.equals(membership.getType());
|
||||
}
|
||||
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
private GroupRepresentation createGroup(String name) {
|
||||
GroupRepresentation group = GroupConfigBuilder.create().name(name).build();
|
||||
try (Response response = realm.admin().groups().add(group)) {
|
||||
group.setId(ApiUtil.getCreatedId(response));
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
private GroupRepresentation createSubGroup(GroupRepresentation parent, String name) {
|
||||
GroupResource parentApi = realm.admin().groups().group(parent.getId());
|
||||
GroupRepresentation subGroup = GroupConfigBuilder.create().name(name).build();
|
||||
|
||||
try (Response response = parentApi.subGroup(subGroup)) {
|
||||
subGroup.setId(ApiUtil.getCreatedId(response));
|
||||
}
|
||||
|
||||
return subGroup;
|
||||
}
|
||||
|
||||
private void assertRootAttributes(User actual, User expected) {
|
||||
assertNotNull(actual);
|
||||
assertTrue(actual.hasSchema(getCoreSchema(expected.getClass())));
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package org.keycloak.utils;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
|
||||
@@ -179,4 +184,45 @@ public class GroupUtils {
|
||||
rep.setAccess(groupsEvaluator.getAccess(groupTree));
|
||||
return rep;
|
||||
}
|
||||
|
||||
public static Set<GroupMembership> getAllMemberships(KeycloakSession session, Collection<GroupModel> groups) {
|
||||
return getAllMemberships(session, groups, true);
|
||||
}
|
||||
|
||||
public static Set<GroupMembership> getAllMemberships(KeycloakSession session, Collection<GroupModel> groups, boolean direct) {
|
||||
Set<GroupMembership> memberships = new HashSet<>();
|
||||
|
||||
for (GroupModel group : groups) {
|
||||
GroupMembership membership = new GroupMembership(group, direct);
|
||||
|
||||
if (!memberships.add(membership)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.getParentId() != null) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
GroupModel parent = session.groups().getGroupById(realm, group.getParentId());
|
||||
|
||||
if (parent != null) {
|
||||
memberships.addAll(getAllMemberships(session, List.of(parent), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return memberships;
|
||||
}
|
||||
|
||||
public record GroupMembership(GroupModel group, boolean direct) {
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof GroupMembership that)) return false;
|
||||
return Objects.equals(group, that.group);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user