diff --git a/scim/client/src/main/java/org/keycloak/scim/client/AbstractScimResourceClient.java b/scim/client/src/main/java/org/keycloak/scim/client/AbstractScimResourceClient.java index 829c905eedd..cc72a9a065a 100644 --- a/scim/client/src/main/java/org/keycloak/scim/client/AbstractScimResourceClient.java +++ b/scim/client/src/main/java/org/keycloak/scim/client/AbstractScimResourceClient.java @@ -51,6 +51,10 @@ public abstract class AbstractScimResourceClient attributes) { + return get(id, attributes, null); + } + public R get(String id, List attributes, List excludedAttributes) { requireNonNull(id, "SCIM resource ID must not be null"); diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/group/Group.java b/scim/core/src/main/java/org/keycloak/scim/resource/group/Group.java index cbb90efc330..9539712451c 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/group/Group.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/group/Group.java @@ -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 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); + } } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/group/Member.java b/scim/core/src/main/java/org/keycloak/scim/resource/group/Member.java index e230b44eb42..8fdc87ecdcc 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/group/Member.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/group/Member.java @@ -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 { } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java index 729559815a8..fb0527c16fe 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java @@ -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, JsonNode> entry : resolveAttributes(path.getPath(), value).entrySet()) { - JsonNode attrValue = path.getValue(entry.getValue()); - setValue(model, entry.getKey(), attrValue, REMOVE); + for (Entry, JsonNode> entry : resolveAttributes(path.getPath(), NullNode.getInstance()).entrySet()) { + setValue(model, entry.getKey(), path.getValue(entry.getKey()), REMOVE); } } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java index 8ddb6c51a26..091b48c6d84 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java @@ -239,7 +239,7 @@ public class Attribute { 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); diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/Path.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/Path.java index 6f5d255a40d..50bc4ee54d3 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/Path.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/Path.java @@ -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 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; - } - } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimFilterToJsonNodeConverter.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimFilterToJsonNodeConverter.java new file mode 100644 index 00000000000..d242d0e4de6 --- /dev/null +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimFilterToJsonNodeConverter.java @@ -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 { + + 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())); + } + } +} diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java deleted file mode 100644 index 298c859d8b7..00000000000 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java +++ /dev/null @@ -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. - *

- * 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> { - - @Override - public Predicate visitFilter(ScimFilterParser.FilterContext ctx) { - return visit(ctx.expression()); - } - - @Override - public Predicate visitExpression(ScimFilterParser.ExpressionContext ctx) { - if (ctx.OR() != null) { - Predicate left = visit(ctx.expression()); - Predicate right = visit(ctx.andExpression()); - return left.or(right); - } - return visit(ctx.andExpression()); - } - - @Override - public Predicate visitAndExpression(ScimFilterParser.AndExpressionContext ctx) { - if (ctx.AND() != null) { - Predicate left = visit(ctx.andExpression()); - Predicate right = visit(ctx.notExpression()); - return left.and(right); - } - return visit(ctx.notExpression()); - } - - @Override - public Predicate visitNotExpression(ScimFilterParser.NotExpressionContext ctx) { - if (ctx.NOT() != null) { - Predicate child = visit(ctx.notExpression()); - return child.negate(); - } - return visit(ctx.atom()); - } - - @Override - public Predicate 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 visitValuePath(ScimFilterParser.ValuePathContext ctx) { - return visit(ctx.expression()); - } - - @Override - public Predicate 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 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"); - } -} diff --git a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java index 5e127ed5242..0be62912b36 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java @@ -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 { @@ -35,14 +48,19 @@ public final class GroupCoreModelSchema extends AbstractModelSchema 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 "displayName"; case "externalId" -> name; + case "members" -> "members"; default -> null; }; } @@ -82,6 +101,53 @@ public final class GroupCoreModelSchema extends AbstractModelSchema "lastModifiedTimestamp") .build()); + attributes.addAll(Attribute.complex("members", Member.class) + .multivalued() + .returned(Attribute.RETURNED_REQUEST) + .modelAttributeResolver(Attribute::getName) + .withModelSetter((TriConsumer>) (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, 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>) (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>) (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 { +public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider 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 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, Supplier>, 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; + } } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java index 2bba883287b..9bc915d0a8f 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java @@ -144,8 +144,9 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema { .modelAttributeResolver(attribute -> "lastModifiedTimestamp") .build()); attributes.addAll(Attribute.complex("groups", GroupMembership.class) - .modelAttributeResolver(Attribute::getName) .multivalued() + .returned(Attribute.RETURNED_REQUEST) + .modelAttributeResolver(Attribute::getName) .withModelSetter((TriConsumer>) (model, name, values) -> { KeycloakSession session = KeycloakSessionUtil.getKeycloakSession(); RealmModel realm = session.getContext().getRealm(); diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/AuthorizationTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/AuthorizationTest.java index f30fea36fba..68b6f69605c 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/AuthorizationTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/AuthorizationTest.java @@ -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); } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java index 68713b4ee55..1e302fb845a 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java @@ -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 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 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; + } } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java index d66b2c8f4c7..fd6b41be16a 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java @@ -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 diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java index 49841b81b25..144c7f22160 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java @@ -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 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());