Skip to content

Commit

Permalink
Merge pull request #13060 from SORMAS-Foundation/feature-#13033-sync-…
Browse files Browse the repository at this point in the history
…users-from-keycloak

Feature #13033 sync users from keycloak
  • Loading branch information
sergiupacurariu authored Apr 11, 2024
2 parents 2120597 + fc8d381 commit 1d1eb9b
Show file tree
Hide file tree
Showing 14 changed files with 224 additions and 56 deletions.
12 changes: 1 addition & 11 deletions sormas-api/src/main/java/de/symeda/sormas/api/AuthProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,14 @@ public class AuthProvider {

private final boolean isDefaultProvider;

private final boolean isUserSyncSupported;

private final boolean isUserSyncAtStartupEnabled;

private final String name;

private AuthProvider(ConfigFacade configFacade) {
String configuredProvider = configFacade.getAuthenticationProvider();
isDefaultProvider = SORMAS.equalsIgnoreCase(configuredProvider);
isUserSyncSupported = KEYCLOAK.equalsIgnoreCase(configuredProvider);
isUserSyncAtStartupEnabled = isUserSyncSupported && configFacade.isAuthenticationProviderUserSyncAtStartupEnabled();
isUserSyncAtStartupEnabled = KEYCLOAK.equalsIgnoreCase(configuredProvider) && configFacade.isAuthenticationProviderUserSyncAtStartupEnabled();
name = configuredProvider;
}

Expand All @@ -68,13 +65,6 @@ public boolean isDefaultProvider() {
return isDefaultProvider;
}

/**
* Authentication Provider enables users to be synced from the default provider.
*/
public boolean isUserSyncSupported() {
return isUserSyncSupported;
}

/**
* Even if the Authentication Provider supports user sync, the user sync at startup might be disabled for startup performance reasons.
* If user sync is not supported, this will always return false.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ public interface ConfigFacade {

boolean isAuthenticationProviderUserSyncAtStartupEnabled();

String getAuthenticationProviderSyncedNewUserRole();

boolean isExternalJournalActive();

int getDashboardMapMarkerLimit();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,8 @@ List<UserReferenceDto> getUserRefsByInfrastructure(
* @return A set containing the user rights associated to all user roles assigned to the user
*/
List<UserRight> getUserRights(String userUuid);

void syncUsersFromAuthenticationProvider();

boolean isSyncEnabled();
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public class ConfigFacadeEjb implements ConfigFacade {

private static final String AUTHENTICATION_PROVIDER = "authentication.provider";
private static final String AUTHENTICATION_PROVIDER_USER_SYNC_AT_STARTUP = "authentication.provider.userSyncAtStartup";
private static final String AUTHENTICATION_PROVIDER_SYNCED_NEW_USER_ROLE = "authentication.provider.syncedNewUserRole";

public static final String COUNTRY_NAME = "country.name";
public static final String COUNTRY_LOCALE = "country.locale";
Expand Down Expand Up @@ -659,6 +660,11 @@ public boolean isAuthenticationProviderUserSyncAtStartupEnabled() {
return getBoolean(AUTHENTICATION_PROVIDER_USER_SYNC_AT_STARTUP, false);
}

@Override
public String getAuthenticationProviderSyncedNewUserRole() {
return getProperty(AUTHENTICATION_PROVIDER_SYNCED_NEW_USER_ROLE, null);
}

@Override
public boolean isExternalJournalActive() {
return !StringUtils.isAllBlank(getProperty(INTERFACE_SYMPTOM_JOURNAL_URL, null), getProperty(INTERFACE_PATIENT_DIARY_URL, null));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import de.symeda.sormas.backend.systemevent.SystemEventFacadeEjb.SystemEventFacadeEjbLocal;
import de.symeda.sormas.backend.task.TaskFacadeEjb.TaskFacadeEjbLocal;
import de.symeda.sormas.backend.travelentry.TravelEntryFacadeEjb;
import de.symeda.sormas.backend.user.UserFacadeEjb.UserFacadeEjbLocal;

@Singleton
@RunAs(UserRight._SYSTEM)
Expand Down Expand Up @@ -93,6 +94,8 @@ public class CronService {
private CoreEntityDeletionService coreEntityDeletionService;
@EJB
private SpecialCaseAccessFacadeEjbLocal specialCaseAccessFacade;
@EJB
private UserFacadeEjbLocal userFacade;

@Schedule(hour = "*", minute = "*/" + TASK_UPDATE_INTERVAL, second = "0", persistent = false)
public void sendNewAndDueTaskMessages() {
Expand Down Expand Up @@ -184,12 +187,11 @@ public void archiveEvents() {

final int daysAfterEventsGetsArchived = featureConfigurationFacade
.getProperty(FeatureType.AUTOMATIC_ARCHIVING, DeletableEntityType.EVENT, FeatureTypeProperty.THRESHOLD_IN_DAYS, Integer.class);
final int daysAfterEventParticipantsGetsArchived = featureConfigurationFacade
.getProperty(
FeatureType.AUTOMATIC_ARCHIVING,
DeletableEntityType.EVENT_PARTICIPANT,
FeatureTypeProperty.THRESHOLD_IN_DAYS,
Integer.class);
final int daysAfterEventParticipantsGetsArchived = featureConfigurationFacade.getProperty(
FeatureType.AUTOMATIC_ARCHIVING,
DeletableEntityType.EVENT_PARTICIPANT,
FeatureTypeProperty.THRESHOLD_IN_DAYS,
Integer.class);
if (daysAfterEventsGetsArchived < daysAfterEventParticipantsGetsArchived) {
logger.warn(
"{} for {} [{}] should be <= the one for {} [{}]",
Expand Down Expand Up @@ -253,12 +255,11 @@ public void archiveContacts() {

@Schedule(hour = "2", minute = "20", persistent = false)
public void archiveEventParticipants() {
final int daysAfterEventParticipantGetsArchived = featureConfigurationFacade
.getProperty(
FeatureType.AUTOMATIC_ARCHIVING,
DeletableEntityType.EVENT_PARTICIPANT,
FeatureTypeProperty.THRESHOLD_IN_DAYS,
Integer.class);
final int daysAfterEventParticipantGetsArchived = featureConfigurationFacade.getProperty(
FeatureType.AUTOMATIC_ARCHIVING,
DeletableEntityType.EVENT_PARTICIPANT,
FeatureTypeProperty.THRESHOLD_IN_DAYS,
Integer.class);

if (daysAfterEventParticipantGetsArchived >= 1) {
eventParticipantFacade.archiveAllArchivableEventParticipants(daysAfterEventParticipantGetsArchived);
Expand All @@ -273,7 +274,7 @@ public void archiveImmunizations() {
if (daysAfterImmunizationsGetsArchived >= 1) {
immunizationFacade.archiveAllArchivableImmunizations(daysAfterImmunizationsGetsArchived);
}
}
}

@Schedule(hour = "2", minute = "30", persistent = false)
public void archiveTravelEntry() {
Expand All @@ -289,4 +290,11 @@ public void archiveTravelEntry() {
public void deleteExpiredSpecialCaseAccesses() {
specialCaseAccessFacade.deleteExpiredSpecialCaseAccesses();
}

@Schedule(hour = "2", minute = "35", persistent = false)
public void syncUsersFromAuthenticationProvider() {
if (userFacade.isSyncEnabled() && featureConfigurationFacade.isFeatureEnabled(FeatureType.AUTH_PROVIDER_TO_SORMAS_USER_SYNC)) {
userFacade.syncUsersFromAuthenticationProvider();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -616,11 +616,6 @@ private void syncUsers() {

AuthProvider authProvider = AuthProvider.getProvider(configFacade);

if (!authProvider.isUserSyncSupported()) {
logger.info("Active Authentication Provider {} doesn't support user sync", authProvider.getName());
return;
}

if (!authProvider.isUserSyncAtStartupEnabled()) {
logger.info("User sync at startup is disabled. Enable this in SORMAS properties if the active Authentication Provider supports it");
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import javax.annotation.PostConstruct;
import javax.ejb.EJB;
Expand Down Expand Up @@ -52,13 +54,15 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Functions;
import com.jayway.jsonpath.JsonPath;

import de.symeda.sormas.api.AuthProvider;
import de.symeda.sormas.api.Language;
import de.symeda.sormas.api.user.UserRight;
import de.symeda.sormas.backend.common.ConfigFacadeEjb.ConfigFacadeEjbLocal;
import de.symeda.sormas.backend.user.event.PasswordResetEvent;
import de.symeda.sormas.backend.user.event.SyncUsersFromProviderEvent;
import de.symeda.sormas.backend.user.event.UserCreateEvent;
import de.symeda.sormas.backend.user.event.UserUpdateEvent;

Expand Down Expand Up @@ -229,6 +233,34 @@ public void handlePasswordResetEvent(@Observes PasswordResetEvent passwordResetE
}
}

public void handleSyncUsersFromProviderEvent(@Observes SyncUsersFromProviderEvent syncUsersFromProviderEvent) {
Optional<Keycloak> keycloak = getKeycloakInstance();
if (keycloak.isEmpty()) {
logger.warn("Cannot obtain keycloak instance. Will not sync users from provider");
return;
}

List<User> existingUsers = syncUsersFromProviderEvent.getExistingUsers();
Map<String, User> existingUsersByUsername =
existingUsers.stream().collect(Collectors.toMap(user1 -> user1.getUserName().toLowerCase(), Functions.identity()));
List<UserRepresentation> providerUsers = keycloak.get().realm(REALM_NAME).users().list();
List<User> syncedUsers = providerUsers.stream().map(user -> {
User sormasUser = existingUsersByUsername.get(user.getUsername().toLowerCase());
if (sormasUser == null) {
sormasUser = new User();
}
updateUser(sormasUser, user);

return sormasUser;
}).collect(Collectors.toList());

Set<String> providerUserNames = providerUsers.stream().map(UserRepresentation::getUsername).collect(Collectors.toSet());
List<User> deletedUsers = existingUsers.stream().filter(user -> !providerUserNames.contains(user.getUserName())).collect(Collectors.toList());

syncUsersFromProviderEvent.getCallback().accept(syncedUsers, deletedUsers);

}

/**
* Creates a {@link UserRepresentation} from the SORMAS user and send the request to create the user to Keycloak.
*
Expand Down Expand Up @@ -274,6 +306,15 @@ private UserRepresentation createUserRepresentation(User user, String hashedPass
return userRepresentation;
}

private void updateUser(User user, UserRepresentation userRepresentation) {
user.setActive(userRepresentation.isEnabled());
user.setUserName(userRepresentation.getUsername());
user.setFirstName(userRepresentation.getFirstName());
user.setLastName(userRepresentation.getLastName());
user.setLanguage(getLanguage(userRepresentation));
user.setUserEmail(userRepresentation.getEmail());
}

private Optional<UserRepresentation> updateUser(Keycloak keycloak, String existingUsername, User newUser) {

Optional<UserRepresentation> userRepresentation = getUserByUsername(keycloak, existingUsername);
Expand Down Expand Up @@ -371,6 +412,17 @@ private void sendPasswordResetEmail(Keycloak keycloak, String userId) {
keycloak.realm(REALM_NAME).users().get(userId).executeActionsEmail(Collections.singletonList(ACTION_UPDATE_PASSWORD));
}

private Language getLanguage(UserRepresentation userRepresentation) {
Map<String, List<String>> attributes = userRepresentation.getAttributes();
if (attributes != null) {
List<String> locale = attributes.get(LOCALE);
if (locale != null && !locale.isEmpty()) {
return Language.fromLocaleString(locale.get(0));
}
}
return null;
}

private void setLanguage(UserRepresentation userRepresentation, Language language) {
Map<String, List<String>> attributes = userRepresentation.getAttributes();
if (attributes == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/
package de.symeda.sormas.backend.user;

import static de.symeda.sormas.api.AuthProvider.KEYCLOAK;
import static java.util.Objects.isNull;

import java.lang.reflect.InvocationTargetException;
Expand Down Expand Up @@ -50,6 +51,7 @@
import javax.persistence.criteria.Subquery;
import javax.validation.Valid;
import javax.validation.ValidationException;
import javax.ws.rs.ForbiddenException;

import org.apache.commons.beanutils.BeanUtils;
import org.apache.commons.lang3.StringUtils;
Expand All @@ -58,6 +60,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import de.symeda.sormas.api.AuthProvider;
import de.symeda.sormas.api.Disease;
import de.symeda.sormas.api.EntityDto;
import de.symeda.sormas.api.InfrastructureDataReferenceDto;
Expand Down Expand Up @@ -102,6 +105,7 @@
import de.symeda.sormas.backend.caze.CaseQueryContext;
import de.symeda.sormas.backend.caze.CaseService;
import de.symeda.sormas.backend.common.AbstractDomainObject;
import de.symeda.sormas.backend.common.ConfigFacadeEjb.ConfigFacadeEjbLocal;
import de.symeda.sormas.backend.common.CriteriaBuilderHelper;
import de.symeda.sormas.backend.contact.Contact;
import de.symeda.sormas.backend.contact.ContactJoins;
Expand All @@ -114,7 +118,7 @@
import de.symeda.sormas.backend.environment.EnvironmentQueryContext;
import de.symeda.sormas.backend.event.EventJurisdictionPredicateValidator;
import de.symeda.sormas.backend.event.EventQueryContext;
import de.symeda.sormas.backend.feature.FeatureConfigurationFacadeEjb;
import de.symeda.sormas.backend.feature.FeatureConfigurationFacadeEjb.FeatureConfigurationFacadeEjbLocal;
import de.symeda.sormas.backend.infrastructure.community.Community;
import de.symeda.sormas.backend.infrastructure.community.CommunityFacadeEjb;
import de.symeda.sormas.backend.infrastructure.community.CommunityService;
Expand All @@ -140,6 +144,7 @@
import de.symeda.sormas.backend.travelentry.TravelEntryQueryContext;
import de.symeda.sormas.backend.user.UserRoleFacadeEjb.UserRoleFacadeEjbLocal;
import de.symeda.sormas.backend.user.event.PasswordResetEvent;
import de.symeda.sormas.backend.user.event.SyncUsersFromProviderEvent;
import de.symeda.sormas.backend.user.event.UserCreateEvent;
import de.symeda.sormas.backend.user.event.UserUpdateEvent;
import de.symeda.sormas.backend.util.DtoHelper;
Expand Down Expand Up @@ -182,17 +187,21 @@ public class UserFacadeEjb implements UserFacade {
@EJB
private UserRoleFacadeEjbLocal userRoleFacade;
@EJB
private FeatureConfigurationFacadeEjb.FeatureConfigurationFacadeEjbLocal featureConfigurationFacade;
private FeatureConfigurationFacadeEjbLocal featureConfigurationFacade;
@EJB
private UserRoleService userRoleService;
@EJB
private PersonService personService;
@EJB
private ConfigFacadeEjbLocal configFacade;
@Inject
private Event<UserCreateEvent> userCreateEvent;
@Inject
private Event<UserUpdateEvent> userUpdateEvent;
@Inject
private Event<PasswordResetEvent> passwordResetEvent;
@Inject
private Event<SyncUsersFromProviderEvent> syncUsersFromProviderEventEvent;

public static UserDto toDto(User source) {

Expand Down Expand Up @@ -1042,6 +1051,56 @@ public UserSyncResult syncUser(String uuid) {
return userSyncResult;
}

@Override
@RightsAllowed({
UserRight._USER_CREATE,
UserRight._USER_EDIT,
UserRight._SYSTEM })
public void syncUsersFromAuthenticationProvider() {

if (!isSyncEnabled()) {
throw new ForbiddenException("No default role for new users from authentication provider is configured");
}

String defaultRoleName = configFacade.getAuthenticationProviderSyncedNewUserRole();
UserRole defaultRole = userRoleService.getByCaption(defaultRoleName);

if (defaultRole == null) {
throw new ForbiddenException("No default role for new users from authentication provider is configured");
}

List<User> existingUsers = userService.getAll();

syncUsersFromProviderEventEvent.fire(new SyncUsersFromProviderEvent(existingUsers, (syncedUsers, deletedUsers) -> {
syncedUsers.forEach(user -> {
if (user.getId() == null) {
user.setUuid(DataHelper.createUuid());
user.setUserRoles(Collections.singleton(defaultRole));
UserService.setNewPassword(user);
}
userService.ensurePersisted(user);
});

deletedUsers.forEach(user -> {
user.setActive(false);
userService.ensurePersisted(user);
});
}));
}

@Override
@RightsAllowed({
UserRight._USER_CREATE,
UserRight._USER_EDIT,
UserRight._SYSTEM })
public boolean isSyncEnabled() {
AuthProvider authProvider = AuthProvider.getProvider(configFacade);
return KEYCLOAK.equalsIgnoreCase(authProvider.getName())
&& (featureConfigurationFacade.isFeatureDisabled(FeatureType.AUTH_PROVIDER_TO_SORMAS_USER_SYNC)
|| StringUtils.isNotBlank(configFacade.getAuthenticationProviderSyncedNewUserRole()));

}

@Override
// TODO - default password change only for ADMIN??
@PermitAll
Expand Down
Loading

0 comments on commit 1d1eb9b

Please sign in to comment.