From 4beaaf2ab4558b9d449c19a30a7b9d5d02307275 Mon Sep 17 00:00:00 2001 From: vramik Date: Mon, 23 Feb 2026 15:22:05 +0100 Subject: [PATCH] Expose organization group membership for a member Closes #46454 Signed-off-by: vramik --- .../resource/OrganizationMemberResource.java | 18 ++ .../InfinispanOrganizationProvider.java | 10 +- .../jpa/JpaOrganizationProvider.java | 7 +- .../organization/OrganizationProvider.java | 14 ++ .../resource/OrganizationMemberResource.java | 27 +++ .../OrganizationGroupMembershipTest.java | 167 ++++++++++++++++++ 6 files changed, 241 insertions(+), 2 deletions(-) diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationMemberResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationMemberResource.java index b11588eb2b3..b0fd64602ce 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationMemberResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationMemberResource.java @@ -28,6 +28,7 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.MemberRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; @@ -63,4 +64,21 @@ public interface OrganizationMemberResource { @Produces(MediaType.APPLICATION_JSON) List getOrganizations( @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation); + + /** + * Returns the organization group memberships for this member + * + * @param firstResult the position of the first result to be processed (pagination offset) + * @param maxResults the maximum number of results to be returned + * @param briefRepresentation if false, return the full representation. Otherwise, only the basic fields are returned. It is true by default. + * @since Keycloak server 26 + * @return the organization groups the member belongs to + */ + @Path("groups") + @GET + @Produces(MediaType.APPLICATION_JSON) + List groups( + @QueryParam("first") Integer firstResult, + @QueryParam("max") Integer maxResults, + @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java index 547e3e46df8..1b75f301ba7 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java @@ -18,6 +18,7 @@ package org.keycloak.models.cache.infinispan.organization; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; @@ -329,6 +330,13 @@ public class InfinispanOrganizationProvider implements OrganizationProvider { return getDelegate().searchGroupsByAttributes(organization, attributes, firstResult, maxResults); } + @Override + public Stream getOrganizationGroupsByMember(OrganizationModel organization, UserModel member, Integer first, Integer max) { + // Don't cache paginated queries - delegate directly to DB + // This follows the same pattern as searchGroupsByName to avoid caching partial results + return getDelegate().getOrganizationGroupsByMember(organization, member, first, max); + } + @Override public Stream getOrganizationGroupsByMember(OrganizationModel organization, UserModel member) { if (userCache == null) { @@ -353,7 +361,7 @@ public class InfinispanOrganizationProvider implements OrganizationProvider { RealmModel realm = getRealm(); return cached.getGroupIds().stream() .map(realm::getGroupById) - .filter(java.util.Objects::nonNull); + .filter(Objects::nonNull); } @Override diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java index 4865278a7b6..04cc3e370f6 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java @@ -696,6 +696,11 @@ public class JpaOrganizationProvider implements OrganizationProvider { @Override public Stream getOrganizationGroupsByMember(OrganizationModel organization, UserModel member) { + return getOrganizationGroupsByMember(organization, member, null, null); + } + + @Override + public Stream getOrganizationGroupsByMember(OrganizationModel organization, UserModel member, Integer first, Integer max) { throwExceptionIfObjectIsNull(organization, "Organization"); throwExceptionIfObjectIsNull(member, "Member"); @@ -716,7 +721,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { query.setParameter("orgId", organization.getId()); query.setParameter("internalOrgGroupId", getOrganizationGroup(organization).getId()); - return closing(query.getResultStream() + return closing(paginateQuery(query, first, max).getResultStream() .map(realm::getGroupById) .filter(Objects::nonNull)); } diff --git a/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java b/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java index 90adca0e678..a6dd19f5fbf 100644 --- a/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java +++ b/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java @@ -301,6 +301,20 @@ public interface OrganizationProvider extends Provider { */ Stream getOrganizationGroupsByMember(OrganizationModel organization, UserModel member); + /** + * Returns organization groups that the given {@code member} explicitly belongs to within the given {@code organization}, + * with pagination support. + * Only returns groups of type {@link org.keycloak.models.GroupModel.Type#ORGANIZATION} that belong to the specified organization. + * Membership is explicit - being a member of a child group does not imply membership in parent groups. + * + * @param organization the organization whose groups to check + * @param member the user whose group memberships to retrieve + * @param first the position of the first result to be processed (pagination offset). Ignored if negative or {@code null}. + * @param max the maximum number of results to be returned. Ignored if negative or {@code null}. + * @return Stream of organization groups the member belongs to. Never returns {@code null}. + */ + Stream getOrganizationGroupsByMember(OrganizationModel organization, UserModel member, Integer first, Integer max); + /** * Associate the given {@link IdentityProviderModel} with the given {@link OrganizationModel}. * diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java index ab22f7d2156..49e1939de03 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java @@ -45,6 +45,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.organization.OrganizationProvider; +import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.MemberRepresentation; import org.keycloak.representations.idm.MembershipType; import org.keycloak.representations.idm.OrganizationRepresentation; @@ -255,6 +256,32 @@ public class OrganizationMemberResource { .map(model -> ModelToRepresentation.toRepresentation(model, briefRepresentation)); } + @Path("{member-id}/groups") + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS) + @Operation( summary = "Returns the organization group memberships for a member with the specified id", description = "Searches for a" + + "user with the given id. If one is found, and is currently a member of the organization, returns the groups from the organization" + + "where the user is member of. Otherwise, an error response with status NOT_FOUND is returned") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "", content = @Content(schema = @Schema(implementation = GroupRepresentation.class, type = SchemaType.ARRAY))), + @APIResponse(responseCode = "400", description = "Bad Request") + }) + public Stream groupMemberships(@PathParam("member-id") String memberId, + @QueryParam("first") Integer firstResult, + @QueryParam("max") Integer maxResults, + @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) { + if (StringUtil.isBlank(memberId)) { + throw ErrorResponse.error("id cannot be null", Status.BAD_REQUEST); + } + + UserModel member = getUser(memberId); + + return provider.getOrganizationGroupsByMember(organization, member, firstResult, maxResults) + .map(group -> ModelToRepresentation.toRepresentation(group, !briefRepresentation)); + } + @Path("count") @GET @Produces(MediaType.APPLICATION_JSON) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/group/OrganizationGroupMembershipTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/group/OrganizationGroupMembershipTest.java index d9c5809b809..82b225425b7 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/group/OrganizationGroupMembershipTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/group/OrganizationGroupMembershipTest.java @@ -36,6 +36,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @@ -316,4 +317,170 @@ public class OrganizationGroupMembershipTest extends AbstractOrganizationTest { assertThat(e.getMessage(), containsString(Status.CONFLICT.toString())); } } + + @Test + public void testGetMemberGroupMemberships() { + // Test basic retrieval of member's group memberships + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + MemberRepresentation member = addMember(orgResource); + + // Create three groups + GroupRepresentation engineeringRep = new GroupRepresentation(); + engineeringRep.setName("Engineering"); + String engineeringId; + try (Response response = orgResource.groups().addTopLevelGroup(engineeringRep)) { + engineeringId = ApiUtil.getCreatedId(response); + } + + GroupRepresentation salesRep = new GroupRepresentation(); + salesRep.setName("Sales"); + String salesId; + try (Response response = orgResource.groups().addTopLevelGroup(salesRep)) { + salesId = ApiUtil.getCreatedId(response); + } + + GroupRepresentation supportRep = new GroupRepresentation(); + supportRep.setName("Support"); + String supportId; + try (Response response = orgResource.groups().addTopLevelGroup(supportRep)) { + supportId = ApiUtil.getCreatedId(response); + } + + // Add member to all three groups + orgResource.groups().group(engineeringId).addMember(member.getId()); + orgResource.groups().group(salesId).addMember(member.getId()); + orgResource.groups().group(supportId).addMember(member.getId()); + + // Get member's group memberships without pagination + List memberGroups = orgResource.members().member(member.getId()).groups(null, null, true); + + // Verify member is in all three groups + assertThat(memberGroups, hasSize(3)); + } + + @Test + public void testGetMemberGroupMembershipsWithPagination() { + // Test pagination of member's group memberships + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + MemberRepresentation member = addMember(orgResource); + + // Create five groups + String[] groupIds = new String[5]; + for (int i = 0; i < 5; i++) { + GroupRepresentation groupRep = new GroupRepresentation(); + groupRep.setName("Group" + i); + try (Response response = orgResource.groups().addTopLevelGroup(groupRep)) { + groupIds[i] = ApiUtil.getCreatedId(response); + } + orgResource.groups().group(groupIds[i]).addMember(member.getId()); + } + + // Test pagination: first page (first 2) + List firstPage = orgResource.members().member(member.getId()).groups(0, 2, true); + assertThat(firstPage, hasSize(2)); + + // Test pagination: second page (next 2) + List secondPage = orgResource.members().member(member.getId()).groups(2, 2, true); + assertThat(secondPage, hasSize(2)); + + // Test pagination: third page (last 1) + List thirdPage = orgResource.members().member(member.getId()).groups(4, 2, true); + assertThat(thirdPage, hasSize(1)); + + // Test getting all without pagination + List allGroups = orgResource.members().member(member.getId()).groups(null, null, true); + assertThat(allGroups, hasSize(5)); + } + + @Test + public void testGetMemberGroupMembershipsEmpty() { + // Test retrieval when member has no group memberships + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + MemberRepresentation member = addMember(orgResource); + + // Get member's group memberships - should be empty + List memberGroups = orgResource.members().member(member.getId()).groups(null, null, true); + + assertThat(memberGroups, hasSize(0)); + } + + @Test + public void testGetMemberGroupMembershipsWithHierarchy() { + // Test that only explicit memberships are returned, not parent groups + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + MemberRepresentation member = addMember(orgResource); + + // Create Engineering -> Backend hierarchy + GroupRepresentation engineeringRep = new GroupRepresentation(); + engineeringRep.setName("Engineering"); + String engineeringId; + try (Response response = orgResource.groups().addTopLevelGroup(engineeringRep)) { + engineeringId = ApiUtil.getCreatedId(response); + } + + GroupRepresentation backendRep = new GroupRepresentation(); + backendRep.setName("Backend"); + String backendId; + try (Response response = orgResource.groups().group(engineeringId).addSubGroup(backendRep)) { + backendId = response.readEntity(GroupRepresentation.class).getId(); + } + + // Add member ONLY to Backend (child group) + orgResource.groups().group(backendId).addMember(member.getId()); + + // Get member's group memberships + List memberGroups = orgResource.members().member(member.getId()).groups(null, null, true); + + // Should only return Backend, NOT Engineering (no implicit parent membership) + assertThat(memberGroups, hasSize(1)); + assertEquals("Backend", memberGroups.get(0).getName()); + } + + @Test + public void testGetMemberGroupMembershipsAfterRemoval() { + // Test that group memberships are correctly reflected after removal + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + MemberRepresentation member = addMember(orgResource); + + // Create two groups + GroupRepresentation engineeringRep = new GroupRepresentation(); + engineeringRep.setName("Engineering"); + String engineeringId; + try (Response response = orgResource.groups().addTopLevelGroup(engineeringRep)) { + engineeringId = ApiUtil.getCreatedId(response); + } + + GroupRepresentation salesRep = new GroupRepresentation(); + salesRep.setName("Sales"); + String salesId; + try (Response response = orgResource.groups().addTopLevelGroup(salesRep)) { + salesId = ApiUtil.getCreatedId(response); + } + + // Add member to both groups + orgResource.groups().group(engineeringId).addMember(member.getId()); + orgResource.groups().group(salesId).addMember(member.getId()); + + // Verify member is in both groups + List memberGroups = orgResource.members().member(member.getId()).groups(null, null, true); + assertThat(memberGroups, hasSize(2)); + + // Remove from Engineering + orgResource.groups().group(engineeringId).removeMember(member.getId()); + + // Verify member is now only in Sales + List updatedGroups = orgResource.members().member(member.getId()).groups(null, null, true); + assertThat(updatedGroups, hasSize(1)); + assertEquals("Sales", updatedGroups.get(0).getName()); + } }