Support for managing members via group resource type

Closes #46216

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor
2026-04-02 15:06:12 -03:00
committed by GitHub
parent 7df438da5c
commit 6a4f865013
15 changed files with 795 additions and 234 deletions
@@ -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;
}
}
@@ -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()));
}
}
}
@@ -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();
}
}
}
@@ -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());