Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,9 @@ private Profile resolveUserProfile(@NonNull List<String> roles) {
return profileMappings.resolveHighestProfileFromRoleNames(roles);
}

Comment thread
f-necas marked this conversation as resolved.
@Override
public List<String> getRootRolesForUser(@NonNull CanonicalUser user) {
return user.getRoles();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,12 @@ interface GroupSynchronizer {
* {@link Group} and and external {@link GroupLink link} in the process.
*/
Privileges resolvePrivilegesFor(CanonicalUser user);

/**
* Returns the names of roles assigned to the given user in the external
* system, normalized by stripping any org/group prefix from the role name.
* Returned values therefore represent normalized role names and should not
* be interpreted as guaranteed global-only roles in the external system.
*/
List<String> getRootRolesForUser(@NonNull CanonicalUser user);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down Expand Up @@ -79,4 +82,8 @@ private GroupSynchronizer resolve() {
public @Override Privileges resolvePrivilegesFor(CanonicalUser user) {
return resolve().resolvePrivilegesFor(user);
}

public @Override List<String> getRootRolesForUser(CanonicalUser user) {
return resolve().getRootRolesForUser(user);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
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;

/**
* Synchronizes geOrchestra groups with external groups based on the user's organization and roles.
* For each organization, a corresponding geOrchestra group is created (if it doesn't exist) and the user is added to it if they belong to that organization.
* Additionally, if the user's roles contain an organization prefix (e.g., "ORG:ROLE"), the user is added to the group corresponding to that organization (e.g., "ORG").
*/
public class RolePerOrgBasedGroupSynchronizer extends AbstractGroupSynchronizer {
Comment thread
f-necas marked this conversation as resolved.

public static final Logger log = LoggerFactory.getLogger(RolePerOrgBasedGroupSynchronizer.class.getPackage().getName());

private static final String separator = ":";

@Autowired
Comment thread
f-necas marked this conversation as resolved.
public ExternalizedSecurityProperties config;

public RolePerOrgBasedGroupSynchronizer(CanonicalAccountsRepository canonicalAccounts) {
super(canonicalAccounts);
}

protected @Override GroupSyncMode getOrigin() {
return GroupSyncMode.role_per_org;
}

public @Override List<CanonicalGroup> fetchCanonicalGroups() {
return canonicalAccounts.findAllOrganizations();
}

protected @Override List<CanonicalGroup> resolveGroupsOf(CanonicalUser user) {
final String orgName = user.getOrganization();
Stream<String> groupsName = userRoles(user)
.flatMap(r -> {
if (r.contains(separator)) {
return Stream.of(r.split(separator)[0]);
}
return StringUtils.hasLength(orgName) ? Stream.of(orgName) : Stream.empty();
})
.distinct();
Stream<CanonicalGroup> roleGroups = groupsName.map(role -> this.externalGroupLinks.findByName(role)//
.map(GroupLink::getCanonical)
Comment thread
f-necas marked this conversation as resolved.
.or(() -> canonicalAccounts.findOrganizationByName(role))
.orElseThrow(notFound(role)));
return roleGroups.collect(Collectors.toList());
}

@Override
public Privileges resolvePrivilegesFor(CanonicalUser user) {
final List<CanonicalGroup> canonicalGroups = resolveGroupsOf(user);

Privileges userPrivileges = new Privileges(resolveDefaultProfile(user));
Stream<Group> 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<String> userRoles(CanonicalUser user) {
Pattern p = Pattern.compile(".+" + separator + ".+");
return user.getRoles().stream()
.filter(r -> p.matcher(r).matches() || config.getProfiles().getRolemappings().containsKey(r));
}
Comment thread
f-necas marked this conversation as resolved.

private Privilege resolvePrivilegeFor(CanonicalUser user, Group group) {
String groupPrefix = group.getName() + separator;
List<String> rolesForGroup = userRoles(user)
.filter(r -> {
if (r.contains(separator)) {
return r.startsWith(groupPrefix);
} else {
return group.getName().equals(user.getOrganization());
}
}) //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<? extends IllegalArgumentException> notFound(final String orgName) {
return () -> new IllegalArgumentException(
"Organization with name '" + orgName + "' not found in internal nor external repository");
}

@Override
public List<String> getRootRolesForUser(CanonicalUser user) {
// Used to resolve the user's global/default profile: returns relevant role names
// with any organization prefix stripped (for example, PSC:GN_REVIEWER -> GN_REVIEWER).
return userRoles(user).map(this::getRootRole).collect(Collectors.toList());
}

private String getRootRole(String role) {
if (role.contains(separator)) {
return role.split(separator)[1];
Comment thread
f-necas marked this conversation as resolved.
}
return role;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

import static java.util.Objects.*;

@Deprecated
public class RolesBasedGroupSynchronizer extends AbstractGroupSynchronizer {

public static final Logger log = LoggerFactory.getLogger(RolesBasedGroupSynchronizer.class.getPackage().getName());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,7 @@
* external system's Organizations or Roles.
*/
public enum GroupSyncMode {
orgs, roles;
orgs,
roles, //deprecated
role_per_org;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 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<CanonicalUser> users = super.defaultUsers;
List<CanonicalGroup> 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<CanonicalGroup> 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<CanonicalGroup> orgs = new ArrayList<>(super.defaultGroups);

// 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<CanonicalUser> expectedUsers, List<CanonicalGroup> 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);
}
}
}
2 changes: 1 addition & 1 deletion georchestra-integration/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<name>geOrchestra integration</name>
<properties>
<rootProjectDir>${basedir}/..</rootProjectDir>
<georchestra.version>25.0.0</georchestra.version>
<georchestra.version>25.0.3</georchestra.version>
</properties>
<modules>
<module>georchestra-utils</module>
Expand Down
Loading