From b08dfe27ca7fe7276e4c98be357ebc6cc6a03f3a Mon Sep 17 00:00:00 2001 From: Sven-Torben Janus Date: Mon, 10 Aug 2026 22:47:25 +0200 Subject: [PATCH] Do not expose organization groups through the SCIM Users groups attribute Closes #51590 Signed-off-by: Sven-Torben Janus --- .../model/user/AbstractUserModelSchema.java | 8 ++++ .../scim/model/user/UserCoreModelSchema.java | 2 +- .../org/keycloak/tests/scim/tck/UserTest.java | 47 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/AbstractUserModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/user/AbstractUserModelSchema.java index fd3e038370ac..594f470973e4 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/AbstractUserModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/AbstractUserModelSchema.java @@ -70,6 +70,7 @@ protected Object getAttributeValue(UserModel model, String name) { if (permissions.hasPermission(model, AdminPermissionsSchema.USERS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW)) { return model.getGroupsStream() + .filter(group -> !isOrganizationGroup(group)) .filter(this::canViewGroup) .toList(); } @@ -122,4 +123,11 @@ protected UserProfile getUserProfile() { protected boolean canViewGroup(GroupModel group) { return session.getContext().getPermissions().hasPermission(group, AdminPermissionsSchema.GROUPS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW); } + + /** + * Organization groups are only accessible through the Organization API and must not be exposed through SCIM. + */ + protected static boolean isOrganizationGroup(GroupModel group) { + return GroupModel.Type.ORGANIZATION.equals(group.getType()) && group.getOrganization() != 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 0d09dc4b6578..21209e9ddf23 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 @@ -255,7 +255,7 @@ private static void checkUserMembershipPermission(Permissions permissions, UserM } private static void checkGroupMembershipPermission(Permissions permissions, GroupModel group) { - if (GroupModel.Type.ORGANIZATION.equals(group.getType()) && group.getOrganization() != null) { + if (isOrganizationGroup(group)) { throw new ModelValidationException("Cannot access organization related group via non Organization API."); } if (permissions.isAdminGroup(group)) { 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 418ad760afa9..1bcd31d87b0a 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 @@ -12,6 +12,7 @@ import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.GroupResource; +import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; import org.keycloak.http.simple.SimpleHttp; @@ -21,6 +22,8 @@ import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.userprofile.config.UPAttribute; @@ -1325,6 +1328,50 @@ public void testPatchCustomAttribute() { } } + @Test + public void testOrganizationGroupsNotExposedOnUser() { + 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()); + + OrganizationResource orgResource = realm.admin().organizations().get(orgRep.getId()); + + GroupRepresentation orgGroup = new GroupRepresentation(); + orgGroup.setName(KeycloakModelUtils.generateId()); + try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) { + orgGroup.setId(ApiUtil.getCreatedId(response)); + } + + GroupRepresentation realmGroup = createGroup(KeycloakModelUtils.generateId()); + + User user = createUser(); + user.addGroup(realmGroup.getId()); + User expected = client.users().create(user); + + // organization membership is itself a membership in the organization's internal group + try (Response response = orgResource.members().addMember(expected.getId())) { + assertEquals(Status.CREATED.getStatusCode(), response.getStatus()); + } + orgResource.groups().group(orgGroup.getId()).addMember(expected.getId()); + + User actual = client.users().get(expected.getId(), List.of("groups")); + List groups = actual.getGroups(); + + assertNotNull(groups); + assertTrue(groups.stream().anyMatch(g -> realmGroup.getId().equals(g.getValue()))); + assertTrue(groups.stream().noneMatch(g -> orgGroup.getId().equals(g.getValue()))); + // neither the organization group nor the organization's internal group are exposed + assertEquals(1, groups.size()); + } + private static void assertGroup(List groups, GroupRepresentation group, String type) { assertTrue(groups.stream().anyMatch(membership -> { boolean found = group.getId().equals(membership.getValue()) && group.getName().equals(membership.getDisplay());