mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-03 20:39:33 -05:00
Organization Groups Import/Export
Closes #45507 Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
parent
1f8744e57e
commit
0433b0017d
6 changed files with 184 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue