mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Support for managing members via group resource type
Closes #46216 Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
@@ -51,6 +51,10 @@ public abstract class AbstractScimResourceClient<R extends ResourceTypeRepresent
|
||||
return get(id, null, null);
|
||||
}
|
||||
|
||||
public R get(String id, List<String> attributes) {
|
||||
return get(id, attributes, null);
|
||||
}
|
||||
|
||||
public R get(String id, List<String> attributes, List<String> excludedAttributes) {
|
||||
requireNonNull(id, "SCIM resource ID must not be null");
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.keycloak.scim.resource.group;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -37,4 +38,17 @@ public class Group extends ResourceTypeRepresentation {
|
||||
public void setMembers(List<Member> members) {
|
||||
this.members = members;
|
||||
}
|
||||
|
||||
public void addMember(String userId) {
|
||||
Member member = new Member();
|
||||
member.setValue(userId);
|
||||
addMember(member);
|
||||
}
|
||||
|
||||
public void addMember(Member member) {
|
||||
if (members == null) {
|
||||
members = new ArrayList<>();
|
||||
}
|
||||
members.add(member);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,9 @@
|
||||
package org.keycloak.scim.resource.group;
|
||||
|
||||
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 Member {
|
||||
|
||||
@JsonProperty("value")
|
||||
private String value;
|
||||
|
||||
@JsonProperty("$ref")
|
||||
private String ref;
|
||||
|
||||
@JsonProperty("type")
|
||||
private String type;
|
||||
|
||||
@JsonProperty("display")
|
||||
private String display;
|
||||
|
||||
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 getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getDisplay() {
|
||||
return display;
|
||||
}
|
||||
|
||||
public void setDisplay(String display) {
|
||||
this.display = display;
|
||||
}
|
||||
public class Member extends MultiValuedAttribute {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.keycloak.scim.resource.schema;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
@@ -104,19 +103,9 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
|
||||
}
|
||||
|
||||
Path path = new Path(this, rawPath);
|
||||
JsonNode value = 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.getValue());
|
||||
setValue(model, entry.getKey(), attrValue, REMOVE);
|
||||
for (Entry<Attribute<M, R>, JsonNode> entry : resolveAttributes(path.getPath(), NullNode.getInstance()).entrySet()) {
|
||||
setValue(model, entry.getKey(), path.getValue(entry.getKey()), REMOVE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
|
||||
|
||||
if (Attribute.RETURNED_REQUEST.equals(returned)) {
|
||||
// No attributes parameter specified - returned: request attributes are not returned by default
|
||||
return true;
|
||||
return !isPresent(schema, requestedAttributes);
|
||||
}
|
||||
|
||||
return isPresent(schema, excludedAttributes);
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package org.keycloak.scim.resource.schema.path;
|
||||
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.keycloak.scim.filter.FilterUtils;
|
||||
import org.keycloak.scim.filter.ScimFilterParser;
|
||||
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;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.NullNode;
|
||||
|
||||
public final class Path {
|
||||
@@ -52,31 +49,12 @@ public final class Path {
|
||||
return path;
|
||||
}
|
||||
|
||||
public JsonNode getValue(JsonNode rawValue) {
|
||||
public JsonNode getValue(Attribute<?, ?> attribute) {
|
||||
if (filter == null) {
|
||||
return rawValue;
|
||||
return NullNode.getInstance();
|
||||
}
|
||||
|
||||
ScimFilterParser.FilterContext filterContext = FilterUtils.parseFilter(filter);
|
||||
Predicate<JsonNode> predicate = new ScimJsonNodeFilterEvaluator().visit(filterContext);
|
||||
|
||||
if (rawValue.isArray()) {
|
||||
ArrayNode matches = JsonNodeFactory.instance.arrayNode();
|
||||
for (JsonNode node : rawValue) {
|
||||
if (node.isObject() && predicate.test(node)) {
|
||||
matches.add(node);
|
||||
}
|
||||
}
|
||||
if (!matches.isEmpty()) {
|
||||
return matches.size() == 1 ? matches.get(0) : matches;
|
||||
}
|
||||
}
|
||||
|
||||
return NullNode.getInstance();
|
||||
return new ScimFilterToJsonNodeConverter(attribute).visit(filterContext);
|
||||
}
|
||||
|
||||
public boolean hasFilter() {
|
||||
return filter != null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package org.keycloak.scim.resource.schema.path;
|
||||
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.scim.filter.ScimFilterParser;
|
||||
import org.keycloak.scim.filter.ScimFilterParserBaseVisitor;
|
||||
import org.keycloak.scim.resource.schema.attribute.Attribute;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.SerializationConfig;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
class ScimFilterToJsonNodeConverter extends ScimFilterParserBaseVisitor<JsonNode> {
|
||||
|
||||
private final Attribute<?, ?> attribute;
|
||||
|
||||
public ScimFilterToJsonNodeConverter(Attribute<?, ?> attribute) {
|
||||
this.attribute = attribute;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode visitFilter(ScimFilterParser.FilterContext ctx) {
|
||||
return visit(ctx.expression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode visitExpression(ScimFilterParser.ExpressionContext ctx) {
|
||||
if (ctx.OR() != null) {
|
||||
JsonNode left = visit(ctx.expression());
|
||||
JsonNode right = visit(ctx.andExpression());
|
||||
ArrayNode array = JsonNodeFactory.instance.arrayNode();
|
||||
flattenIntoArray(array, left);
|
||||
flattenIntoArray(array, right);
|
||||
return array;
|
||||
}
|
||||
return visit(ctx.andExpression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode visitAndExpression(ScimFilterParser.AndExpressionContext ctx) {
|
||||
if (ctx.AND() != null) {
|
||||
JsonNode left = visit(ctx.andExpression());
|
||||
JsonNode right = visit(ctx.notExpression());
|
||||
ObjectNode merged = JsonNodeFactory.instance.objectNode();
|
||||
mergeFields(merged, left);
|
||||
mergeFields(merged, right);
|
||||
return merged;
|
||||
}
|
||||
return visit(ctx.notExpression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode visitNotExpression(ScimFilterParser.NotExpressionContext ctx) {
|
||||
if (ctx.NOT() != null) {
|
||||
throw new IllegalArgumentException("NOT operator is not supported when converting a SCIM filter to a JSON value");
|
||||
}
|
||||
return visit(ctx.atom());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode visitAtom(ScimFilterParser.AtomContext ctx) {
|
||||
if (ctx.valuePath() != null) {
|
||||
return visit(ctx.valuePath());
|
||||
}
|
||||
if (ctx.attributeExpression() != null) {
|
||||
return visit(ctx.attributeExpression());
|
||||
}
|
||||
return visit(ctx.expression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode visitValuePath(ScimFilterParser.ValuePathContext ctx) {
|
||||
return visit(ctx.expression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode visitPresentExpression(ScimFilterParser.PresentExpressionContext ctx) {
|
||||
throw new IllegalArgumentException("Present (pr) operator is not supported when converting a SCIM filter to a JSON value");
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode visitComparisonExpression(ScimFilterParser.ComparisonExpressionContext ctx) {
|
||||
String operator = ctx.compareOp().getText().toLowerCase();
|
||||
|
||||
if (!"eq".equals(operator)) {
|
||||
throw new IllegalArgumentException("Only 'eq' operator is supported when converting a SCIM filter to a JSON value, got: " + operator);
|
||||
}
|
||||
|
||||
Class<?> complexType = attribute.getComplexType();
|
||||
|
||||
if (complexType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String attrName = ctx.ATTRPATH().getText();
|
||||
|
||||
if (!isComplexTypeAttribute(complexType, attrName)) {
|
||||
throw new ModelValidationException("Unknown attribute " + attrName);
|
||||
}
|
||||
|
||||
String value = extractValue(ctx.compValue());
|
||||
ObjectNode node = JsonNodeFactory.instance.objectNode();
|
||||
|
||||
if (value == null) {
|
||||
node.putNull(attrName);
|
||||
} else {
|
||||
node.put(attrName, value);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static boolean isComplexTypeAttribute(Class<?> complexType, String attrName) {
|
||||
JavaType javaType = JsonSerialization.mapper.getTypeFactory().constructType(complexType);
|
||||
SerializationConfig serializationConfig = JsonSerialization.mapper.getSerializationConfig();
|
||||
return serializationConfig.introspect(javaType).findProperties().stream().anyMatch(p -> p.getName().equals(attrName));
|
||||
}
|
||||
|
||||
private String extractValue(ScimFilterParser.CompValueContext ctx) {
|
||||
if (ctx.STRING() != null) {
|
||||
String raw = ctx.STRING().getText();
|
||||
return unescapeJsonString(raw.substring(1, raw.length() - 1));
|
||||
}
|
||||
if (ctx.TRUE() != null) return "true";
|
||||
if (ctx.FALSE() != null) return "false";
|
||||
if (ctx.NULL() != null) return null;
|
||||
if (ctx.NUMBER() != null) return ctx.NUMBER().getText();
|
||||
return null;
|
||||
}
|
||||
|
||||
private String unescapeJsonString(String s) {
|
||||
return s.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\")
|
||||
.replace("\\/", "/")
|
||||
.replace("\\b", "\b")
|
||||
.replace("\\f", "\f")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\r", "\r")
|
||||
.replace("\\t", "\t");
|
||||
}
|
||||
|
||||
private void flattenIntoArray(ArrayNode array, JsonNode node) {
|
||||
if (node.isArray()) {
|
||||
node.forEach(array::add);
|
||||
} else {
|
||||
array.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeFields(ObjectNode target, JsonNode source) {
|
||||
if (source.isObject()) {
|
||||
source.properties().forEach(e -> target.set(e.getKey(), e.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
package org.keycloak.scim.resource.schema.path;
|
||||
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.keycloak.scim.filter.ScimFilterParser;
|
||||
import org.keycloak.scim.filter.ScimFilterParserBaseVisitor;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Visitor that converts a SCIM filter AST into a {@link Predicate} over {@link JsonNode} elements.
|
||||
* <p>
|
||||
* This is used by {@link Path} to evaluate filter expressions (e.g., {@code value eq "some-id"})
|
||||
* against JSON array elements in-memory, supporting all SCIM comparison operators and logical
|
||||
* operators ({@code and}, {@code or}, {@code not}).
|
||||
*/
|
||||
class ScimJsonNodeFilterEvaluator extends ScimFilterParserBaseVisitor<Predicate<JsonNode>> {
|
||||
|
||||
@Override
|
||||
public Predicate<JsonNode> visitFilter(ScimFilterParser.FilterContext ctx) {
|
||||
return visit(ctx.expression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate<JsonNode> visitExpression(ScimFilterParser.ExpressionContext ctx) {
|
||||
if (ctx.OR() != null) {
|
||||
Predicate<JsonNode> left = visit(ctx.expression());
|
||||
Predicate<JsonNode> right = visit(ctx.andExpression());
|
||||
return left.or(right);
|
||||
}
|
||||
return visit(ctx.andExpression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate<JsonNode> visitAndExpression(ScimFilterParser.AndExpressionContext ctx) {
|
||||
if (ctx.AND() != null) {
|
||||
Predicate<JsonNode> left = visit(ctx.andExpression());
|
||||
Predicate<JsonNode> right = visit(ctx.notExpression());
|
||||
return left.and(right);
|
||||
}
|
||||
return visit(ctx.notExpression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate<JsonNode> visitNotExpression(ScimFilterParser.NotExpressionContext ctx) {
|
||||
if (ctx.NOT() != null) {
|
||||
Predicate<JsonNode> child = visit(ctx.notExpression());
|
||||
return child.negate();
|
||||
}
|
||||
return visit(ctx.atom());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate<JsonNode> visitAtom(ScimFilterParser.AtomContext ctx) {
|
||||
if (ctx.valuePath() != null) {
|
||||
return visit(ctx.valuePath());
|
||||
}
|
||||
if (ctx.attributeExpression() != null) {
|
||||
return visit(ctx.attributeExpression());
|
||||
}
|
||||
return visit(ctx.expression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate<JsonNode> visitValuePath(ScimFilterParser.ValuePathContext ctx) {
|
||||
return visit(ctx.expression());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate<JsonNode> visitPresentExpression(ScimFilterParser.PresentExpressionContext ctx) {
|
||||
String attrName = ctx.ATTRPATH().getText();
|
||||
return node -> {
|
||||
if (!node.isObject()) return false;
|
||||
JsonNode value = node.get(attrName);
|
||||
return value != null && !value.isNull() && !value.isMissingNode();
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate<JsonNode> visitComparisonExpression(ScimFilterParser.ComparisonExpressionContext ctx) {
|
||||
String attrName = ctx.ATTRPATH().getText();
|
||||
String operator = ctx.compareOp().getText().toLowerCase();
|
||||
String compValue = extractValue(ctx.compValue());
|
||||
|
||||
return node -> {
|
||||
if (!node.isObject()) return false;
|
||||
JsonNode attrNode = node.get(attrName);
|
||||
if (attrNode == null || attrNode.isNull()) {
|
||||
return "eq".equals(operator) && compValue == null;
|
||||
}
|
||||
return compare(attrNode.asText(), operator, compValue);
|
||||
};
|
||||
}
|
||||
|
||||
private boolean compare(String nodeValue, String operator, String compValue) {
|
||||
if (compValue == null || nodeValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return switch (operator) {
|
||||
case "eq" -> nodeValue.equals(compValue);
|
||||
case "ne" -> !nodeValue.equals(compValue);
|
||||
case "co" -> nodeValue.contains(compValue);
|
||||
case "sw" -> nodeValue.startsWith(compValue);
|
||||
case "ew" -> nodeValue.endsWith(compValue);
|
||||
case "gt" -> nodeValue.compareTo(compValue) > 0;
|
||||
case "ge" -> nodeValue.compareTo(compValue) >= 0;
|
||||
case "lt" -> nodeValue.compareTo(compValue) < 0;
|
||||
case "le" -> nodeValue.compareTo(compValue) <= 0;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private String extractValue(ScimFilterParser.CompValueContext ctx) {
|
||||
if (ctx.STRING() != null) {
|
||||
String raw = ctx.STRING().getText();
|
||||
return unescapeJsonString(raw.substring(1, raw.length() - 1));
|
||||
}
|
||||
if (ctx.TRUE() != null) return "true";
|
||||
if (ctx.FALSE() != null) return "false";
|
||||
if (ctx.NULL() != null) return null;
|
||||
if (ctx.NUMBER() != null) return ctx.NUMBER().getText();
|
||||
return null;
|
||||
}
|
||||
|
||||
private String unescapeJsonString(String s) {
|
||||
return s.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\")
|
||||
.replace("\\/", "/")
|
||||
.replace("\\b", "\b")
|
||||
.replace("\\f", "\f")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\r", "\r")
|
||||
.replace("\\t", "\t");
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
package org.keycloak.scim.model.group;
|
||||
|
||||
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.authorization.fgap.AdminPermissionsSchema;
|
||||
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.Permissions;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.scim.protocol.ForbiddenException;
|
||||
import org.keycloak.scim.resource.group.Group;
|
||||
import org.keycloak.scim.resource.group.Member;
|
||||
import org.keycloak.scim.resource.schema.AbstractModelSchema;
|
||||
import org.keycloak.scim.resource.schema.attribute.Attribute;
|
||||
import org.keycloak.utils.KeycloakSessionUtil;
|
||||
|
||||
public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel, Group> {
|
||||
|
||||
@@ -35,14 +48,19 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
|
||||
|
||||
@Override
|
||||
protected Set<String> getModelAttributeNames() {
|
||||
return Set.of("name", "externalId");
|
||||
return Set.of("name", "externalId", "members");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getAttributeValue(GroupModel model, String name) {
|
||||
protected Object getAttributeValue(GroupModel model, String name) {
|
||||
return switch (name) {
|
||||
case "name" -> model.getName();
|
||||
case "externalId" -> model.getFirstAttribute("externalId");
|
||||
case "members" -> {
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
yield session.users().getGroupMembersStream(realm, model).toList();
|
||||
}
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
@@ -52,6 +70,7 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
|
||||
return switch (name) {
|
||||
case "name" -> "displayName";
|
||||
case "externalId" -> name;
|
||||
case "members" -> "members";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
@@ -82,6 +101,53 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
|
||||
.timestamp()
|
||||
.modelAttributeResolver(attribute -> "lastModifiedTimestamp")
|
||||
.build());
|
||||
attributes.addAll(Attribute.<GroupModel, Group>complex("members", Member.class)
|
||||
.multivalued()
|
||||
.returned(Attribute.RETURNED_REQUEST)
|
||||
.modelAttributeResolver(Attribute::getName)
|
||||
.withModelSetter((TriConsumer<GroupModel, String, Set<Member>>) (model, name, values) -> {
|
||||
if (!Optional.ofNullable(values).orElse(Set.of()).isEmpty()) {
|
||||
// managing members on updates are not supported, client should use PATCH
|
||||
throw new ModelValidationException("Managing members on updates are not supported");
|
||||
}
|
||||
}, (BiConsumer<Group, Collection<UserModel>>) (group, users) -> {
|
||||
for (UserModel user : Optional.ofNullable(users).orElse(List.of())) {
|
||||
Member member = new Member();
|
||||
member.setValue(user.getId());
|
||||
member.setDisplay(user.getUsername());
|
||||
member.setType("User");
|
||||
group.addMember(member);
|
||||
}
|
||||
})
|
||||
.withModelRemover((TriConsumer<GroupModel, String, Set<Member>>) (model, name, values) -> {
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
checkGroupMembershipPermission(session.getContext().getPermissions(), model);
|
||||
|
||||
for (Member member : values) {
|
||||
UserModel user = session.users().getUserById(realm, member.getValue());
|
||||
if (user == null) {
|
||||
throw new ModelValidationException("User with id " + member.getValue() + " not found");
|
||||
}
|
||||
checkRequireManageGroupMembership(session.getContext().getPermissions(), user);
|
||||
user.leaveGroup(model);
|
||||
}
|
||||
})
|
||||
.withModelAdder((TriConsumer<GroupModel, String, Set<Member>>) (model, name, values) -> {
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
checkGroupMembershipPermission(session.getContext().getPermissions(), model);
|
||||
|
||||
for (Member member : values) {
|
||||
UserModel user = session.users().getUserById(realm, member.getValue());
|
||||
if (user == null) {
|
||||
throw new ModelValidationException("User with id " + member.getValue() + " not found");
|
||||
}
|
||||
checkRequireManageGroupMembership(session.getContext().getPermissions(), user);
|
||||
user.joinGroup(model);
|
||||
}
|
||||
})
|
||||
.build());
|
||||
return attributes.stream().collect(Collectors.toMap(Attribute::getName, Function.identity()));
|
||||
}
|
||||
|
||||
@@ -107,4 +173,19 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
|
||||
resource.setLastModifiedTimestamp(lastModified);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkGroupMembershipPermission(Permissions permissions, GroupModel group) {
|
||||
if (GroupModel.Type.ORGANIZATION.equals(group.getType()) && group.getOrganization() != null) {
|
||||
throw new ModelValidationException("Cannot access organization related group via non Organization API.");
|
||||
}
|
||||
if (!permissions.hasPermission(group, AdminPermissionsSchema.GROUPS_RESOURCE_TYPE, AdminPermissionsSchema.MANAGE_MEMBERSHIP)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
private void checkRequireManageGroupMembership(Permissions permissions, UserModel model) {
|
||||
if (!permissions.hasPermission(model, AdminPermissionsSchema.USERS_RESOURCE_TYPE, AdminPermissionsSchema.MANAGE_GROUP_MEMBERSHIP)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -12,11 +12,16 @@ package org.keycloak.scim.model.group;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Expression;
|
||||
import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
@@ -25,21 +30,26 @@ import org.keycloak.common.util.Time;
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.GroupModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.jpa.GroupAdapter;
|
||||
import org.keycloak.models.jpa.entities.GroupEntity;
|
||||
import org.keycloak.models.jpa.entities.UserGroupMembershipEntity;
|
||||
import org.keycloak.scim.filter.FilterUtils;
|
||||
import org.keycloak.scim.filter.ScimFilterParser;
|
||||
import org.keycloak.scim.model.filter.ScimAttributeJpaExpressionResolver;
|
||||
import org.keycloak.scim.model.filter.ScimJPAPredicateEvaluator;
|
||||
import org.keycloak.scim.protocol.request.SearchRequest;
|
||||
import org.keycloak.scim.resource.group.Group;
|
||||
import org.keycloak.scim.resource.group.Member;
|
||||
import org.keycloak.scim.resource.schema.attribute.Attribute;
|
||||
import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
||||
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
|
||||
import static org.keycloak.utils.StreamsUtil.closing;
|
||||
|
||||
public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<GroupModel, Group> {
|
||||
public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<GroupModel, Group> implements ScimAttributeJpaExpressionResolver {
|
||||
|
||||
public GroupResourceTypeProvider(KeycloakSession session) {
|
||||
super(session, new GroupCoreModelSchema());
|
||||
@@ -55,6 +65,17 @@ public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<
|
||||
return group;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Group update(Group resource) {
|
||||
List<Member> members = resource.getMembers();
|
||||
|
||||
if (!Optional.ofNullable(members).orElse(List.of()).isEmpty()) {
|
||||
throw new ModelValidationException("Managing members on updates are not supported");
|
||||
}
|
||||
|
||||
return super.update(resource);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Group onUpdate(GroupModel model, Group resource) {
|
||||
model.setLastModifiedTimestamp(Time.currentTimeMillis());
|
||||
@@ -157,4 +178,14 @@ public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<
|
||||
|
||||
return predicates;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Expression<?> getAttributeExpression(Attribute<?, ?> attribute, CriteriaBuilder cb, Root<?> root, BiFunction<Class<?>, Supplier<Join<?, ?>>, Join<?, ?>> joinResolver) {
|
||||
if ("members".equals(attribute.getName())) {
|
||||
Join<?, ?> join = joinResolver.apply(UserGroupMembershipEntity.class, () -> root.join(UserGroupMembershipEntity.class));
|
||||
join.on(cb.equal(root.get("id"), join.get("groupId")));
|
||||
return join.get("user").get("id");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +144,9 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema {
|
||||
.modelAttributeResolver(attribute -> "lastModifiedTimestamp")
|
||||
.build());
|
||||
attributes.addAll(Attribute.<UserModel, User>complex("groups", GroupMembership.class)
|
||||
.modelAttributeResolver(Attribute::getName)
|
||||
.multivalued()
|
||||
.returned(Attribute.RETURNED_REQUEST)
|
||||
.modelAttributeResolver(Attribute::getName)
|
||||
.withModelSetter((TriConsumer<UserModel, String, Set<GroupMembership>>) (model, name, values) -> {
|
||||
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
@KeycloakIntegrationTest(config = ScimServerConfig.class)
|
||||
@@ -307,6 +308,274 @@ public class AuthorizationTest extends AbstractScimTest {
|
||||
assertAccessDenied(() -> noAccessClient.users().update(finalUser.getId(), finalUser));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersAddDeniedWithoutManageUsersRole() {
|
||||
// Grant view-users (enough to see groups, but not to manage)
|
||||
grantAdminRole(AdminRoles.VIEW_USERS);
|
||||
GroupRepresentation group = createGroup();
|
||||
|
||||
// PATCH add member on group should be denied (no manage permission on groups)
|
||||
assertAccessDenied(() -> noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", managedUser.getId())
|
||||
.build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersRemoveDeniedWithoutManageUsersRole() {
|
||||
// Grant view-users (enough to see groups, but not to manage)
|
||||
grantAdminRole(AdminRoles.VIEW_USERS);
|
||||
GroupRepresentation group = createGroup();
|
||||
|
||||
// Add the user to the group via admin API so we have something to remove
|
||||
managedUser.admin().joinGroup(group.getId());
|
||||
|
||||
// PATCH remove member on group should be denied (no manage permission on groups)
|
||||
assertAccessDenied(() -> noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.remove("members[value eq \"" + managedUser.getId() + "\"]")
|
||||
.build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersAccessWithManageUsersRole() {
|
||||
grantAdminRole(AdminRoles.MANAGE_USERS);
|
||||
GroupRepresentation group = createGroup();
|
||||
|
||||
// PATCH add member should succeed with manage-users role
|
||||
noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", managedUser.getId())
|
||||
.build());
|
||||
|
||||
// Verify member was added
|
||||
Group fetched = noAccessClient.groups().get(group.getId(), List.of("members"));
|
||||
assertNotNull(fetched.getMembers());
|
||||
assertEquals(1, fetched.getMembers().size());
|
||||
|
||||
// PATCH remove member should succeed with manage-users role
|
||||
noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.remove("members[value eq \"" + managedUser.getId() + "\"]")
|
||||
.build());
|
||||
|
||||
// Verify member was removed
|
||||
fetched = noAccessClient.groups().get(group.getId(), List.of("members"));
|
||||
assertTrue(fetched.getMembers() == null || fetched.getMembers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersAddDeniedWithoutManageMembershipFGAP() {
|
||||
RealmRepresentation realmRep = this.realm.admin().toRepresentation();
|
||||
realmRep.setAdminPermissionsEnabled(true);
|
||||
realm.admin().update(realmRep);
|
||||
|
||||
GroupRepresentation group = createGroup();
|
||||
ClientRepresentation client = getScimClient();
|
||||
UserRepresentation serviceAccount = realm.admin().clients().get(client.getId()).getServiceAccountUser();
|
||||
|
||||
// Create policy for the SCIM service account
|
||||
UserPolicyRepresentation policy = new UserPolicyRepresentation();
|
||||
policy.setName("Allow SCIM access");
|
||||
policy.addUser(serviceAccount.getId());
|
||||
ClientResource permissionClient = realm.admin().clients().get(
|
||||
realm.admin().clients().findByClientId(Constants.ADMIN_PERMISSIONS_CLIENT_ID).get(0).getId());
|
||||
permissionClient.authorization().policies().user().create(policy).close();
|
||||
|
||||
// Grant manage on the group (allows PATCH on non-member attributes)
|
||||
// but NOT manage-membership (required to manage group members)
|
||||
ScopePermissionRepresentation groupPermission = new ScopePermissionRepresentation();
|
||||
groupPermission.setName("Allow SCIM manage group");
|
||||
groupPermission.setResourceType(AdminPermissionsSchema.GROUPS_RESOURCE_TYPE);
|
||||
groupPermission.setResources(Set.of(group.getId()));
|
||||
groupPermission.setScopes(Set.of(AdminPermissionsSchema.MANAGE, AdminPermissionsSchema.VIEW));
|
||||
groupPermission.addPolicy(policy.getName());
|
||||
permissionClient.authorization().permissions().scope().create(groupPermission).close();
|
||||
|
||||
// Verify the client CAN patch non-member attributes (e.g., displayName)
|
||||
noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("displayName", "updated name")
|
||||
.build());
|
||||
|
||||
// Verify the client CANNOT add members without manage-membership scope
|
||||
assertAccessDenied(() -> noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", managedUser.getId())
|
||||
.build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersRemoveDeniedWithoutManageMembershipFGAP() {
|
||||
RealmRepresentation realmRep = this.realm.admin().toRepresentation();
|
||||
realmRep.setAdminPermissionsEnabled(true);
|
||||
realm.admin().update(realmRep);
|
||||
|
||||
GroupRepresentation group = createGroup();
|
||||
// Add the user to the group via admin API
|
||||
managedUser.admin().joinGroup(group.getId());
|
||||
|
||||
ClientRepresentation client = getScimClient();
|
||||
UserRepresentation serviceAccount = realm.admin().clients().get(client.getId()).getServiceAccountUser();
|
||||
|
||||
// Create policy for the SCIM service account
|
||||
UserPolicyRepresentation policy = new UserPolicyRepresentation();
|
||||
policy.setName("Allow SCIM access");
|
||||
policy.addUser(serviceAccount.getId());
|
||||
ClientResource permissionClient = realm.admin().clients().get(
|
||||
realm.admin().clients().findByClientId(Constants.ADMIN_PERMISSIONS_CLIENT_ID).get(0).getId());
|
||||
permissionClient.authorization().policies().user().create(policy).close();
|
||||
|
||||
// Grant manage on the group but NOT manage-membership
|
||||
ScopePermissionRepresentation groupPermission = new ScopePermissionRepresentation();
|
||||
groupPermission.setName("Allow SCIM manage group");
|
||||
groupPermission.setResourceType(AdminPermissionsSchema.GROUPS_RESOURCE_TYPE);
|
||||
groupPermission.setResources(Set.of(group.getId()));
|
||||
groupPermission.setScopes(Set.of(AdminPermissionsSchema.MANAGE, AdminPermissionsSchema.VIEW));
|
||||
groupPermission.addPolicy(policy.getName());
|
||||
permissionClient.authorization().permissions().scope().create(groupPermission).close();
|
||||
|
||||
// Verify the client CANNOT remove members without manage-membership scope
|
||||
assertAccessDenied(() -> noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.remove("members[value eq \"" + managedUser.getId() + "\"]")
|
||||
.build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersRemoveDeniedWithoutManageGroupMembershipOnUserFGAP() {
|
||||
RealmRepresentation realmRep = this.realm.admin().toRepresentation();
|
||||
realmRep.setAdminPermissionsEnabled(true);
|
||||
realm.admin().update(realmRep);
|
||||
|
||||
GroupRepresentation group = createGroup();
|
||||
// Add the user to the group via admin API
|
||||
managedUser.admin().joinGroup(group.getId());
|
||||
|
||||
ClientRepresentation client = getScimClient();
|
||||
UserRepresentation serviceAccount = realm.admin().clients().get(client.getId()).getServiceAccountUser();
|
||||
|
||||
// Create policy for the SCIM service account
|
||||
UserPolicyRepresentation policy = new UserPolicyRepresentation();
|
||||
policy.setName("Allow SCIM access");
|
||||
policy.addUser(serviceAccount.getId());
|
||||
ClientResource permissionClient = realm.admin().clients().get(
|
||||
realm.admin().clients().findByClientId(Constants.ADMIN_PERMISSIONS_CLIENT_ID).get(0).getId());
|
||||
permissionClient.authorization().policies().user().create(policy).close();
|
||||
|
||||
// Grant manage + manage-membership on the group
|
||||
ScopePermissionRepresentation groupPermission = new ScopePermissionRepresentation();
|
||||
groupPermission.setName("Allow SCIM manage group with membership");
|
||||
groupPermission.setResourceType(AdminPermissionsSchema.GROUPS_RESOURCE_TYPE);
|
||||
groupPermission.setResources(Set.of(group.getId()));
|
||||
groupPermission.setScopes(Set.of(AdminPermissionsSchema.MANAGE, AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE_MEMBERSHIP));
|
||||
groupPermission.addPolicy(policy.getName());
|
||||
permissionClient.authorization().permissions().scope().create(groupPermission).close();
|
||||
|
||||
// Grant view on users but NOT manage-group-membership
|
||||
ScopePermissionRepresentation userPermission = new ScopePermissionRepresentation();
|
||||
userPermission.setName("Allow SCIM view users");
|
||||
userPermission.setResourceType(AdminPermissionsSchema.USERS_RESOURCE_TYPE);
|
||||
userPermission.setScopes(Set.of(AdminPermissionsSchema.VIEW));
|
||||
userPermission.addPolicy(policy.getName());
|
||||
permissionClient.authorization().permissions().scope().create(userPermission).close();
|
||||
|
||||
// Verify the client CANNOT remove members without manage-group-membership scope on the user
|
||||
assertAccessDenied(() -> noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.remove("members[value eq \"" + managedUser.getId() + "\"]")
|
||||
.build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersAllowedWithAllFGAPScopes() {
|
||||
RealmRepresentation realmRep = this.realm.admin().toRepresentation();
|
||||
realmRep.setAdminPermissionsEnabled(true);
|
||||
realm.admin().update(realmRep);
|
||||
|
||||
GroupRepresentation group = createGroup();
|
||||
ClientRepresentation client = getScimClient();
|
||||
UserRepresentation serviceAccount = realm.admin().clients().get(client.getId()).getServiceAccountUser();
|
||||
|
||||
// Create policy for the SCIM service account
|
||||
UserPolicyRepresentation policy = new UserPolicyRepresentation();
|
||||
policy.setName("Allow SCIM access");
|
||||
policy.addUser(serviceAccount.getId());
|
||||
ClientResource permissionClient = realm.admin().clients().get(
|
||||
realm.admin().clients().findByClientId(Constants.ADMIN_PERMISSIONS_CLIENT_ID).get(0).getId());
|
||||
permissionClient.authorization().policies().user().create(policy).close();
|
||||
|
||||
// Grant manage + manage-membership on the group
|
||||
ScopePermissionRepresentation groupPermission = new ScopePermissionRepresentation();
|
||||
groupPermission.setName("Allow SCIM full group access");
|
||||
groupPermission.setResourceType(AdminPermissionsSchema.GROUPS_RESOURCE_TYPE);
|
||||
groupPermission.setResources(Set.of(group.getId()));
|
||||
groupPermission.setScopes(Set.of(AdminPermissionsSchema.MANAGE, AdminPermissionsSchema.VIEW,
|
||||
AdminPermissionsSchema.MANAGE_MEMBERSHIP, AdminPermissionsSchema.VIEW_MEMBERS));
|
||||
groupPermission.addPolicy(policy.getName());
|
||||
permissionClient.authorization().permissions().scope().create(groupPermission).close();
|
||||
|
||||
// Grant manage-group-membership on users
|
||||
ScopePermissionRepresentation userPermission = new ScopePermissionRepresentation();
|
||||
userPermission.setName("Allow SCIM manage user group membership");
|
||||
userPermission.setResourceType(AdminPermissionsSchema.USERS_RESOURCE_TYPE);
|
||||
userPermission.setScopes(Set.of(AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE_GROUP_MEMBERSHIP));
|
||||
userPermission.addPolicy(policy.getName());
|
||||
permissionClient.authorization().permissions().scope().create(userPermission).close();
|
||||
|
||||
// PATCH add member should succeed with all required FGAP scopes
|
||||
noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", managedUser.getId())
|
||||
.build());
|
||||
|
||||
// Verify member was added
|
||||
Group fetched = noAccessClient.groups().get(group.getId(), List.of("members"));
|
||||
assertNotNull(fetched.getMembers());
|
||||
assertEquals(1, fetched.getMembers().size());
|
||||
|
||||
// PATCH remove member should succeed with all required FGAP scopes
|
||||
noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.remove("members[value eq \"" + managedUser.getId() + "\"]")
|
||||
.build());
|
||||
|
||||
// Verify member was removed
|
||||
fetched = noAccessClient.groups().get(group.getId(), List.of("members"));
|
||||
assertTrue(fetched.getMembers() == null || fetched.getMembers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersAddDeniedWithoutManageGroupMembershipOnUserFGAP() {
|
||||
RealmRepresentation realmRep = this.realm.admin().toRepresentation();
|
||||
realmRep.setAdminPermissionsEnabled(true);
|
||||
realm.admin().update(realmRep);
|
||||
|
||||
GroupRepresentation group = createGroup();
|
||||
ClientRepresentation client = getScimClient();
|
||||
UserRepresentation serviceAccount = realm.admin().clients().get(client.getId()).getServiceAccountUser();
|
||||
|
||||
// Create policy for the SCIM service account
|
||||
UserPolicyRepresentation policy = new UserPolicyRepresentation();
|
||||
policy.setName("Allow SCIM access");
|
||||
policy.addUser(serviceAccount.getId());
|
||||
ClientResource permissionClient = realm.admin().clients().get(
|
||||
realm.admin().clients().findByClientId(Constants.ADMIN_PERMISSIONS_CLIENT_ID).get(0).getId());
|
||||
permissionClient.authorization().policies().user().create(policy).close();
|
||||
|
||||
// Grant manage + manage-membership on the group
|
||||
ScopePermissionRepresentation groupPermission = new ScopePermissionRepresentation();
|
||||
groupPermission.setName("Allow SCIM manage group with membership");
|
||||
groupPermission.setResourceType(AdminPermissionsSchema.GROUPS_RESOURCE_TYPE);
|
||||
groupPermission.setResources(Set.of(group.getId()));
|
||||
groupPermission.setScopes(Set.of(AdminPermissionsSchema.MANAGE, AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE_MEMBERSHIP));
|
||||
groupPermission.addPolicy(policy.getName());
|
||||
permissionClient.authorization().permissions().scope().create(groupPermission).close();
|
||||
|
||||
// Grant view on users but NOT manage-group-membership
|
||||
ScopePermissionRepresentation userPermission = new ScopePermissionRepresentation();
|
||||
userPermission.setName("Allow SCIM view users");
|
||||
userPermission.setResourceType(AdminPermissionsSchema.USERS_RESOURCE_TYPE);
|
||||
userPermission.setScopes(Set.of(AdminPermissionsSchema.VIEW));
|
||||
userPermission.addPolicy(policy.getName());
|
||||
permissionClient.authorization().permissions().scope().create(userPermission).close();
|
||||
|
||||
// Verify the client CANNOT add members without manage-group-membership scope on the user
|
||||
assertAccessDenied(() -> noAccessClient.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", managedUser.getId())
|
||||
.build()));
|
||||
}
|
||||
|
||||
private ClientRepresentation getScimClient() {
|
||||
return realm.admin().clients().findByClientId("scim-client-restricted").get(0);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,26 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import org.keycloak.admin.client.resource.OrganizationResource;
|
||||
import org.keycloak.events.admin.OperationType;
|
||||
import org.keycloak.events.admin.ResourceType;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.representations.idm.GroupRepresentation;
|
||||
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||
import org.keycloak.scim.client.ResourceFilter;
|
||||
import org.keycloak.scim.client.ScimClientException;
|
||||
import org.keycloak.scim.protocol.request.PatchRequest;
|
||||
import org.keycloak.scim.protocol.response.ErrorResponse;
|
||||
import org.keycloak.scim.protocol.response.ListResponse;
|
||||
import org.keycloak.scim.resource.group.Group;
|
||||
import org.keycloak.scim.resource.group.Member;
|
||||
import org.keycloak.scim.resource.user.User;
|
||||
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
|
||||
import org.keycloak.testframework.events.AdminEventAssertion;
|
||||
import org.keycloak.testframework.util.ApiUtil;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -26,6 +34,7 @@ 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;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
@KeycloakIntegrationTest(config = ScimServerConfig.class)
|
||||
@@ -234,4 +243,209 @@ public class GroupTest extends AbstractScimTest {
|
||||
assertNotNull(error.getDetail());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembers() {
|
||||
// create users via SCIM
|
||||
User userA = createScimUser();
|
||||
User userB = createScimUser();
|
||||
User userC = createScimUser();
|
||||
|
||||
// create a group via SCIM
|
||||
Group group = new Group();
|
||||
group.setDisplayName(KeycloakModelUtils.generateId());
|
||||
group = client.groups().create(group);
|
||||
adminEvents.clear();
|
||||
|
||||
// initially no members
|
||||
Group fetched = client.groups().get(group.getId());
|
||||
assertNotNull(fetched);
|
||||
assertTrue(fetched.getMembers() == null || fetched.getMembers().isEmpty());
|
||||
|
||||
// PATCH add two members
|
||||
client.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", userA.getId())
|
||||
.add("members", userB.getId())
|
||||
.build());
|
||||
fetched = client.groups().get(group.getId(), List.of("members"), null);
|
||||
assertNotNull(fetched.getMembers());
|
||||
assertEquals(2, fetched.getMembers().size());
|
||||
assertMember(fetched.getMembers(), userA.getId(), userA.getUserName());
|
||||
assertMember(fetched.getMembers(), userB.getId(), userB.getUserName());
|
||||
|
||||
// PATCH add a third member
|
||||
client.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", userC.getId())
|
||||
.build());
|
||||
fetched = client.groups().get(group.getId(), List.of("members"), null);
|
||||
assertNotNull(fetched.getMembers());
|
||||
assertEquals(3, fetched.getMembers().size());
|
||||
assertMember(fetched.getMembers(), userC.getId(), userC.getUserName());
|
||||
|
||||
// PATCH remove one member using value path filter
|
||||
client.groups().patch(group.getId(), PatchRequest.create()
|
||||
.remove("members[value eq \"" + userB.getId() + "\"]")
|
||||
.build());
|
||||
fetched = client.groups().get(group.getId(), List.of("members"), null);
|
||||
assertNotNull(fetched.getMembers());
|
||||
assertEquals(2, fetched.getMembers().size());
|
||||
assertMember(fetched.getMembers(), userA.getId(), userA.getUserName());
|
||||
assertMember(fetched.getMembers(), userC.getId(), userC.getUserName());
|
||||
|
||||
// capture groupId for use in lambdas
|
||||
String groupId = group.getId();
|
||||
|
||||
// verify user's groups reflect the membership (bi-directional)
|
||||
User fetchedUserA = client.users().get(userA.getId(), List.of("groups"));
|
||||
assertNotNull(fetchedUserA.getGroups());
|
||||
assertTrue(fetchedUserA.getGroups().stream().anyMatch(g -> g.getValue().equals(groupId)));
|
||||
|
||||
User fetchedUserB = client.users().get(userB.getId(), List.of("groups"));
|
||||
assertTrue(fetchedUserB.getGroups() == null || fetchedUserB.getGroups().stream().noneMatch(g -> g.getValue().equals(groupId)));
|
||||
|
||||
// filter groups by members.value
|
||||
String filter = ResourceFilter.filter().eq("members.value", userA.getId()).build();
|
||||
ListResponse<Group> response = client.groups().getAll(filter);
|
||||
assertFalse(response.getResources().isEmpty());
|
||||
assertTrue(response.getResources().stream().anyMatch(g -> g.getId().equals(groupId)));
|
||||
|
||||
filter = ResourceFilter.filter().eq("members.value", userB.getId()).build();
|
||||
response = client.groups().getAll(filter);
|
||||
assertTrue(response.getResources().isEmpty() || response.getResources().stream().noneMatch(g -> g.getId().equals(groupId)));
|
||||
|
||||
// PATCH remove all remaining members
|
||||
client.groups().patch(group.getId(), PatchRequest.create()
|
||||
.remove("members[value eq \"" + userA.getId() + "\"]")
|
||||
.remove("members[value eq \"" + userC.getId() + "\"]")
|
||||
.build());
|
||||
fetched = client.groups().get(group.getId(), List.of("members"));
|
||||
assertTrue(fetched.getMembers() == null || fetched.getMembers().isEmpty());
|
||||
|
||||
client.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", userA.getId())
|
||||
.add("members", userB.getId())
|
||||
.add("members", userC.getId())
|
||||
.build());
|
||||
fetched = client.groups().get(group.getId(), List.of("members"));
|
||||
assertNotNull(fetched.getMembers());
|
||||
assertEquals(3, fetched.getMembers().size());
|
||||
|
||||
client.groups().patch(group.getId(), PatchRequest.create()
|
||||
.remove("members[value eq \"" + userA.getId() + "\" or value eq \"" + userC.getId() + "\"]")
|
||||
.build());
|
||||
fetched = client.groups().get(group.getId(), List.of("members"));
|
||||
assertNotNull(fetched.getMembers());
|
||||
assertEquals(1, fetched.getMembers().size());
|
||||
assertMember(fetched.getMembers(), userB.getId(), userB.getUserName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersOnCreate() {
|
||||
// create users via SCIM
|
||||
User userA = createScimUser();
|
||||
User userB = createScimUser();
|
||||
|
||||
// create a group with members set in the initial representation
|
||||
Group group = new Group();
|
||||
group.setDisplayName(KeycloakModelUtils.generateId());
|
||||
group.addMember(userA.getId());
|
||||
group.addMember(userB.getId());
|
||||
|
||||
try {
|
||||
client.groups().create(group);
|
||||
fail("Should have thrown an exception");
|
||||
} catch (ScimClientException e) {
|
||||
ErrorResponse error = e.getError();
|
||||
assertNotNull(error);
|
||||
assertEquals("Managing members on updates are not supported", error.getDetail());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGroupMembersReplace() {
|
||||
// create users via SCIM
|
||||
User userA = createScimUser();
|
||||
User userB = createScimUser();
|
||||
User userC = createScimUser();
|
||||
|
||||
// create a group and add initial members
|
||||
Group group = new Group();
|
||||
group.setDisplayName(KeycloakModelUtils.generateId());
|
||||
group = client.groups().create(group);
|
||||
adminEvents.clear();
|
||||
|
||||
client.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", userA.getId())
|
||||
.add("members", userB.getId())
|
||||
.build());
|
||||
|
||||
// PUT update: replace members with a different set
|
||||
Group toUpdate = client.groups().get(group.getId());
|
||||
toUpdate.setMembers(null);
|
||||
toUpdate.addMember(userC.getId());
|
||||
try {
|
||||
client.groups().update(toUpdate);
|
||||
fail("Should have thrown an exception");
|
||||
} catch (ScimClientException e) {
|
||||
ErrorResponse error = e.getError();
|
||||
assertNotNull(error);
|
||||
assertEquals("Managing members on updates are not supported", error.getDetail());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOrganizationGroupMembersNotManagedViaScim() {
|
||||
realm.updateWithCleanup(realm -> realm.organizationsEnabled(true));
|
||||
|
||||
OrganizationRepresentation orgRep = new OrganizationRepresentation();
|
||||
String orgName = KeycloakModelUtils.generateId();
|
||||
orgRep.setName(orgName);
|
||||
orgRep.setAlias(orgName);
|
||||
orgRep.addDomain(new OrganizationDomainRepresentation(orgName + ".org"));
|
||||
try (Response response = realm.admin().organizations().create(orgRep)) {
|
||||
orgRep.setId(ApiUtil.getCreatedId(response));
|
||||
}
|
||||
realm.cleanup().add(realm -> {
|
||||
realm.organizations().get(orgRep.getId()).delete().close();
|
||||
});
|
||||
|
||||
GroupRepresentation group = new GroupRepresentation();
|
||||
group.setName(KeycloakModelUtils.generateId());
|
||||
OrganizationResource orgResource = realm.admin().organizations().get(orgRep.getId());
|
||||
try (Response response = orgResource.groups().addTopLevelGroup(group)) {
|
||||
group.setId(ApiUtil.getCreatedId(response));
|
||||
}
|
||||
|
||||
try {
|
||||
User userA = createScimUser();
|
||||
client.groups().patch(group.getId(), PatchRequest.create()
|
||||
.add("members", userA.getId())
|
||||
.build());
|
||||
fail("Should have thrown an exception when managing organization group via SCIM");
|
||||
} catch (ScimClientException e) {
|
||||
ErrorResponse error = e.getError();
|
||||
assertNotNull(error);
|
||||
assertEquals("Cannot access organization related group via non Organization API.", error.getDetail());
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertMember(List<Member> members, String userId, String userName) {
|
||||
assertTrue(members.stream().anyMatch(m ->
|
||||
userId.equals(m.getValue())
|
||||
&& userName.equals(m.getDisplay())
|
||||
&& "User".equals(m.getType())
|
||||
), "Expected member with userId=" + userId + " and userName=" + userName);
|
||||
}
|
||||
|
||||
private User createScimUser() {
|
||||
User user = new User();
|
||||
user.setUserName(KeycloakModelUtils.generateId());
|
||||
user.setEmail(user.getUserName() + "@keycloak.org");
|
||||
user.setFirstName("firstName");
|
||||
user.setLastName("lastName");
|
||||
user.setActive(true);
|
||||
User created = client.users().create(user);
|
||||
adminEvents.clear();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +102,11 @@ public class SchemaTest extends AbstractScimTest {
|
||||
.map(Schema.Attribute::getName)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertEquals(2, attributeNames.size(), "Group schema should have exactly 2 attributes");
|
||||
assertEquals(3, attributeNames.size(), "Group schema should have exactly 3 attributes");
|
||||
|
||||
assertAttribute(findAttribute(schema, "displayName"), "string", false, false, false, "readWrite", "none");
|
||||
assertAttribute(findAttribute(schema, "externalId"), "string", false, false, true, "immutable", "none");
|
||||
assertAttribute(findAttribute(schema, "members"), "complex", true, false, true, "readWrite", "none");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -629,7 +629,7 @@ public class UserTest extends AbstractScimTest {
|
||||
|
||||
// patch a multivalued attribute using a filter in the path that matches an existing value
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.replace("emails[value ew \"patched4.org\"].value", expected.getEmail().replace("patched4.org", "filtered.org"))
|
||||
.replace("emails[value eq \"patched4.org\"].value", expected.getEmail().replace("patched4.org", "filtered.org"))
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
expected.setEmail(expected.getEmail().replace("patched4.org", "filtered.org"));
|
||||
@@ -757,7 +757,7 @@ public class UserTest extends AbstractScimTest {
|
||||
user.addGroup(groupC1.getId());
|
||||
|
||||
User expected = client.users().create(user);
|
||||
User actual = client.users().get(expected.getId());
|
||||
User actual = client.users().get(expected.getId(), List.of("groups"));
|
||||
|
||||
List<GroupMembership> groups = actual.getGroups();
|
||||
|
||||
@@ -769,7 +769,7 @@ public class UserTest extends AbstractScimTest {
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.remove("groups[value eq \"" + groupC1.getId() + "\"]")
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
actual = client.users().get(expected.getId(), List.of("groups"));
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(5, groups.size());
|
||||
@@ -777,7 +777,7 @@ public class UserTest extends AbstractScimTest {
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.remove("groups[value eq \"" + groupA1.getId() + "\" or value eq \"" + groupB.getId() + "\"]")
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
actual = client.users().get(expected.getId(), List.of("groups"));
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(3, groups.size());
|
||||
@@ -785,7 +785,7 @@ public class UserTest extends AbstractScimTest {
|
||||
client.users().patch(expected.getId(), PatchRequest.create()
|
||||
.add("groups", groupC1.getId())
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
actual = client.users().get(expected.getId(), List.of("groups"));
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(5, groups.size());
|
||||
@@ -794,7 +794,7 @@ public class UserTest extends AbstractScimTest {
|
||||
.add("groups", groupA1.getId())
|
||||
.add("groups", groupB.getId())
|
||||
.build());
|
||||
actual = client.users().get(expected.getId());
|
||||
actual = client.users().get(expected.getId(), List.of("groups"));
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(7, groups.size());
|
||||
@@ -803,7 +803,7 @@ public class UserTest extends AbstractScimTest {
|
||||
expected.getGroups().clear();
|
||||
expected.addGroup(groupA.getId());
|
||||
client.users().update(expected);
|
||||
actual = client.users().get(expected.getId());
|
||||
actual = client.users().get(expected.getId(), List.of("groups"));
|
||||
groups = actual.getGroups();
|
||||
assertNotNull(groups);
|
||||
assertEquals(1, groups.size());
|
||||
|
||||
Reference in New Issue
Block a user