Organization Groups Import/Export

Closes #45507

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis 2026-01-26 12:26:55 +01:00 committed by Pedro Igor
parent 1f8744e57e
commit 0433b0017d
6 changed files with 184 additions and 9 deletions

View file

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

View file

@ -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());

View file

@ -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<String> 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);
}

View file

@ -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<String, List<String>> 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);
}
}
}
}

View file

@ -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}.

View file

@ -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<OrganizationRepresentation> expectedOrganizations = new ArrayList<>();
Map<String, List<String>> expectedManagedMembers = new HashMap<>();
Map<String, List<String>> expectedUnmanagedMembers = new HashMap<>();
Map<String, String> 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<MemberRepresentation> 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<OrganizationRepresentation> expectedOrganizations,
Map<String, List<String>> expectedManagedMembers, Map<String, List<String>> expectedUnmanagedMembers,
Map<String, String> 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<String> 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<String, String> expectedGroupIds) {
List<GroupRepresentation> topLevelGroups = organization.groups().getAll(null, null, null, null);
assertThat(topLevelGroups, hasSize(2));
// Validate top-level group names
List<String> 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<GroupRepresentation> topLevelGroups, Map<String, String> expectedGroupIds) {
GroupRepresentation deptGroup = topLevelGroups.stream()
.filter(g -> g.getName().startsWith("Department-"))
.findFirst()
.orElseThrow(() -> new AssertionError("Department group not found"));
List<GroupRepresentation> subGroups = organization.groups().group(deptGroup.getId())
.getSubGroups(null, null, null, null);
assertThat(subGroups, hasSize(2));
// Validate subgroup names
List<String> 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<GroupRepresentation> 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<MemberRepresentation> 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<MemberRepresentation> teamMembers = organization.groups().group(teamGroup.getId()).getMembers(null, null, null);
assertThat(teamMembers, hasSize(1));
List<GroupRepresentation> subGroups = organization.groups().group(deptGroup.getId()).getSubGroups(null, null, null, null);
GroupRepresentation devGroup = subGroups.stream().filter(g -> g.getName().startsWith("Development-")).findFirst().orElseThrow();
List<MemberRepresentation> devMembers = organization.groups().group(devGroup.getId()).getMembers(null, null, null);
assertThat(devMembers, hasSize(1));
}
private void validateGroupIds(List<GroupRepresentation> groups, Map<String, String> 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();
}
}
}