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
4 changes: 4 additions & 0 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ jobs:
sudo sed -i 's/^couchdb.user\s*=/& '${COUCHDB_USER}'/' /etc/sw360/couchdb-test.properties
sudo sed -i 's/^couchdb.password\s*=/& '${COUCHDB_PASSWORD}'/' /etc/sw360/couchdb-test.properties

- name: Copy test configuration files
run: |
sudo cp ./build-configuration/resources/orgmapping.properties /etc/sw360/

- name: Set up JDK 21
uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
with:
Expand Down
40 changes: 40 additions & 0 deletions build-configuration/resources/orgmapping.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#
# Copyright Siemens AG, 2024-2026. Part of the SW360 Portal Project.
#
# This program and the accompanying materials are made
# available under the terms of the Eclipse Public License 2.0
# which is available at https://www.eclipse.org/legal/epl-2.0/
#
# SPDX-License-Identifier: EPL-2.0
#

# N.B this is the default build property file for organization mapping

# Enable custom organization mapping
enable.custom.mapping=true

# Enable prefix matching for hierarchical department structures
match.prefix=true

# Organization mappings for test scenarios
# Format: mapping.<id>=<source> and mapping.<id>.target=<target>

# Multi-word department mapping (simulating hierarchical structures)
mapping.1=ORG UNIT DEPT TEAM SUBTEAM
mapping.1.target=MAPPED_DEPT_A

mapping.2=ORG UNIT DEPT TEAM
mapping.2.target=MAPPED_DEPT_B

mapping.3=EXTERNAL_COMPANY
mapping.3.target=EXTERNAL

mapping.4=Engineering
mapping.4.target=ENG

mapping.5=UPPERCASE
mapping.5.target=UPPER_MAPPED

# Prefix matching test cases
mapping.6=PREFIX
mapping.6.target=PREFIX_MAPPED
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* Copyright Bosch Software Innovations GmbH, 2016.
* Copyright Siemens AG, 2016-2019, 2024-2026.
* Part of the SW360 Portal Project.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.sw360.keycloak.event.listener.service;

import org.eclipse.sw360.datahandler.common.CommonUtils;
import org.jboss.logging.Logger;

import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;

