mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-28 04:13:22 -04:00
Expose organization group membership for a member
Closes #46454 Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
parent
4808e9e13a
commit
4beaaf2ab4
6 changed files with 241 additions and 2 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue