Support for groups attribute from User core schema

Closes #46215

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor
2026-03-03 14:01:39 -03:00
parent 0b1651fc96
commit 623f942070
19 changed files with 877 additions and 331 deletions
@@ -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);
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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
@@ -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()));
}
}
@@ -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;
}
}
@@ -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,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())));