/**
* Helper class to map organization from identity provider to sw360 internal org names.
* Uses intelligent lazy loading with automatic retry if initial load fails.
*/
public class OrganizationMapper {

private static final Logger log = Logger.getLogger(OrganizationMapper.class);

private static final String MAPPING_KEYS_PREFIX = "mapping.";
private static final String MAPPING_VALUES_SUFFIX = ".target";
private static final String MATCH_PREFIX_KEY = "match.prefix";
private static final String ENABLE_CUSTOM_MAPPING_KEY = "enable.custom.mapping";
private static final String PROPERTIES_FILE_PATH = "/orgmapping.properties";

private static boolean matchPrefix = false;
private static boolean customMappingEnabled = false;
private static List<Map.Entry<String, String>> sortedOrganizationMappings = new ArrayList<>();

// Flags for intelligent loading
private static volatile boolean initialized = false;
private static volatile boolean loadAttempted = false;
private static final Object INIT_LOCK = new Object();

static {
tryInitialize();
}

/**
* Attempts to initialize the mapper. If initialization fails, it can be retried later.
*/
private static void tryInitialize() {
synchronized (INIT_LOCK) {
if (!initialized) {
loadAttempted = true;
loadOrganizationMapperSettings();
}
}
}

/**
* Ensures the mapper is properly initialized before use.
* If initial load failed and properties are empty, attempts to reload once.
*/
private static void ensureInitialized() {
if (initialized) {
return;
}

synchronized (INIT_LOCK) {
if (!initialized && loadAttempted) {
// Initial load was attempted but failed, try one more time
log.info("Re-attempting to load organization mapping properties...");
loadOrganizationMapperSettings();
} else if (!loadAttempted) {
// Static block didn't run yet (unusual), initialize now
tryInitialize();
}
}
}

/**
* Maps organization name from identity provider to sw360 internal org name.
* If custom mapping is disabled, returns the original name.
*
* @param name the organization name from identity provider
* @return the mapped organization name or original if no mapping found
*/
public static String mapOrganizationName(String name) {
// Ensure initialization before accessing configuration
ensureInitialized();

if (name == null || name.isEmpty()) {
return name;
}

if (!isCustomMappingEnabled()) {
log.debug("Custom organization mapping is disabled, returning original name: " + name);
return name;
}

final Predicate<Map.Entry<String, String>> matcher;
if (isMatchPrefixEnabled()) {
matcher = e -> name.startsWith(e.getKey());
} else {
// match complete name
matcher = e -> name.equals(e.getKey());
}

String mappedName = sortedOrganizationMappings.stream()
.filter(matcher)
.findFirst()
.map(Map.Entry::getValue)
.orElse(name);

if (!mappedName.equals(name)) {
log.info("Mapped organization name from '" + name + "' to '" + mappedName + "'");
}

return mappedName;
}

/**
* Check if custom organization mapping is enabled.
*
* @return true if custom mapping is enabled
*/
public static boolean isCustomMappingEnabled() {
ensureInitialized();
return customMappingEnabled;
}

/**
* Check if prefix matching is enabled.
*
* @return true if prefix matching is enabled
*/
public static boolean isMatchPrefixEnabled() {
ensureInitialized();
return matchPrefix;
}

private static void loadOrganizationMapperSettings() {
log.info("Initializing OrganizationMapper...");

// Load properties with preference to system configuration path (e.g., /etc/sw360/orgmapping.properties)
// System config properties override bundled resource properties
Properties orgmappingProperties = CommonUtils.loadProperties(OrganizationMapper.class, PROPERTIES_FILE_PATH, true);

if (orgmappingProperties.isEmpty()) {
log.info("No organization mapping properties found at " + PROPERTIES_FILE_PATH
+ " (checked system config path: " + CommonUtils.SYSTEM_CONFIGURATION_PATH + "), custom mapping disabled");
// Don't mark as initialized - allow retry on next access
return;
}

// Mark as initialized since we successfully loaded properties
initialized = true;

log.info("Organization mapping properties loaded from system config path: "
+ CommonUtils.SYSTEM_CONFIGURATION_PATH + PROPERTIES_FILE_PATH + " (if exists) or bundled resource");

matchPrefix = Boolean.parseBoolean(orgmappingProperties.getProperty(MATCH_PREFIX_KEY, "false"));
customMappingEnabled = Boolean.parseBoolean(orgmappingProperties.getProperty(ENABLE_CUSTOM_MAPPING_KEY, "false"));

if (!customMappingEnabled) {
log.info("Custom organization mapping is disabled via configuration");
return;
}

List<String> mappingSourceKeys = orgmappingProperties
.stringPropertyNames()
.stream()
.filter(p -> p.startsWith(MAPPING_KEYS_PREFIX) && !p.endsWith(MAPPING_VALUES_SUFFIX))
.toList();

Map<String, String> tempOrgMappings = new HashMap<>();
for (String sourceKey : mappingSourceKeys) {
String sourceOrg = orgmappingProperties.getProperty(sourceKey);
String targetOrg = orgmappingProperties.getProperty(sourceKey + MAPPING_VALUES_SUFFIX);
if (sourceOrg != null && targetOrg != null && !sourceOrg.isEmpty() && !targetOrg.isEmpty()) {
tempOrgMappings.put(sourceOrg, targetOrg);
log.debug("Loaded organization mapping: '" + sourceOrg + "' -> '" + targetOrg + "'");
}
}

// Sort by key length in descending order for longest match first
sortedOrganizationMappings = tempOrgMappings
.entrySet()
.stream()
.sorted(Comparator.comparingInt((Map.Entry<String, String> o) -> o.getKey().length()).reversed())
.collect(Collectors.toList());

log.info(String.format("OrganizationMapper initialized with %d mappings, matchPrefix=%s",
sortedOrganizationMappings.size(), matchPrefix));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
import org.eclipse.sw360.keycloak.event.model.Group;
import org.eclipse.sw360.keycloak.event.model.UserEntity;
import org.jboss.logging.Logger;
import org.jetbrains.annotations.NotNull;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
Expand All @@ -25,16 +27,17 @@
import java.util.Optional;

import static org.eclipse.sw360.datahandler.common.SW360Constants.TYPE_USER;
import static org.eclipse.sw360.keycloak.event.listener.service.Sw360UserService.CUSTOM_ATTR_DEPARTMENT;
import static org.eclipse.sw360.keycloak.event.listener.service.Sw360UserService.CUSTOM_ATTR_EXTERNAL_ID;
import static org.eclipse.sw360.keycloak.event.listener.service.Sw360UserService.DEFAULT_DEPARTMENT;
import static org.eclipse.sw360.keycloak.event.listener.service.Sw360UserService.DEFAULT_EXTERNAL_ID;
import static org.eclipse.sw360.keycloak.event.listener.service.Sw360UserService.REALM;

public class Sw360KeycloakAdminEventService {
private static final Logger log = Logger.getLogger(Sw360KeycloakAdminEventService.class);
private final ObjectMapper objectMapper;
private final Sw360UserService userService;
private final KeycloakSession keycloakSession;
private static final String REALM = "sw360";

private static final String CUSTOM_ATTR_DEPARTMENT = "Department";
private static final String CUSTOM_ATTR_EXTERNAL_ID = "externalId";

public Sw360KeycloakAdminEventService(Sw360UserService sw360UserService, ObjectMapper objectMapper, KeycloakSession keycloakSession) {
this.objectMapper = objectMapper;
Expand Down Expand Up @@ -86,6 +89,9 @@ private UserModel getUserModelFromSession(String resourcePath) {
private String getUserIdfromResourcePath(String resourcePath) {
int startIndex = resourcePath.indexOf("users/") + "users/".length();
int endIndex = resourcePath.indexOf("/", startIndex);
if (endIndex == -1) {
return resourcePath.substring(startIndex);
}
return resourcePath.substring(startIndex, endIndex);
}

Expand All @@ -95,16 +101,23 @@ public void createUserOperation(AdminEvent event) {
UserEntity userEntity = objectMapper.readValue(event.getRepresentation(), UserEntity.class);
User sw360User = convertEntityToUserThriftObj(userEntity);
log.debugf("Converted Entity:: %s", sw360User);
// Set user group if exists in CouchDB
Optional<User> existingUser = Optional.ofNullable(userService.getUserByEmail(sw360User.getEmail()));
existingUser.ifPresent(eu -> {
sw360User.setUserGroup(eu.getUserGroup());
updateKeycloakUserGroup(event, eu.getUserGroup());
});

Optional<User> user = Optional.ofNullable(userService.createOrUpdateUser(sw360User));
user.ifPresentOrElse((u) -> {
log.infof("Saved User Couchdb Id:: %s" ,u.getId());
log.infof("Saved User Couchdb Id:: %s", u.getId());
}, () -> {
log.info("User not saved may be as it returned null!");
});
} catch (JsonMappingException e) {
log.errorf("CustomEventListenerSW360::onEvent(_,_)::Json mapping error: %s" , e);
log.errorf("CustomEventListenerSW360::onEvent(_,_)::Json mapping error: %s", e);
} catch (JsonProcessingException e) {
log.errorf("CustomEventListenerSW360::onEvent(_,_)::Json processing error: %s" , e);
log.errorf("CustomEventListenerSW360::onEvent(_,_)::Json processing error: %s", e);
}
}

Expand Down Expand Up @@ -166,12 +179,47 @@ private void setUserGroup(UserEntity userEntity, User user) {
}

private static void setDepartment(UserEntity userEntity, User user) {
List<String> userDepartment = userEntity.getAttributes().getOrDefault(CUSTOM_ATTR_DEPARTMENT, List.of("Unknown"));
user.setDepartment(userDepartment.getFirst());
List<String> userDepartment = userEntity.getAttributes().getOrDefault(CUSTOM_ATTR_DEPARTMENT, List.of(DEFAULT_DEPARTMENT));
String department = Sw360KeycloakUserEventService.sanitizeDepartment(userDepartment.getFirst());
user.setDepartment(department);
}

private static void setExternalId(UserEntity userEntity, User user) {
List<String> userExternalId = userEntity.getAttributes().getOrDefault(CUSTOM_ATTR_EXTERNAL_ID, List.of("N/A"));
user.setExternalid(userExternalId.getFirst());
List<String> userExternalId = userEntity.getAttributes().getOrDefault(CUSTOM_ATTR_EXTERNAL_ID, List.of(DEFAULT_EXTERNAL_ID));
String externalId = Sw360KeycloakUserEventService.sanitizeExternalId(userExternalId.getFirst());
user.setExternalid(externalId);
}

/**
* User was created in CouchDB (prob by SW360 application). Update
* KeyCloak's user model to have the group membership from CouchDB values.
* @param event Event which is triggered.
* @param userGroup New UserGroup to assign to KC user
*/
private void updateKeycloakUserGroup(@NotNull AdminEvent event, @NotNull UserGroup userGroup) {
String resourcePath = event.getResourcePath();
RealmModel realm = keycloakSession.realms().getRealmByName(REALM);
UserModel userModel = getUserModelFromSession(event.getResourcePath());

if (userModel != null) {
String groupName = userGroup.toString();
Optional<GroupModel> groupModel = realm.getGroupsStream()
.filter(g -> g.getName().equalsIgnoreCase(groupName))
.findFirst();

groupModel.ifPresent(g -> {
if (!userModel.isMemberOf(g)) {
userModel.getGroupsStream().forEach(userModel::leaveGroup);
userModel.joinGroup(g);
log.infof("Updated KeyCloak user group to %s for user %s", groupName, userModel.getEmail());
}
});

if (groupModel.isEmpty()) {
log.warnf("Group %s not found in KeyCloak", groupName);
}
} else {
log.warnf("User with id %s not found in Keycloak", getUserIdfromResourcePath(resourcePath));
}
}
}
Loading