Support for custom user scim extension

Closes #48345

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor
2026-04-29 16:28:11 -03:00
committed by GitHub
parent 8b357d610a
commit 98e9ec6028
21 changed files with 813 additions and 181 deletions
@@ -1,19 +1,24 @@
package org.keycloak.scim.resource;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.keycloak.scim.resource.common.Meta;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import static org.keycloak.scim.resource.Scim.getCoreSchema;
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonInclude(Include.NON_NULL)
@JsonIgnoreProperties({"type"})
public abstract class ResourceTypeRepresentation {
@@ -34,6 +39,8 @@ public abstract class ResourceTypeRepresentation {
@JsonIgnore
private Long lastModifiedTimestamp;
private Map<String, Object> extensions;
public Set<String> getSchemas() {
if (schemas == null) {
schemas = new HashSet<>();
@@ -96,4 +103,21 @@ public abstract class ResourceTypeRepresentation {
}
schemas.add(schema);
}
@JsonAnyGetter
public Map<String, Object> getExtensions() {
return extensions;
}
@JsonAnySetter
public void setExtensions(String name, Object value) {
if (extensions == null) {
extensions = new HashMap<>();
}
this.extensions.put(name, value);
}
public void setExtensions(Map<String, Object> extensions) {
this.extensions = extensions;
}
}
@@ -25,6 +25,8 @@ import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import static java.util.Optional.ofNullable;
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;
@@ -52,15 +54,14 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
@Override
public Map<String, Attribute<M, R>> getAttributes() {
if (attributes == null) {
attributes = doGetAttributes();
attributes = getAttributeMappers();
}
return attributes;
}
protected abstract Map<String, Attribute<M,R>> doGetAttributes();
@Override
public void populate(M model, R representation) {
validate(representation);
populateModel(model, representation);
representation.setId(model.getId());
}
@@ -78,7 +79,7 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
}
@Override
public void validate(R representation) throws SchemaValidationException {
public void validate(R representation) throws ModelValidationException {
// validate here the schema
}
@@ -125,7 +126,8 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
}
/**
* Returns the names of the attributes from the given {@code model}.
* Returns the names of the attributes from the given {@code model}, if provided. The {@code model} provides
* additional context when resolving model attributes, in case attributes depend on the current model being processed.
*
* @return the names of the attributes defined in the model
*/
@@ -149,83 +151,70 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
protected abstract String getAttributeSchemaName(String name);
private void populateModel(M model, R resource) {
ObjectNode objectNode;
ObjectNode resourceNode;
try {
objectNode = JsonSerialization.createObjectNode(resource);
resourceNode = JsonSerialization.createObjectNode(resource);
} catch (Exception e) {
throw new RuntimeException("Failed to convert representation to JSON", e);
}
for (String name : getModelAttributeNames()) {
Attribute<M, R> attribute = getAttributeMapperByModelAttribute(name);
for (Entry<String, Attribute<M, R>> entry : getAttributeMappers().entrySet()) {
Attribute<M, R> attribute = entry.getValue();
String scimName = attribute.getName();
ObjectNode valueNode = resourceNode;
if (attribute == null) {
continue;
}
if (attribute.isExtension()) {
JsonNode node = ofNullable(resourceNode.get(attribute.getSchema())).orElse(NullNode.getInstance());
Object value = getJsonValue(objectNode, attribute.getName());
if (value == null) {
String attributeName = attribute.getName();
if (attributeName.indexOf('.') > 0) {
attributeName = attributeName.substring(attributeName.indexOf('.') + 1);
List<String> paths = new ArrayList<>();
paths.add(getId());
paths.addAll(List.of(attributeName.split("\\.")));
value = getJsonValue(objectNode, paths);
if (!node.isObject()) {
continue;
}
valueNode = (ObjectNode) node;
scimName = attribute.getSimpleName();
}
if (value == null) {
JsonNode schemaExtension = objectNode.get(getId());
value = getJsonValue(schemaExtension, attribute.getName());
}
Object value = getJsonValue(valueNode, scimName);
if (value != null) {
if (value instanceof Collection<?> values) {
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;
}
}
} else {
attribute.set(model, TextNode.valueOf(value.toString()));
}
setValue(model, attribute, value);
}
}
}
private void populateResourceType(R resource, M model, List<String> requestedAttributes, List<String> excludedAttributes) {
for (String name : getModelAttributeNames()) {
Attribute<M, R> attribute = getAttributeMapperByModelAttribute(name);
for (Entry<String, Attribute<M, R>> entry : getAttributeMappers().entrySet()) {
Attribute<M, R> attribute = entry.getValue();
if (!attribute.isExcluded(this, requestedAttributes, excludedAttributes)) {
String modelAttributeName = attribute.getModelAttributeName();
Object value = getAttributeValue(model, modelAttributeName);
if (attribute != null && !attribute.isExcluded(this, requestedAttributes, excludedAttributes)) {
Object value = getAttributeValue(model, name);
attribute.set(resource, value);
resource.addSchema(this.id);
if (!isInternal()) {
resource.addSchema(this.id);
}
}
}
}
private Attribute<M, R> getAttributeMapperByModelAttribute(String name) {
protected Map<String, Attribute<M,R>> getAttributeMappers() {
Map<String, Attribute<M,R>> mappers = new HashMap<>();
for (String name : getModelAttributeNames()) {
Attribute<M, R> attribute = getAttributeMapperByModelAttribute(name);
if (attribute != null) {
mappers.put(name, attribute);
}
}
return mappers;
}
protected Attribute<M, R> getAttributeMapperByModelAttribute(String name) {
String scimName = getAttributeSchemaName(name);
if (scimName == null) {
@@ -323,27 +312,25 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
return false;
}
return getPaths(attribute).stream().anyMatch(path::equalsIgnoreCase);
return resolvePaths(attribute).stream().anyMatch(path::equalsIgnoreCase);
}
private List<String> getPaths(Attribute<M, R> attr) {
private List<String> resolvePaths(Attribute<M, R> attr) {
List<String> paths = new ArrayList<>();
// the name of the attribute itself is always a valid path
paths.add(attr.getName());
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 (attr.isExtension()) {
paths.add(attr.getSchema() + ":" + attr.getSimpleName());
}
if (parent != null) {
paths.add(getId() + attr.getName().replace(parent + ".", ":"));
paths.add(getId() + attr.getName().replace(parent, ""));
}
if (attr.getName().endsWith(".value")) {
paths.add(attr.getSchema() + ":" + attr.getSimpleName().substring(0, attr.getSimpleName().length() - 6));
}
if (attr.getAlias() != null) {
paths.add(getId() + ":" + attr.getAlias());
}
if (attr.getAlias() != null) {
paths.add(getId() + ":" + attr.getAlias());
}
Class<?> complexType = attr.getComplexType();
@@ -357,25 +344,50 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
return paths;
}
public void setValue(M model, Attribute<M, R> resolved, JsonNode value) {
setValue(model, resolved, value, SET);
private void setValue(M model, Attribute<M, R> attribute, Object value) {
setValue(model, attribute, value, SET);
}
private void setValue(M model, Attribute<M, R> attribute, JsonNode value, Operation operation) {
private void setValue(M model, Attribute<M, R> attribute, Object 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");
JsonNode jsonValue = toJsonNode(value);
switch (operation) {
case SET -> attribute.set(model, value);
case ADD -> attribute.add(model, value);
case REMOVE -> attribute.remove(model, value);
case SET -> attribute.set(model, jsonValue);
case ADD -> attribute.add(model, jsonValue);
case REMOVE -> attribute.remove(model, jsonValue);
default -> throw new ModelException("Invalid operation: " + operation);
}
}
private JsonNode resolveAttributeValue(Attribute<M, R> attribute,JsonNode jsonNode) {
private JsonNode toJsonNode(Object value) {
if (value == null) {
return null;
}
if (value instanceof JsonNode) {
return (JsonNode) value;
} else if (value instanceof Collection<?> values) {
ArrayNode nodes = JsonNodeFactory.instance.arrayNode();
for (Object v : values) {
if (v instanceof JsonNode jsonNode) {
nodes.add(jsonNode);
} else {
nodes.add(TextNode.valueOf(v.toString()));
}
}
return nodes;
}
return TextNode.valueOf(value.toString());
}
private JsonNode resolveAttributeValue(Attribute<M, R> attribute, JsonNode jsonNode) {
if (jsonNode.isValueNode()) {
Class<?> complexType = attribute.getComplexType();
@@ -2,13 +2,17 @@ package org.keycloak.scim.resource.schema;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.keycloak.models.Model;
import org.keycloak.models.ModelValidationException;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import com.fasterxml.jackson.databind.JsonNode;
import static java.util.Optional.ofNullable;
/**
* <p>An interface that represents a schema for a resource type.
*
@@ -70,9 +74,9 @@ public interface ModelSchema<M extends Model, R extends ResourceTypeRepresentati
* Validates the given {@code representation} against the schema. It should throw an exception if the representation is not valid.
*
* @param representation the representation to be validated
* @throws SchemaValidationException if the representation is not valid against the schema
* @throws ModelValidationException if the representation is not valid against the schema
*/
void validate(R representation) throws SchemaValidationException;
void validate(R representation) throws ModelValidationException;
/**
* Performs a PATCH {@code add} operation on the given {@code model} for the attribute defined by the given {@code path} and the given {@code value}.
@@ -128,4 +132,23 @@ public interface ModelSchema<M extends Model, R extends ResourceTypeRepresentati
* @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);
/**
* Returns {@code true} if this schema is an internal schema, not exposed from the schema endpoint.
*
* @return {@code true} if this schema is an internal schema, {@code false} otherwise
*/
default boolean isInternal() {
return false;
}
/**
* Returns {@code true} if this schema supports any of the given {@code schemas}
*
* @param schemas the schemas
* @return {@code true} if this schema supports any of the given {@code schemas}. Otherwise, {@code false}
*/
default boolean supports(Set<String> schemas) {
return isCore() || ofNullable(schemas).orElse(Set.of()).contains(getId());
}
}
@@ -1,10 +0,0 @@
package org.keycloak.scim.resource.schema;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
/**
* Thrown to indicate errors when validating a {@link ResourceTypeRepresentation} against a {@link ModelSchema}.
*/
public class SchemaValidationException extends RuntimeException {
}
@@ -14,6 +14,7 @@ import org.keycloak.scim.resource.schema.ModelSchema;
import com.fasterxml.jackson.databind.JsonNode;
import static java.util.Objects.requireNonNull;
import static java.util.Optional.ofNullable;
/**
@@ -29,6 +30,39 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
public static final String RETURNED_REQUEST = "request";
public static final String RETURNED_NEVER = "never";
public static String getSchema(String name) {
requireNonNull(name, "name is required");
int schemaSeparator = name.lastIndexOf(':');
if (schemaSeparator == -1) {
return null;
}
return name.substring(0, schemaSeparator) + ":" + getResourceType(name);
}
public static String getResourceType(String name) {
requireNonNull(name, "name is required");
int schemaSeparator = name.lastIndexOf(':');
if (schemaSeparator == -1) {
return null;
}
String resourceType = name.substring(name.substring(0, schemaSeparator).length() + 1);
return resourceType.substring(0, resourceType.indexOf('.'));
}
public static String getSimpleName(String name) {
String schema = getSchema(name);
if (schema == null) {
return name;
}
return name.substring(schema.length() + 1);
}
/**
* Creates a simple attribute with the given {@code name}.
*
@@ -186,6 +220,27 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
return uniqueness;
}
public String getSchema() {
if (!isExtension()) {
return null;
}
return getSchema(getName());
}
public String getResourceType() {
if (!isExtension()) {
return null;
}
return getResourceType(getName());
}
public String getSimpleName() {
if (!isExtension()) {
return null;
}
return getSimpleName(getName());
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Attribute<?, ?> attribute)) return false;
@@ -264,12 +319,16 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
}).anyMatch(this::equals);
}
public boolean isExtension() {
return getName().contains(":");
}
public static class Builder<M extends Model, R extends ResourceTypeRepresentation> {
private final Class<?> complexType;
private final String name;
private TriConsumer<M, String, ?> modelSetter;
private BiConsumer<R, ?> representationSetter;
private TriConsumer<Attribute<M, R>, R, ?> representationSetter;
List<Attribute<M, R>> attributes = new ArrayList<>();
// by default, resolve model attribute name as the same as the scim attribute name
private Function<Attribute<M, R>, String> modelAttributeResolver = Attribute::getName;
@@ -284,14 +343,20 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
private String uniqueness = "none";
private Builder(String name, Class<?> complexType) {
Objects.requireNonNull(name, "name cannot be null");
requireNonNull(name, "name cannot be null");
this.complexType = complexType;
this.name = name;
}
public <C extends BiConsumer<R, ?>> Builder<M, R> withModelSetter(TriConsumer<M, String, ?> modelSetter, C representationSetter) {
public <V, C extends BiConsumer<R, V>> Builder<M, R> withModelSetter(TriConsumer<M, String, ?> modelSetter, C repSetter) {
this.modelSetter = modelSetter;
this.representationSetter = representationSetter;
this.representationSetter = (TriConsumer<Attribute<M, R>, R, Object>) (attribute, r, o) -> repSetter.accept(r, (V) o);
return this;
}
public <V, C extends TriConsumer<Attribute<M, R>, R, V>> Builder<M, R> withSetters(TriConsumer<M, String, ?> modelSetter, C repSetter) {
this.modelSetter = modelSetter;
this.representationSetter = repSetter;
return this;
}
@@ -3,7 +3,6 @@ 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;
@@ -24,13 +23,13 @@ public class AttributeMapper<M extends Model, R extends ResourceTypeRepresentati
private final TriConsumer<M, String, ?> modelSetter;
private TriConsumer<M, String, ?> modelRemover;
private TriConsumer<M, String, ?> modelAdder;
private final BiConsumer<R, ?> representationSetter;
private final TriConsumer<Attribute<M, R>, R, ?> representationSetter;
AttributeMapper(TriConsumer<M, String, ?> modelSetter, BiConsumer<R, ?> representationSetter) {
AttributeMapper(TriConsumer<M, String, ?> modelSetter, TriConsumer<Attribute<M, R>, R, ?> representationSetter) {
this(modelSetter, representationSetter, null, null);
}
AttributeMapper(TriConsumer<M, String, ?> modelSetter, BiConsumer<R, ?> representationSetter, TriConsumer<M, String, ?> modelRemover, TriConsumer<M, String, ?> modelAdder) {
AttributeMapper(TriConsumer<M, String, ?> modelSetter, TriConsumer<Attribute<M, R>, R, ?> representationSetter, TriConsumer<M, String, ?> modelRemover, TriConsumer<M, String, ?> modelAdder) {
this.modelSetter = modelSetter;
this.representationSetter = representationSetter;
this.modelRemover = modelRemover;
@@ -39,7 +38,7 @@ public class AttributeMapper<M extends Model, R extends ResourceTypeRepresentati
public void setValue(R representation, Object value) {
if (representationSetter != null) {
((BiConsumer<R, Object>) representationSetter).accept(representation, value);
((TriConsumer<Attribute<M, R>, R, Object>) representationSetter).accept(attribute, representation, value);
}
}
@@ -5,12 +5,13 @@ import java.lang.reflect.ParameterizedType;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Objects;
import java.util.function.BiConsumer;
import org.keycloak.common.util.TriConsumer;
import org.keycloak.models.Model;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.common.MultiValuedAttribute;
public class ComplexAttributeSetter<R extends ResourceTypeRepresentation> implements BiConsumer<R, String> {
public class ComplexAttributeSetter<M extends Model, R extends ResourceTypeRepresentation> implements TriConsumer<Attribute<M, R>, R, String> {
private final String name;
private final String subName;
@@ -28,7 +29,7 @@ public class ComplexAttributeSetter<R extends ResourceTypeRepresentation> implem
}
@Override
public void accept(R representation, String newValue) {
public void accept(Attribute<M, R> attribute, R representation, String newValue) {
try {
Method declaredMethod = representation.getClass().getMethod("get" + Character.toUpperCase(name.charAt(0)) + name.substring(1));
Class<?> returnType = declaredMethod.getReturnType();
@@ -17,6 +17,8 @@ import org.keycloak.scim.resource.schema.ModelSchema;
import com.fasterxml.jackson.databind.JsonNode;
import static java.util.function.Predicate.not;
import static org.keycloak.utils.StringUtil.isBlank;
public abstract class AbstractScimResourceTypeProvider<M extends Model, R extends ResourceTypeRepresentation> implements ScimResourceTypeProvider<R> {
@@ -155,7 +157,7 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
@Override
public List<String> getSchemaExtensions() {
return schemaExtensions.stream().map(ModelSchema::getId).toList();
return schemaExtensions.stream().filter(not(ModelSchema::isInternal)).map(ModelSchema::getId).toList();
}
protected abstract R onCreate(R resource);
@@ -172,7 +174,7 @@ public abstract class AbstractScimResourceTypeProvider<M extends Model, R extend
protected void populate(M model, R resource) {
for (ModelSchema<M, R> schema : schemas) {
if (resource.hasSchema(schema.getId())) {
if (schema.supports(resource.getSchemas())) {
schema.populate(model, resource);
}
}
@@ -25,6 +25,8 @@ import org.keycloak.scim.resource.schema.AbstractModelSchema;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.utils.KeycloakSessionUtil;
import static org.keycloak.utils.StringUtil.isBlank;
public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel, Group> {
public GroupCoreModelSchema() {
@@ -76,7 +78,7 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
}
@Override
protected Map<String, Attribute<GroupModel, Group>> doGetAttributes() {
protected Map<String, Attribute<GroupModel, Group>> getAttributeMappers() {
List<Attribute<GroupModel, Group>> attributes = new ArrayList<>(Attribute.<GroupModel, Group>simple("displayName")
.notCaseExact()
.modelAttributeResolver((attribute) -> {
@@ -163,6 +165,13 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
setTimestamps(resource, model);
}
@Override
public void validate(Group representation) throws ModelValidationException {
if (isBlank(representation.getDisplayName())) {
throw new ModelValidationException("Display name is required");
}
}
private void setTimestamps(Group resource, GroupModel model) {
Long createdTimestamp = model.getCreatedTimestamp();
if (createdTimestamp != null) {
@@ -70,7 +70,7 @@ public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<
List<Member> members = resource.getMembers();
if (!Optional.ofNullable(members).orElse(List.of()).isEmpty()) {
throw new ModelValidationException("Managing members on updates are not supported");
throw new ModelValidationException("Managing members on updates is not supported");
}
return super.update(resource);
@@ -22,6 +22,7 @@ import org.keycloak.scim.resource.schema.Schema;
import org.keycloak.scim.resource.schema.Schema.Attribute;
import org.keycloak.scim.resource.spi.ScimResourceTypeProvider;
/**
* Provider for SCIM Schema resources. This provider exposes the supported SCIM schemas
* for discovery by SCIM clients via the /Schemas endpoint.
@@ -50,8 +51,7 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
|| providerFactory instanceof ServiceProviderConfigResourceTypeProvider)
).flatMap((Function<ProviderFactory, Stream<ModelSchema>>) factory -> {
ScimResourceTypeProvider provider = session.getProvider(ScimResourceTypeProvider.class, factory.getId());
List<ModelSchema> modelSchemas = provider.getSchemas();
return modelSchemas.stream();
return provider.getSchemas().stream();
}).forEach(this::buildSchema);
}
@@ -73,6 +73,11 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
String parentName = attribute.getParentName();
if (!modelSchema.isCore()) {
// extensions attributes should be set in a top-level attribute with the schema name as the name
parentName = attribute.getSchema();
}
if (parentName != null && !parentName.equals(name)) {
// This is a sub-attribute — strip the parent prefix to get the relative path
String relativeName = name.substring(parentName.length() + 1);
@@ -140,38 +145,56 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
subAttributes.add(subAttr);
} else {
// Extension schema simple sub-attribute (e.g., "enterpriseUser.employeeNumber" → "employeeNumber")
topLevelAttributes.computeIfAbsent(relativeName, k -> {
Attribute attr = new Attribute();
attr.setName(k);
attr.setType(attribute.getType());
attr.setMultiValued(attribute.isMultivalued());
attr.setReturned(attribute.getReturned());
attr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite");
attr.setRequired(attribute.isRequired());
attr.setCaseExact(attribute.isCaseExact());
attr.setUniqueness(attribute.getUniqueness());
return attr;
});
topLevelAttributes.computeIfAbsent(relativeName, createExtensionAttribute(modelSchema, parentName, attribute));
}
} else {
// Top-level attribute — only add if not already created as a parent
topLevelAttributes.computeIfAbsent(name, k -> {
Attribute attr = new Attribute();
attr.setName(k);
attr.setType(attribute.getType());
attr.setMultiValued(attribute.isMultivalued());
attr.setReturned(attribute.getReturned());
attr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite");
attr.setRequired(attribute.isRequired());
attr.setCaseExact(attribute.isCaseExact());
attr.setUniqueness(attribute.getUniqueness());
return attr;
});
topLevelAttributes.computeIfAbsent(name, k -> createTopLevelAttribute(attribute, k));
}
}
rep.setAttributes(List.copyOf(topLevelAttributes.values()));
schemas.put(modelSchema.getId(), rep);
if (!modelSchema.isInternal()) {
schemas.put(modelSchema.getId(), rep);
}
}
private Function<String, Attribute> createExtensionAttribute(ModelSchema<?, ?> modelSchema, String schemaName, org.keycloak.scim.resource.schema.attribute.Attribute<?, ?> attribute) {
return k -> {
Attribute attr = createTopLevelAttribute(attribute, k);
if (modelSchema.isCore()) {
return attr;
}
schemas.computeIfAbsent(schemaName, n -> {
Schema schema = new Schema();
schema.setName(n);
schema.setId(n);
schema.setAttributes(new ArrayList<>());
return schema;
}).getAttributes().add(attr);
return attr;
};
}
private Attribute createTopLevelAttribute(org.keycloak.scim.resource.schema.attribute.Attribute<?, ?> attribute, String name) {
Attribute attr = new Attribute();
attr.setName(name);
attr.setType(attribute.getType());
attr.setMultiValued(attribute.isMultivalued());
attr.setReturned(attribute.getReturned());
attr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite");
attr.setRequired(attribute.isRequired());
attr.setCaseExact(attribute.isCaseExact());
attr.setUniqueness(attribute.getUniqueness());
return attr;
}
@@ -18,6 +18,8 @@ import org.keycloak.userprofile.UserProfileProvider;
import static java.util.Optional.ofNullable;
import static org.keycloak.scim.resource.schema.attribute.Attribute.getSchema;
public abstract class AbstractUserModelSchema extends AbstractModelSchema<UserModel ,User> {
public static final String ANNOTATION_SCIM_SCHEMA_ATTRIBUTE = "kc.scim.schema.attribute";
@@ -30,11 +32,13 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema<UserMo
@Override
protected Set<String> getModelAttributeNames() {
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, Map.of());
UserProfile profile = getUserProfile();
Attributes attributes = profile.getAttributes();
Set<String> names = new HashSet<>(attributes.nameSet());
names.add(UserModel.ENABLED);
names.add("groups");
return names;
}
@@ -95,4 +99,12 @@ public abstract class AbstractUserModelSchema extends AbstractModelSchema<UserMo
return null;
}
protected boolean hasSchema(String attributeName) {
return getId().equals(getSchema(attributeName));
}
protected UserProfile getUserProfile() {
return session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, Map.of());
}
}
@@ -45,7 +45,14 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema {
}
@Override
protected Map<String, Attribute<UserModel, User>> doGetAttributes() {
protected boolean hasSchema(String attributeName) {
String schema = Attribute.getSchema(attributeName);
return schema == null || getId().equals(schema);
}
@Override
protected Map<String, Attribute<UserModel, User>> getAttributeMappers() {
List<Attribute<UserModel, User>> attributes = new ArrayList<>();
attributes.addAll(Attribute.<UserModel, User>simple("userName")
@@ -131,7 +138,12 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema {
.bool()
.withModelSetter(
(model, name, value) -> model.setEnabled(Boolean.parseBoolean(Optional.ofNullable(value).orElse("").toString()))
, (user, value) -> user.setActive(Boolean.parseBoolean(value.toString()))
, (user, value) -> {
if (value == null) {
return;
}
user.setActive(Boolean.parseBoolean(value.toString()));
}
)
.build());
attributes.addAll(Attribute.<UserModel, User>simple("meta.created")
@@ -1,19 +1,12 @@
package org.keycloak.scim.model.user;
import java.util.ArrayList;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.scim.resource.user.EnterpriseUser;
import org.keycloak.scim.resource.user.User;
import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA;
public final class UserEnterpriseModelSchema extends AbstractUserModelSchema {
public final class UserEnterpriseModelSchema extends UserExtensionModelSchema {
public UserEnterpriseModelSchema(KeycloakSession session) {
super(session, ENTERPRISE_USER_SCHEMA);
@@ -35,21 +28,12 @@ public final class UserEnterpriseModelSchema extends AbstractUserModelSchema {
}
@Override
public boolean isCore() {
public boolean isInternal() {
return false;
}
@Override
protected Map<String, Attribute<UserModel, User>> doGetAttributes() {
return new ArrayList<>(Attribute.<UserModel, User>complex("enterpriseUser", EnterpriseUser.class)
.modelAttributeResolver(this::createModelAttributeResolver)
.withAttribute("employeeNumber", UserModel::setSingleAttribute)
.withAttribute("costCenter", UserModel::setSingleAttribute)
.withAttribute("organization", UserModel::setSingleAttribute)
.withAttribute("division", UserModel::setSingleAttribute)
.withAttribute("department", UserModel::setSingleAttribute)
.withAttribute("manager.value", "manager", UserModel::setSingleAttribute)
.withAttribute("manager.displayName", UserModel::setSingleAttribute)
.build()).stream().collect(Collectors.toMap(Attribute::getName, Function.identity()));
protected boolean hasSchema(String attributeName) {
return getId().equals(Attribute.getSchema(attributeName));
}
}
@@ -0,0 +1,225 @@
package org.keycloak.scim.model.user;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.scim.resource.user.User;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfile;
import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA;
import static org.keycloak.scim.resource.Scim.USER_CORE_SCHEMA;
import static org.keycloak.userprofile.UserProfileUtil.isRootAttribute;
public class UserExtensionModelSchema extends AbstractUserModelSchema {
public static final String KEYCLOAK_USER_SCHEMA = "urn:keycloak:params:scim:schemas:extension:realm:1.0:User";
private final KeycloakSession session;
public UserExtensionModelSchema(KeycloakSession session) {
this(session, KEYCLOAK_USER_SCHEMA);
}
public UserExtensionModelSchema(KeycloakSession session, String schema) {
super(session, schema);
this.session = session;
}
@Override
public String getId() {
return KEYCLOAK_USER_SCHEMA;
}
@Override
public String getName() {
return "RealmUser";
}
@Override
public String getDescription() {
return "Realm User";
}
@Override
public boolean isCore() {
return false;
}
@Override
public boolean isInternal() {
return true;
}
@Override
public boolean supports(Set<String> schemas) {
for (Attribute<UserModel, User> value : getAttributes().values()) {
String schema = value.getSchema();
if (schema != null && schemas.contains(schema)) {
return true;
}
}
return false;
}
@Override
protected Set<String> getModelAttributeNames() {
if (isCore()) {
return Set.of();
}
Set<String> names = new HashSet<>();
UserProfile profile = getUserProfile();
for (String name : profile.getAttributes().nameSet()) {
if (isRootAttribute(name)) {
continue;
}
AttributeMetadata metadata = profile.getAttributes().getMetadata(name);
if (metadata == null) {
continue;
}
String scimName = (String) metadata.getAnnotations().get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
if (scimName == null || !scimName.contains(":")) {
continue;
}
names.add(name);
}
return names;
}
@Override
protected Attribute<UserModel, User> getAttributeMapperByModelAttribute(String name) {
UserProfile profile = getUserProfile();
Attributes attributes = profile.getAttributes();
AttributeMetadata metadata = attributes.getMetadata(name);
if (metadata == null) {
return null;
}
Map<String, Object> annotations = metadata.getAnnotations();
if (annotations == null) {
return null;
}
String scimName = (String) annotations.get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
if (scimName == null) {
return null;
}
if (!hasSchema(scimName)) {
return null;
}
return createCustomAttribute(scimName);
}
@Override
protected boolean hasSchema(String attributeName) {
String schema = Attribute.getSchema(attributeName);
// it should be possible to query other schemas from the providers
return schema != null && !List.of(USER_CORE_SCHEMA, ENTERPRISE_USER_SCHEMA).contains(schema);
}
private Attribute<UserModel, User> createCustomAttribute(Object scimName) {
return Attribute.<UserModel, User>simple(scimName.toString())
.modelAttributeResolver(attribute -> {
if (isCore()) {
return null;
}
UserProfile profile = getUserProfile();
Attributes attributes = profile.getAttributes();
for (String modelName : attributes.nameSet()) {
AttributeMetadata metadata = attributes.getMetadata(modelName);
if (metadata == null) {
return null;
}
Map<String, Object> annotations = metadata.getAnnotations();
if (annotations == null) {
return null;
}
Object modelScimAttributeName = annotations.get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
if (attribute.getName().equals(modelScimAttributeName)) {
return modelName;
}
}
return null;
})
.withSetters((model, name, value) -> {
if (isCore()) {
return;
}
if (getAttributeMapperByModelAttribute(name) == null) {
return;
}
if (value == null) {
model.removeAttribute(name);
} else {
model.setSingleAttribute(name, value.toString());
}
}, (attribute, user, value) -> {
if (isCore()) {
return;
}
String schema = attribute.getSchema();
if (schema == null) {
return;
}
String attributeName = attribute.getSimpleName();
Map<String, Object> extensions = user.getExtensions();
if (extensions == null) {
extensions = new HashMap<>();
user.setExtensions(extensions);
}
Map<String, Object> subAttributes = (Map<String, Object>) extensions.get(schema);
if (subAttributes == null) {
subAttributes = new HashMap<>();
extensions.put(schema, subAttributes);
user.addSchema(schema);
}
int subSubAttribute = attributeName.indexOf('.');
if (subSubAttribute != -1) {
String parentAttributeName = attributeName.substring(0, subSubAttribute);
subAttributes.put(parentAttributeName, new HashMap<>());
subAttributes = (Map<String, Object>) subAttributes.get(parentAttributeName);
attributeName = attributeName.substring(parentAttributeName.length() + 1);
}
subAttributes.put(attributeName, value);
}).build().get(0);
}
}
@@ -49,7 +49,7 @@ import static org.keycloak.utils.StreamsUtil.closing;
public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<UserModel, User> implements ScimAttributeJpaExpressionResolver {
public UserResourceTypeProvider(KeycloakSession session) {
super(session, new UserCoreModelSchema(session), List.of(new UserEnterpriseModelSchema(session)));
super(session, new UserCoreModelSchema(session), List.of(new UserEnterpriseModelSchema(session), new UserExtensionModelSchema(session)));
}
@Override
@@ -10,6 +10,10 @@
package org.keycloak.tests.scim.tck;
import java.util.Map;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.scim.client.ScimClient;
import org.keycloak.testframework.annotations.InjectAdminEvents;
import org.keycloak.testframework.annotations.InjectRealm;
@@ -17,6 +21,9 @@ import org.keycloak.testframework.events.AdminEvents;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.scim.client.annotations.InjectScimClient;
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE;
import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA;
public abstract class AbstractScimTest {
@InjectRealm(config = ScimRealmConfig.class)
@@ -27,4 +34,25 @@ public abstract class AbstractScimTest {
@InjectAdminEvents
AdminEvents adminEvents;
protected void addEnterpriseUserUserProfileAttributes() {
UPConfig configuration = realm.admin().users().userProfile().getConfiguration();
configuration.addOrReplaceAttribute(new UPAttribute("department", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".department")));
configuration.addOrReplaceAttribute(new UPAttribute("division", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".division")));
configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".costCenter")));
configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber")));
configuration.addOrReplaceAttribute(new UPAttribute("organization", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".organization")));
configuration.addOrReplaceAttribute(new UPAttribute("manager", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.value")));
configuration.addOrReplaceAttribute(new UPAttribute("managerName", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.displayName")));
realm.admin().users().userProfile().update(configuration);
}
}
@@ -706,6 +706,8 @@ public class FilterTest extends AbstractScimTest {
user.setFirstName(givenName);
user.setLastName(familyName);
user = client.users().create(user);
assertNotNull(user);
user = client.users().get(user.getId());
userIdsToRemove.add(user.getId());
return user;
}
@@ -389,7 +389,7 @@ public class GroupTest extends AbstractScimTest {
} catch (ScimClientException e) {
ErrorResponse error = e.getError();
assertNotNull(error);
assertEquals("Managing members on updates are not supported", error.getDetail());
assertEquals("Managing members on updates is not supported", error.getDetail());
}
}
@@ -1,19 +1,27 @@
package org.keycloak.tests.scim.tck;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPAttributePermissions;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.scim.protocol.response.ListResponse;
import org.keycloak.scim.resource.Scim;
import org.keycloak.scim.resource.schema.Schema;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.userprofile.config.UPConfigUtils;
import org.junit.jupiter.api.Test;
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = ScimServerConfig.class)
@@ -21,12 +29,15 @@ public class SchemaTest extends AbstractScimTest {
@Test
public void testGetAllSchemas() {
String customSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User";
addOrReplaceUPAttribute(customSchema, "myattribute");
ListResponse<Schema> response = client.schemas().getAll();
assertNotNull(response);
assertNotNull(response.getResources());
assertEquals(3, response.getTotalResults());
assertEquals(3, response.getResources().size());
assertEquals(4, response.getTotalResults());
assertEquals(4, response.getResources().size());
// Verify all expected schemas are present
List<String> schemaIds = response.getResources().stream()
@@ -36,6 +47,7 @@ public class SchemaTest extends AbstractScimTest {
assertTrue(schemaIds.contains(Scim.USER_CORE_SCHEMA));
assertTrue(schemaIds.contains(Scim.GROUP_CORE_SCHEMA));
assertTrue(schemaIds.contains(Scim.ENTERPRISE_USER_SCHEMA));
assertTrue(schemaIds.contains(customSchema));
}
@Test
@@ -111,6 +123,7 @@ public class SchemaTest extends AbstractScimTest {
@Test
public void testGetEnterpriseUserSchema() {
addEnterpriseUserUserProfileAttributes();
Schema schema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
assertNotNull(schema);
@@ -146,6 +159,37 @@ public class SchemaTest extends AbstractScimTest {
}
}
@Test
public void testGetCustomSchema() {
String fooSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User";
addOrReplaceUPAttribute(fooSchema, "myattribute");
addOrReplaceUPAttribute(fooSchema, "team");
String barSchema = "urn:my:params:scim:schemas:extension:other:1.0:User";
addOrReplaceUPAttribute(barSchema, "other");
Schema schema = client.schemas().get(fooSchema);
assertNotNull(schema);
assertEquals(fooSchema, schema.getId());
assertEquals(fooSchema, schema.getName());
assertNull(schema.getDescription());
assertNotNull(schema.getAttributes());
Set<String> attributeNames = schema.getAttributes().stream()
.map(Schema.Attribute::getName)
.collect(Collectors.toSet());
assertEquals(2, attributeNames.size());
assertAttribute(findAttribute(schema, "myattribute"), "string", false, false, true, "readWrite", "none");
assertAttribute(findAttribute(schema, "team"), "string", false, false, true, "readWrite", "none");
schema = client.schemas().get(barSchema);
attributeNames = schema.getAttributes().stream()
.map(Schema.Attribute::getName)
.collect(Collectors.toSet());
assertEquals(1, attributeNames.size());
// Simple string attributes
assertAttribute(findAttribute(schema, "other"), "string", false, false, true, "readWrite", "none");
}
@Test
public void testNoDuplicateAttributes() {
// Verify that attributes are not duplicated even when multiple SCIM paths
@@ -203,4 +247,12 @@ public class SchemaTest extends AbstractScimTest {
assertEquals(multiValued, subAttribute.getMultiValued(), subAttribute.getName() + " sub-attribute multiValued");
assertEquals(mutability, subAttribute.getMutability(), subAttribute.getName() + " sub-attribute mutability");
}
private void addOrReplaceUPAttribute(String customSchema, String name) {
UPConfig upConfig = realm.admin().users().userProfile().getConfiguration();
UPAttribute upAttribute = new UPAttribute("scim" + name, Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, customSchema + "." + name));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
}
}
@@ -1,6 +1,7 @@
package org.keycloak.tests.scim.tck;
import java.time.Instant;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
@@ -18,8 +19,10 @@ 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;
import org.keycloak.representations.userprofile.config.UPAttributePermissions;
import org.keycloak.representations.userprofile.config.UPAttributeRequired;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.scim.client.ResourceFilter;
import org.keycloak.scim.client.ScimClient;
import org.keycloak.scim.client.ScimClientException;
import org.keycloak.scim.protocol.request.PatchRequest;
@@ -39,13 +42,17 @@ import org.keycloak.testframework.realm.GroupBuilder;
import org.keycloak.testframework.realm.UserBuilder;
import org.keycloak.testframework.scim.client.annotations.InjectScimClient;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.userprofile.config.UPConfigUtils;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import static java.util.Optional.ofNullable;
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE;
import static org.keycloak.scim.model.user.UserExtensionModelSchema.KEYCLOAK_USER_SCHEMA;
import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA;
import static org.keycloak.scim.resource.Scim.USER_RESOURCE_TYPE;
import static org.keycloak.scim.resource.Scim.getCoreSchema;
@@ -53,6 +60,7 @@ import static org.keycloak.scim.resource.Scim.getCoreSchema;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -1079,6 +1087,186 @@ public class UserTest extends AbstractScimTest {
assertNull(actual.getEnterpriseUser());
}
@Test
public void testCreateCustomAttribute() {
UPConfig upConfig = realm.admin().users().userProfile().getConfiguration();
String fooSchema = "urn:my:params:scim:schemas:extension:foo:1.0:User";
UPAttribute upAttribute = new UPAttribute("keycloak.team", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, fooSchema + ".memberOf"));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
String barSchema = "urn:my:params:scim:schemas:extension:bar:1.0:User";
upAttribute = new UPAttribute("keycloak.area", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, barSchema + ".myattribute"));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
User user = new User();
user.addSchema(fooSchema);
user.addSchema(barSchema);
user.setUserName(KeycloakModelUtils.generateId());
user.setExtensions(new HashMap<>());
HashMap<Object, Object> keycloakSchema = new HashMap<>();
keycloakSchema.put("memberOf", "core-iam");
user.getExtensions().put(fooSchema, keycloakSchema);
HashMap<Object, Object> customSchemaValues = new HashMap<>();
customSchemaValues.put("myattribute", "myvalue");
user.getExtensions().put(barSchema, customSchemaValues);
try {
user = client.users().create(user);
user = client.users().get(user.getId());
Object value = ofNullable(user.getExtensions()).orElse(Map.of()).get(fooSchema);
assertInstanceOf(Map.class, value);
assertTrue(user.getSchemas().contains(fooSchema));
assertEquals("core-iam", ((Map<?, ?>) value).get("memberOf"));
value = ofNullable(user.getExtensions()).orElse(Map.of()).get(barSchema);
assertInstanceOf(Map.class, value);
assertTrue(user.getSchemas().contains(barSchema));
assertEquals("myvalue", ((Map<?, ?>) value).get("myattribute"));
} finally {
client.users().delete(user.getId());
}
}
@Test
public void testGetCustomAttribute() {
UserRepresentation existing = UserBuilder.create()
.username(KeycloakModelUtils.generateId())
.email(KeycloakModelUtils.generateId() + "@keycloak.org")
.firstName("f")
.lastName("l")
.enabled(true)
.build();
try (Response response = realm.admin().users().create(existing)) {
String id = ApiUtil.getCreatedId(response);
existing.setId(id);
}
// adds a user profile attribute
UPConfig upConfig = realm.admin().users().userProfile().getConfiguration();
UPAttribute upAttribute = new UPAttribute("keycloak.team", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, KEYCLOAK_USER_SCHEMA + ".memberOf"));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
existing = realm.admin().users().get(existing.getId()).toRepresentation();
existing.singleAttribute(upAttribute.getName(), "core-iam");
realm.admin().users().get(existing.getId()).update(existing);
String customSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User";
upAttribute = new UPAttribute("keycloak.area", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, customSchema + ".myattribute"));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
existing = realm.admin().users().get(existing.getId()).toRepresentation();
existing.singleAttribute(upAttribute.getName(), "myvalue");
realm.admin().users().get(existing.getId()).update(existing);
existing = realm.admin().users().get(existing.getId()).toRepresentation();
assertNotNull(ofNullable(existing.getAttributes()).orElse(Map.of()).get(upAttribute.getName()));
User user = client.users().get(existing.getId());
Object value = ofNullable(user.getExtensions()).orElse(Map.of()).get(KEYCLOAK_USER_SCHEMA);
assertInstanceOf(Map.class, value);
assertTrue(user.getSchemas().contains(KEYCLOAK_USER_SCHEMA));
assertEquals("core-iam", ((Map<?, ?>) value).get("memberOf"));
value = ofNullable(user.getExtensions()).orElse(Map.of()).get(customSchema);
assertInstanceOf(Map.class, value);
assertTrue(user.getSchemas().contains(customSchema));
assertEquals("myvalue", ((Map<?, ?>) value).get("myattribute"));
}
@Test
public void testSearchCustomAttribute() {
UserRepresentation existing = UserBuilder.create()
.username(KeycloakModelUtils.generateId())
.email(KeycloakModelUtils.generateId() + "@keycloak.org")
.firstName("f")
.lastName("l")
.enabled(true)
.build();
try (Response response = realm.admin().users().create(existing)) {
String id = ApiUtil.getCreatedId(response);
existing.setId(id);
}
// adds a user profile attribute
UPConfig upConfig = realm.admin().users().userProfile().getConfiguration();
UPAttribute upAttribute = new UPAttribute("keycloak.team", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, KEYCLOAK_USER_SCHEMA + ".memberOf"));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
existing = realm.admin().users().get(existing.getId()).toRepresentation();
existing.singleAttribute(upAttribute.getName(), "core-iam");
realm.admin().users().get(existing.getId()).update(existing);
String customSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User";
upAttribute = new UPAttribute("keycloak.area", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, customSchema + ".myattribute"));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
existing = realm.admin().users().get(existing.getId()).toRepresentation();
existing.singleAttribute(upAttribute.getName(), "myvalue");
realm.admin().users().get(existing.getId()).update(existing);
ListResponse<User> result = client.users().search(ResourceFilter.filter().eq(KEYCLOAK_USER_SCHEMA + ".memberOf", "core-iam").build());
assertEquals(1, result.getTotalResults());
result = client.users().search(ResourceFilter.filter().eq(KEYCLOAK_USER_SCHEMA + ".memberOf", "non-existent").build());
assertEquals(0, result.getTotalResults());
result = client.users().search(ResourceFilter.filter().eq(customSchema + ".myattribute", "myvalue").build());
assertEquals(1, result.getTotalResults());
}
@Test
public void testPatchCustomAttribute() {
UPConfig upConfig = realm.admin().users().userProfile().getConfiguration();
UPAttribute upAttribute = new UPAttribute("keycloak.team", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, KEYCLOAK_USER_SCHEMA + ".memberOf"));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
String customSchema = "urn:my:params:scim:schemas:extension:custom:1.0:User";
upAttribute = new UPAttribute("keycloak.area", Map.of(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, customSchema + ".myattribute"));
upAttribute.setPermissions(new UPAttributePermissions(Set.of(UPConfigUtils.ROLE_ADMIN), Set.of(UPConfigUtils.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(upAttribute);
realm.admin().users().userProfile().update(upConfig);
User user = new User();
user.addSchema(KEYCLOAK_USER_SCHEMA);
user.addSchema(customSchema);
user.setUserName(KeycloakModelUtils.generateId());
user.setExtensions(new HashMap<>());
HashMap<Object, Object> keycloakSchema = new HashMap<>();
keycloakSchema.put("memberOf", "core-iam");
user.getExtensions().put(KEYCLOAK_USER_SCHEMA, keycloakSchema);
HashMap<Object, Object> customSchemaValues = new HashMap<>();
customSchemaValues.put("myattribute", "myvalue");
user.getExtensions().put(customSchema, customSchemaValues);
try {
user = client.users().create(user);
client.users().patch(user.getId(), PatchRequest.create()
.replace(KEYCLOAK_USER_SCHEMA + ":memberOf", "core-iam-updated")
.replace(customSchema + ":myattribute", "myvalue-updated")
.build());
user = client.users().get(user.getId());
Object value = ofNullable(user.getExtensions()).orElse(Map.of()).get(KEYCLOAK_USER_SCHEMA);
assertInstanceOf(Map.class, value);
assertTrue(user.getSchemas().contains(KEYCLOAK_USER_SCHEMA));
assertEquals("core-iam-updated", ((Map<?, ?>) value).get("memberOf"));
value = ofNullable(user.getExtensions()).orElse(Map.of()).get(customSchema);
assertInstanceOf(Map.class, value);
assertTrue(user.getSchemas().contains(customSchema));
assertEquals("myvalue-updated", ((Map<?, ?>) value).get("myattribute"));
} finally {
client.users().delete(user.getId());
}
}
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());
@@ -1197,6 +1385,7 @@ public class UserTest extends AbstractScimTest {
return user;
}
@Disabled("Update attribute validation after adding custom attributes")
@Test
public void testCreateWithInvalidAttribute() {
User user = new User() {
@@ -1240,24 +1429,4 @@ public class UserTest extends AbstractScimTest {
assertNotNull(error.getDetail());
}
}
private void addEnterpriseUserUserProfileAttributes() {
UPConfig configuration = realm.admin().users().userProfile().getConfiguration();
configuration.addOrReplaceAttribute(new UPAttribute("department", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".department")));
configuration.addOrReplaceAttribute(new UPAttribute("division", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".division")));
configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".costCenter")));
configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber")));
configuration.addOrReplaceAttribute(new UPAttribute("organization", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".organization")));
configuration.addOrReplaceAttribute(new UPAttribute("manager", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.value")));
configuration.addOrReplaceAttribute(new UPAttribute("managerName", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.displayName")));
realm.admin().users().userProfile().update(configuration);
}
}