diff --git a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/AbstractGroupSynchronizer.java b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/AbstractGroupSynchronizer.java index 94c37004ae..f5c68f1d8a 100644 --- a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/AbstractGroupSynchronizer.java +++ b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/AbstractGroupSynchronizer.java @@ -237,4 +237,8 @@ private Profile resolveUserProfile(@NonNull List roles) { return profileMappings.resolveHighestProfileFromRoleNames(roles); } + public List getRootRolesForUser(@NonNull CanonicalUser user) { + return user.getRoles(); + } + } diff --git a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/GroupSynchronizer.java b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/GroupSynchronizer.java index 90bb154c15..f9960ceefe 100644 --- a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/GroupSynchronizer.java +++ b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/GroupSynchronizer.java @@ -70,4 +70,10 @@ interface GroupSynchronizer { * {@link Group} and and external {@link GroupLink link} in the process. */ Privileges resolvePrivilegesFor(CanonicalUser user); + + /** + * Returns the names of the root roles (non group-specific) assigned to the given user in the + * external system. + */ + List getRootRolesForUser(@NonNull CanonicalUser user); } diff --git a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/GroupSynchronizerProxy.java b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/GroupSynchronizerProxy.java index 0789dbbc3c..fcc9e87740 100644 --- a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/GroupSynchronizerProxy.java +++ b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/GroupSynchronizerProxy.java @@ -39,6 +39,7 @@ class GroupSynchronizerProxy implements GroupSynchronizer { private @Autowired OrgsBasedGroupSynchronizer orgsSynchronizer; private @Autowired RolesBasedGroupSynchronizer rolesSynchronizer; + private @Autowired RolePerOrgBasedGroupSynchronizer rolePerOrgSynchronizer; private GroupSynchronizer resolve() { final GroupSyncMode syncMode = props.getSyncMode(); @@ -47,6 +48,8 @@ private GroupSynchronizer resolve() { return orgsSynchronizer; case roles: return rolesSynchronizer; + case role_per_org: + return rolePerOrgSynchronizer; default: throw new IllegalStateException("Invalid sync mode: " + syncMode); } @@ -79,4 +82,8 @@ private GroupSynchronizer resolve() { public @Override Privileges resolvePrivilegesFor(CanonicalUser user) { return resolve().resolvePrivilegesFor(user); } + + public @Override List getRootRolesForUser(CanonicalUser user) { + return resolve().getRootRolesForUser(user); + } } diff --git a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/IntegrationConfiguration.java b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/IntegrationConfiguration.java index 923b492721..795e555f39 100644 --- a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/IntegrationConfiguration.java +++ b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/IntegrationConfiguration.java @@ -51,6 +51,10 @@ public class IntegrationConfiguration { return new OrgsBasedGroupSynchronizer(canonicalAccountsRepository); } + protected @Bean RolePerOrgBasedGroupSynchronizer rolePerOrgBasedGroupSynchronizer() { + return new RolePerOrgBasedGroupSynchronizer(canonicalAccountsRepository); + } + protected @Bean LogoUpdater logoUpdater() { return new LogoUpdater(canonicalAccountsRepository); } diff --git a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/RolePerOrgBasedGroupSynchronizer.java b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/RolePerOrgBasedGroupSynchronizer.java new file mode 100644 index 0000000000..3bd33bbd6a --- /dev/null +++ b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/integration/RolePerOrgBasedGroupSynchronizer.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2009-2025 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ +package org.geonetwork.security.external.integration; + +import org.fao.geonet.domain.Group; +import org.fao.geonet.domain.Profile; +import org.geonetwork.security.external.configuration.ExternalizedSecurityProperties; +import org.geonetwork.security.external.model.CanonicalGroup; +import org.geonetwork.security.external.model.CanonicalUser; +import org.geonetwork.security.external.model.GroupLink; +import org.geonetwork.security.external.model.GroupSyncMode; +import org.geonetwork.security.external.repository.CanonicalAccountsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.StringUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class RolePerOrgBasedGroupSynchronizer extends AbstractGroupSynchronizer { + + public static final Logger log = LoggerFactory.getLogger(RolePerOrgBasedGroupSynchronizer.class.getPackage().getName()); + + private static final String separator = ":"; + + @Autowired + public ExternalizedSecurityProperties config; + + public RolePerOrgBasedGroupSynchronizer(CanonicalAccountsRepository canonicalAccounts) { + super(canonicalAccounts); + } + + protected @Override GroupSyncMode getOrigin() { + return GroupSyncMode.role_per_org; + } + + public @Override List fetchCanonicalGroups() { + return canonicalAccounts.findAllOrganizations(); + } + + protected @Override List resolveGroupsOf(CanonicalUser user) { + final String orgName = user.getOrganization(); + if (!StringUtils.hasLength(orgName)) { + return Collections.emptyList(); + } + Stream groupsName = userRoles(user) + .map(r -> r.contains(separator) ? r.split(separator)[0] : orgName + ).distinct(); + Stream roleGroups = groupsName.map(role -> this.externalGroupLinks.findByName(role)// + .map(GroupLink::getCanonical) + .orElseThrow(notFound(role))); + return roleGroups.collect(Collectors.toList()); + } + + @Override + public Privileges resolvePrivilegesFor(CanonicalUser user) { + final List canonicalGroups = resolveGroupsOf(user); + + Privileges userPrivileges = new Privileges(resolveDefaultProfile(user)); + Stream groups = canonicalGroups.stream().map(this::synchronize).map(GroupLink::getGeonetworkGroup); + groups.map(g -> resolvePrivilegeFor(user, g)) + .forEach(userPrivileges.getAdditionalProvileges()::add); + return userPrivileges; + } + + @Override + protected Profile resolveDefaultProfile(CanonicalUser user) { + return configProperties.getProfiles().resolveHighestProfileFromRoleNames(getRootRolesForUser(user)); + } + + private Stream userRoles(CanonicalUser user) { + return user.getRoles().stream() + .filter(r -> Pattern.compile(".+" + separator + ".+").matcher(r).matches() || config.getProfiles().getRolemappings().keySet().contains(r)); + } + + private Privilege resolvePrivilegeFor(CanonicalUser user, Group group) { + String groupPrefix = group.getName() + separator; + List rolesForGroup = userRoles(user) + .filter(r -> r.startsWith(groupPrefix) || config.getProfiles().getRolemappings().keySet().contains(r)) //e.g filter roles for this group PSC:GN_REVIEWER and GN_EDITOR + .map(this::getRootRole) // e.g get only the role part GN_REVIEWER + .collect(Collectors.toList()); + Profile p = config.getProfiles().resolveHighestProfileFromRoleNames(rolesForGroup); // resolve highest profile for the roles filtered, here GN_REVIEWER + return new Privilege(group, p); + } + + private Supplier notFound(final String orgName) { + return () -> new IllegalArgumentException( + "Organization with name '" + orgName + "' not found in internal nor external repository"); + } + + @Override + public List getRootRolesForUser(CanonicalUser user) { + // Not used in RolePerOrg mode + return userRoles(user).map(this::getRootRole).collect(Collectors.toList()); + } + + private String getRootRole(String role) { + if (role.contains(separator)) { + return role.split(separator)[1]; + } + return role; + } + +} diff --git a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/model/GroupSyncMode.java b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/model/GroupSyncMode.java index 40a2b9a8e3..51820f9d02 100644 --- a/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/model/GroupSyncMode.java +++ b/georchestra-integration/externalized-accounts/src/main/java/org/geonetwork/security/external/model/GroupSyncMode.java @@ -25,5 +25,5 @@ * external system's Organizations or Roles. */ public enum GroupSyncMode { - orgs, roles; + orgs, roles, role_per_org; } diff --git a/georchestra-integration/externalized-accounts/src/test/java/org/geonetwork/security/external/integration/IntegrationTestSupport.java b/georchestra-integration/externalized-accounts/src/test/java/org/geonetwork/security/external/integration/IntegrationTestSupport.java index 21fec90c5e..105352b045 100644 --- a/georchestra-integration/externalized-accounts/src/test/java/org/geonetwork/security/external/integration/IntegrationTestSupport.java +++ b/georchestra-integration/externalized-accounts/src/test/java/org/geonetwork/security/external/integration/IntegrationTestSupport.java @@ -87,6 +87,10 @@ public void setRolesSyncMode() { configProps.setSyncMode(GroupSyncMode.roles); } + public void setRolePerOrgSyncMode() { + configProps.setSyncMode(GroupSyncMode.role_per_org); + } + public ProfileMappingProperties getProfileMappings() { return configProps.getProfiles(); } @@ -136,7 +140,7 @@ public void assertUser(CanonicalUser expected, User user) { assertEquals(expectedTitle, user.getKind()); ProfileMappingProperties profileMappings = configProps.getProfiles(); - Profile expectedProfile = profileMappings.resolveHighestProfileFromRoleNames(expected.getRoles()); + Profile expectedProfile = profileMappings.resolveHighestProfileFromRoleNames(groupSynchronizer.getRootRolesForUser(expected)); assertEquals(expectedProfile, user.getProfile()); } diff --git a/georchestra-integration/externalized-accounts/src/test/java/org/geonetwork/security/external/integration/RolePerOrgBasedSynchronizationIT.java b/georchestra-integration/externalized-accounts/src/test/java/org/geonetwork/security/external/integration/RolePerOrgBasedSynchronizationIT.java new file mode 100644 index 0000000000..6be5cdcf3a --- /dev/null +++ b/georchestra-integration/externalized-accounts/src/test/java/org/geonetwork/security/external/integration/RolePerOrgBasedSynchronizationIT.java @@ -0,0 +1,92 @@ +// java +package org.geonetwork.security.external.integration; + +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +import javax.transaction.Transactional; + +import org.assertj.core.util.Sets; +import org.fao.geonet.domain.Group; +import org.fao.geonet.domain.User; +import org.geonetwork.security.external.configuration.ExternalizedSecurityProperties; +import org.geonetwork.security.external.model.CanonicalGroup; +import org.geonetwork.security.external.model.CanonicalUser; +import org.geonetwork.security.external.model.GroupLink; +import org.geonetwork.security.external.model.UserLink; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.annotation.DirtiesContext; + +@Transactional +@DirtiesContext +public class RolePerOrgBasedSynchronizationIT extends AbstractAccountsReconcilingServiceIntegrationTest { + + @Before + public void setUp_SetSyncModeToRolePerOrg() { + support.setRolePerOrgSyncMode(); + } + + + @Test + public void Synchronize_on_empty_geonetwork_db_creates_all_users_and_groups_from_orgs() { + List users = super.defaultUsers; + List orgs = super.defaultGroups; + + assertEquals(0, support.gnUserRepository.count()); + assertEquals(0, support.gnGroupRepository.count()); + + service.synchronize(); + verify(users, orgs); + } + + @Test + public void RolePerOrg_user_with_prefixed_role_maps_to_org_group() { + List orgGroups = super.defaultGroups; + CanonicalGroup org = orgGroups.get(0); // just to be explicit + CanonicalGroup role = super.createRole("PSC:GN_REVIEWER"); // role name in external repo + List orgs = new ArrayList<>(super.defaultGroups); + orgs.add(org); + + // Create a user that belongs to organization "PSC" and has role "PSC:GN_REVIEWER" + CanonicalUser u = super.setUpNewUser("prefixed", org, role); + + when(canonicalAccountsRepositoryMock.findAllOrganizations()).thenReturn(orgs); + when(canonicalAccountsRepositoryMock.findAllUsers()).thenReturn(List.of(u)); + + service.synchronize(); + + // verify group link exists for the organization and user is linked to it + //support.assertGroupLinkassertUserLink(org); + UserLink link = support.assertUserLink(u); + support.assertGroup(link.getInternalUser(), org); + } + + + private void verify(List expectedUsers, List expectedOrgs) { + assertEquals(expectedOrgs.size(), support.groupLinkRepository.findAll().size()); + assertEquals(expectedUsers.size(), support.userLinkRepository.findAll().size()); + + for (CanonicalGroup expected : expectedOrgs) { + support.assertGroupLink(expected); + } + for (CanonicalUser expected : expectedUsers) { + support.assertUserLink(expected); + } + } +}