Expose organization group membership for a member

Closes #46454

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik 2026-02-23 15:22:05 +01:00 committed by Pedro Igor
parent 4808e9e13a
commit 4beaaf2ab4
6 changed files with 241 additions and 2 deletions

View file

@ -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<OrganizationRepresentation> 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<GroupRepresentation> groups(
@QueryParam("first") Integer firstResult,
@QueryParam("max") Integer maxResults,
@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation);
}

View file

@ -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<GroupModel> 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<GroupModel> 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

View file

@ -696,6 +696,11 @@ public class JpaOrganizationProvider implements OrganizationProvider {
@Override
public Stream<GroupModel> getOrganizationGroupsByMember(OrganizationModel organization, UserModel member) {
return getOrganizationGroupsByMember(organization, member, null, null);
}
@Override
public Stream<GroupModel> 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));
}

View file

@ -301,6 +301,20 @@ public interface OrganizationProvider extends Provider {
*/
Stream<GroupModel> 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<GroupModel> getOrganizationGroupsByMember(OrganizationModel organization, UserModel member, Integer first, Integer max);
/**
* Associate the given {@link IdentityProviderModel} with the given {@link OrganizationModel}.
*

View file

@ -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<GroupRepresentation> 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)

View file

@ -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<GroupRepresentation> 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<GroupRepresentation> firstPage = orgResource.members().member(member.getId()).groups(0, 2, true);
assertThat(firstPage, hasSize(2));
// Test pagination: second page (next 2)
List<GroupRepresentation> secondPage = orgResource.members().member(member.getId()).groups(2, 2, true);
assertThat(secondPage, hasSize(2));
// Test pagination: third page (last 1)
List<GroupRepresentation> thirdPage = orgResource.members().member(member.getId()).groups(4, 2, true);
assertThat(thirdPage, hasSize(1));
// Test getting all without pagination
List<GroupRepresentation> 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<GroupRepresentation> 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<GroupRepresentation> 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<GroupRepresentation> 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<GroupRepresentation> updatedGroups = orgResource.members().member(member.getId()).groups(null, null, true);
assertThat(updatedGroups, hasSize(1));
assertEquals("Sales", updatedGroups.get(0).getName());
}
}