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 8205115fb92..99ddfc971bb 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 @@ -304,9 +304,9 @@ public class InfinispanOrganizationProvider implements OrganizationProvider { } @Override - public GroupModel createGroup(OrganizationModel organization, String name, GroupModel toParent) { + public GroupModel createGroup(OrganizationModel organization, String id, String name, GroupModel toParent) { //todo caching - return getDelegate().createGroup(organization, name, toParent); + return getDelegate().createGroup(organization, id, name, toParent); } @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 43b264a6485..e9e6496083f 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 @@ -479,7 +479,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { } @Override - public GroupModel createGroup(OrganizationModel organization, String name, GroupModel toParent) { + public GroupModel createGroup(OrganizationModel organization, String id, String name, GroupModel toParent) { throwExceptionIfObjectIsNull(name, "Name"); OrganizationEntity orgEntity = getEntity(organization.getId()); @@ -499,7 +499,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { parentGroup = toParent; } - GroupModel createdGroup = groupProvider.createGroup(getRealm(), null, Type.ORGANIZATION, name, parentGroup); + GroupModel createdGroup = groupProvider.createGroup(getRealm(), id, Type.ORGANIZATION, name, parentGroup); // Set organization-groups relationship GroupEntity groupEntity = em.find(GroupEntity.class, createdGroup.getId()); diff --git a/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java b/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java index d959970f085..9c47ce3f810 100755 --- a/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java +++ b/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java @@ -39,6 +39,7 @@ import org.keycloak.exportimport.ExportOptions; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel.Type; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -264,6 +265,12 @@ public class ExportUtils { member.setUsername(user.getUsername()); member.setMembershipType(orgProvider.isManagedMember(model, user) ? MembershipType.MANAGED : MembershipType.UNMANAGED); + // Export organization group memberships + List groupIds = orgProvider.getOrganizationGroupsByMember(model, user).map(GroupModel::getId).collect(Collectors.toList()); + if (!groupIds.isEmpty()) { + member.setGroups(groupIds); + } + org.addMember(member); }); @@ -274,6 +281,10 @@ public class ExportUtils { return broker; }).forEach(org::addIdentityProvider); + orgProvider.getTopLevelGroups(model, null, null) + .map(group -> ModelToRepresentation.toGroupHierarchy(group, true)) + .forEach(org::addGroup); + return org; }).forEach(rep::addOrganization); } diff --git a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java index 78162a4c881..ff9f9d5bd22 100644 --- a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java @@ -1723,6 +1723,10 @@ public class DefaultExportImportManager implements ExportImportManager { provider.addIdentityProvider(orgModel, idp); } + for (GroupRepresentation groupRep : Optional.ofNullable(orgRep.getGroups()).orElse(Collections.emptyList())) { + importOrganizationGroup(provider, orgModel, groupRep, null); + } + for (MemberRepresentation member : Optional.ofNullable(orgRep.getMembers()).orElse(Collections.emptyList())) { UserModel m = session.users().getUserByUsername(newRealm, member.getUsername()); if (MembershipType.MANAGED.equals(member.getMembershipType())) { @@ -1730,6 +1734,8 @@ public class DefaultExportImportManager implements ExportImportManager { } else { provider.addMember(orgModel, m); } + // Import organization group memberships + importOrganizationGroupMemberships(member, m, newRealm); } } } @@ -1754,4 +1760,32 @@ public class DefaultExportImportManager implements ExportImportManager { } } } + + private void importOrganizationGroup(OrganizationProvider provider, OrganizationModel organization, GroupRepresentation groupRep, GroupModel parent) { + GroupModel group = provider.createGroup(organization, groupRep.getId(), groupRep.getName(), parent); + + if (groupRep.getAttributes() != null) { + for (Map.Entry> attr : groupRep.getAttributes().entrySet()) { + group.setAttribute(attr.getKey(), attr.getValue()); + } + } + + if (groupRep.getSubGroups() != null) { + for (GroupRepresentation subGroup : groupRep.getSubGroups()) { + importOrganizationGroup(provider, organization, subGroup, group); + } + } + } + + private void importOrganizationGroupMemberships(MemberRepresentation memberRep, UserModel user, RealmModel realm) { + if (memberRep.getGroups() != null) { + for (String groupId : memberRep.getGroups()) { + GroupModel group = session.groups().getGroupById(realm, groupId); + if (group == null) { + throw new ModelException("Unable to find organization group specified by id: " + groupId); + } + user.joinGroup(group); + } + } + } } 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 188df230ae0..90adca0e678 100644 --- a/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java +++ b/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java @@ -215,6 +215,7 @@ public interface OrganizationProvider extends Provider { /** * Creates a new group within the given {@link OrganizationModel}. + * The internal ID of the group will be created automatically. * The created group will be of type {@link org.keycloak.models.GroupModel.Type#ORGANIZATION}. * If {@code toParent} is {@code null}, the group will be created as a top-level organization group, * as a direct child of the organization's internal group structure. @@ -231,7 +232,30 @@ public interface OrganizationProvider extends Provider { * @throws ModelValidationException if {@code toParent} is not an organization group or does not * belong to the specified organization */ - GroupModel createGroup(OrganizationModel organization, String name, GroupModel toParent); + default GroupModel createGroup(OrganizationModel organization, String name, GroupModel toParent) { + return createGroup(organization, null, name, toParent); + } + + /** + * Creates a new group with the given {@code id} within the given {@link OrganizationModel}. + * The created group will be of type {@link org.keycloak.models.GroupModel.Type#ORGANIZATION}. + * If {@code toParent} is {@code null}, the group will be created as a top-level organization group, + * as a direct child of the organization's internal group structure. + * If {@code toParent} is provided, the group will be created as a subgroup of the specified parent. + * + * @param organization the organization to create the group in + * @param id the id of the group. If {@code null}, an id will be generated automatically. + * @param name the name of the group to create + * @param toParent the parent group under which to create the new group. If {@code null}, + * the group is created as a top-level organization group. If provided, must be + * an organization group (type {@link org.keycloak.models.GroupModel.Type#ORGANIZATION}) + * belonging to the same organization. + * @return the newly created {@link GroupModel} + * @throws ModelException if {@code organization} or {@code name} is {@code null} + * @throws ModelValidationException if {@code toParent} is not an organization group or does not + * belong to the specified organization + */ + GroupModel createGroup(OrganizationModel organization, String id, String name, GroupModel toParent); /** * Returns the top-level groups of the given {@link OrganizationModel}. diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/exportimport/OrganizationExportTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/exportimport/OrganizationExportTest.java index a452d7a233e..3529e388b55 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/exportimport/OrganizationExportTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/exportimport/OrganizationExportTest.java @@ -36,14 +36,16 @@ import org.keycloak.exportimport.dir.DirExportProviderFactory; import org.keycloak.exportimport.dir.DirImportProviderFactory; import org.keycloak.exportimport.singlefile.SingleFileExportProviderFactory; import org.keycloak.exportimport.singlefile.SingleFileImportProviderFactory; -import org.keycloak.models.OrganizationModel; import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; +import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.PartialImportRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.client.resources.TestingExportImportResource; import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest; import org.keycloak.testsuite.pages.AppPage; @@ -70,6 +72,7 @@ public class OrganizationExportTest extends AbstractOrganizationTest { List expectedOrganizations = new ArrayList<>(); Map> expectedManagedMembers = new HashMap<>(); Map> expectedUnmanagedMembers = new HashMap<>(); + Map expectedGroupIds = new HashMap<>(); for (int i = 0; i < 2; i++) { IdentityProviderRepresentation broker = bc.setUpIdentityProvider(); @@ -84,6 +87,17 @@ public class OrganizationExportTest extends AbstractOrganizationTest { assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); } + // Create organization groups with hierarchy + String deptId = createTopLevelGroup(organization, "Department-" + i); + String teamId = createTopLevelGroup(organization, "Team-" + i); + String devId = createSubGroup(organization, deptId, "Development-" + i); + String qaId = createSubGroup(organization, deptId, "QA-" + i); + + expectedGroupIds.put("Department-" + i, deptId); + expectedGroupIds.put("Team-" + i, teamId); + expectedGroupIds.put("Development-" + i, devId); + expectedGroupIds.put("QA-" + i, qaId); + expectedOrganizations.add(orgRep); for (int j = 0; j < 3; j++) { @@ -115,22 +129,29 @@ public class OrganizationExportTest extends AbstractOrganizationTest { testRealm().logoutAll(); providerRealm.logoutAll(); } + + // Add members to organization groups + List orgMembers = organization.members().getAll(); + organization.groups().group(deptId).addMember(orgMembers.get(0).getId()); + organization.groups().group(teamId).addMember(orgMembers.get(1).getId()); + organization.groups().group(devId).addMember(orgMembers.get(2).getId()); } RealmRepresentation importedSingleFileRealm = exportRemoveImportRealm(true); - validateImported(expectedOrganizations, expectedManagedMembers, expectedUnmanagedMembers, importedSingleFileRealm); + validateImported(expectedOrganizations, expectedManagedMembers, expectedUnmanagedMembers, expectedGroupIds, importedSingleFileRealm); testRealm().logoutAll(); providerRealm.logoutAll(); RealmRepresentation importedDirRealm = exportRemoveImportRealm(false); - validateImported(expectedOrganizations, expectedManagedMembers, expectedUnmanagedMembers, importedDirRealm); + validateImported(expectedOrganizations, expectedManagedMembers, expectedUnmanagedMembers, expectedGroupIds, importedDirRealm); } private void validateImported(List expectedOrganizations, Map> expectedManagedMembers, Map> expectedUnmanagedMembers, + Map expectedGroupIds, RealmRepresentation importedRealm) { assertTrue(importedRealm.isOrganizationsEnabled()); @@ -162,10 +183,15 @@ public class OrganizationExportTest extends AbstractOrganizationTest { for (OrganizationRepresentation orgRep : organizations) { OrganizationResource organization = testRealm().organizations().get(orgRep.getId()); + + // Validate members List members = organization.members().list(-1, -1).stream().map(UserRepresentation::getEmail).toList(); assertEquals(members.size(), expectedUnmanagedMembers.get(orgRep.getName()).size() + expectedManagedMembers.get(orgRep.getName()).size()); assertTrue(members.containsAll(expectedUnmanagedMembers.get(orgRep.getName()))); assertTrue(members.containsAll(expectedManagedMembers.get(orgRep.getName()))); + + // Validate organization groups and hierarchy + validateOrganizationGroups(organization, expectedGroupIds); } // make sure a managed user can authenticate through the broker associated with an org @@ -182,6 +208,69 @@ public class OrganizationExportTest extends AbstractOrganizationTest { assertThat(executions.stream().filter(e -> "First Broker Login - Conditional Organization".equals(e.getDisplayName())).count(), is(1L)); } + private void validateOrganizationGroups(OrganizationResource organization, Map expectedGroupIds) { + List topLevelGroups = organization.groups().getAll(null, null, null, null); + assertThat(topLevelGroups, hasSize(2)); + + // Validate top-level group names + List topLevelGroupNames = topLevelGroups.stream().map(GroupRepresentation::getName).toList(); + assertThat(topLevelGroupNames, hasItem(Matchers.startsWith("Department-"))); + assertThat(topLevelGroupNames, hasItem(Matchers.startsWith("Team-"))); + + // Validate group IDs are preserved + validateGroupIds(topLevelGroups, expectedGroupIds); + + // Validate subgroups + validateSubGroups(organization, topLevelGroups, expectedGroupIds); + + // Validate group memberships are preserved + validateGroupMemberships(organization, topLevelGroups); + } + + private void validateSubGroups(OrganizationResource organization, List topLevelGroups, Map expectedGroupIds) { + GroupRepresentation deptGroup = topLevelGroups.stream() + .filter(g -> g.getName().startsWith("Department-")) + .findFirst() + .orElseThrow(() -> new AssertionError("Department group not found")); + + List subGroups = organization.groups().group(deptGroup.getId()) + .getSubGroups(null, null, null, null); + assertThat(subGroups, hasSize(2)); + + // Validate subgroup names + List subGroupNames = subGroups.stream().map(GroupRepresentation::getName).toList(); + assertThat(subGroupNames, hasItem(Matchers.startsWith("Development-"))); + assertThat(subGroupNames, hasItem(Matchers.startsWith("QA-"))); + + // Validate group IDs are preserved + validateGroupIds(subGroups, expectedGroupIds); + } + + private void validateGroupMemberships(OrganizationResource organization, List topLevelGroups) { + // Each group should have exactly 1 explicit member as added in the test setup + GroupRepresentation deptGroup = topLevelGroups.stream().filter(g -> g.getName().startsWith("Department-")).findFirst().orElseThrow(); + List deptMembers = organization.groups().group(deptGroup.getId()).getMembers(null, null, null); + assertThat(deptMembers, hasSize(1)); + + GroupRepresentation teamGroup = topLevelGroups.stream().filter(g -> g.getName().startsWith("Team-")).findFirst().orElseThrow(); + List teamMembers = organization.groups().group(teamGroup.getId()).getMembers(null, null, null); + assertThat(teamMembers, hasSize(1)); + + List subGroups = organization.groups().group(deptGroup.getId()).getSubGroups(null, null, null, null); + GroupRepresentation devGroup = subGroups.stream().filter(g -> g.getName().startsWith("Development-")).findFirst().orElseThrow(); + List devMembers = organization.groups().group(devGroup.getId()).getMembers(null, null, null); + assertThat(devMembers, hasSize(1)); + } + + private void validateGroupIds(List groups, Map expectedGroupIds) { + for (GroupRepresentation group : groups) { + String expectedId = expectedGroupIds.get(group.getName()); + if (expectedId != null) { + assertEquals("Group ID mismatch for group: " + group.getName(), expectedId, group.getId()); + } + } + } + @Test public void testExportImportEmptyOrg() { OrganizationRepresentation orgRep = createRepresentation("acme", "acme.com"); @@ -250,7 +339,6 @@ public class OrganizationExportTest extends AbstractOrganizationTest { private void assertPartialExportImport(boolean exportGroupsAndRoles, boolean exportClients) { RealmRepresentation export = testRealm().partialExport(exportGroupsAndRoles, exportClients); - assertTrue(Optional.ofNullable(export.getGroups()).orElse(List.of()).stream().noneMatch(g -> g.getAttributes().containsKey(OrganizationModel.ORGANIZATION_ATTRIBUTE))); assertTrue(Optional.ofNullable(export.getOrganizations()).orElse(List.of()).isEmpty()); assertTrue(Optional.ofNullable(export.getIdentityProviders()).orElse(List.of()).stream().noneMatch(idp -> Objects.nonNull(idp.getOrganizationId()))); PartialImportRepresentation rep = new PartialImportRepresentation(); @@ -261,4 +349,22 @@ public class OrganizationExportTest extends AbstractOrganizationTest { rep.setGroups(export.getGroups()); testRealm().partialImport(rep).close(); } + + private String createTopLevelGroup(OrganizationResource organization, String name) { + GroupRepresentation group = new GroupRepresentation(); + group.setName(name); + try (Response response = organization.groups().addTopLevelGroup(group)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + return ApiUtil.getCreatedId(response); + } + } + + private String createSubGroup(OrganizationResource organization, String parentId, String name) { + GroupRepresentation group = new GroupRepresentation(); + group.setName(name); + try (Response response = organization.groups().group(parentId).addSubGroup(group)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + return response.readEntity(GroupRepresentation.class).getId(); + } + } }