From 50acb71c15f297df6f2c43a2004552aa8875e5eb Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Thu, 31 Oct 2024 09:29:38 +0100 Subject: [PATCH] Initial import --- .../java/org/keycloak/common/Profile.java | 2 + federation/pom.xml | 1 + federation/scim/pom.xml | 92 ++++++ .../sh/libre/scim/core/ScimDispatcher.java | 171 +++++++++++ ...ntConfigurationStorageProviderFactory.java | 165 ++++++++++ .../scim/core/ScrimEndPointConfiguration.java | 101 +++++++ .../InconsistentScimMappingException.java | 7 + ...alidResponseFromScimEndpointException.java | 29 ++ .../core/exceptions/RollbackApproach.java | 55 ++++ .../core/exceptions/RollbackStrategy.java | 22 ++ .../core/exceptions/ScimExceptionHandler.java | 43 +++ .../exceptions/ScimPropagationException.java | 12 + .../core/exceptions/SkipOrStopApproach.java | 59 ++++ .../core/exceptions/SkipOrStopStrategy.java | 66 ++++ .../UnexpectedScimDataException.java | 7 + .../core/service/AbstractScimService.java | 281 ++++++++++++++++++ .../core/service/EntityOnRemoteScimId.java | 6 + .../scim/core/service/GroupScimService.java | 131 ++++++++ .../libre/scim/core/service/KeycloakDao.java | 81 +++++ .../libre/scim/core/service/KeycloakId.java | 7 + .../libre/scim/core/service/ScimClient.java | 155 ++++++++++ .../scim/core/service/ScimResourceType.java | 29 ++ .../scim/core/service/UserScimService.java | 145 +++++++++ .../ScimBackgroundGroupMembershipUpdater.java | 74 +++++ .../scim/event/ScimEventListenerProvider.java | 247 +++++++++++++++ .../ScimEventListenerProviderFactory.java | 36 +++ .../sh/libre/scim/jpa/ScimResourceDao.java | 96 ++++++ .../sh/libre/scim/jpa/ScimResourceId.java | 83 ++++++ .../libre/scim/jpa/ScimResourceMapping.java | 89 ++++++ .../libre/scim/jpa/ScimResourceProvider.java | 29 ++ .../scim/jpa/ScimResourceProviderFactory.java | 39 +++ .../META-INF/jboss-deployment-structure.xml | 10 + .../META-INF/scim-resource-changelog.xml | 35 +++ ...pa.entityprovider.JpaEntityProviderFactory | 1 + ...ycloak.events.EventListenerProviderFactory | 1 + ...eycloak.storage.UserStorageProviderFactory | 1 + quarkus/server/pom.xml | 4 + 37 files changed, 2412 insertions(+) create mode 100644 federation/scim/pom.xml create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/ScimDispatcher.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/service/AbstractScimService.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/service/GroupScimService.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/service/KeycloakDao.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/service/KeycloakId.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/service/ScimClient.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/service/ScimResourceType.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/core/service/UserScimService.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceId.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java create mode 100644 federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java create mode 100644 federation/scim/src/main/resources/META-INF/jboss-deployment-structure.xml create mode 100644 federation/scim/src/main/resources/META-INF/scim-resource-changelog.xml create mode 100644 federation/scim/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory create mode 100644 federation/scim/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory create mode 100644 federation/scim/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index b3551549da..3107930411 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -112,6 +112,8 @@ public class Profile { OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL), + SCIM("Synchronise users and groups with registered SCIM endpoints", Type.EXPERIMENTAL), + OPENTELEMETRY("OpenTelemetry Tracing", Type.PREVIEW), DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL), diff --git a/federation/pom.xml b/federation/pom.xml index 314e274a73..74edc9ae82 100755 --- a/federation/pom.xml +++ b/federation/pom.xml @@ -36,6 +36,7 @@ kerberos ldap sssd + scim diff --git a/federation/scim/pom.xml b/federation/scim/pom.xml new file mode 100644 index 0000000000..0326a1c570 --- /dev/null +++ b/federation/scim/pom.xml @@ -0,0 +1,92 @@ + + + + keycloak-parent + org.keycloak + 999.0.0-SNAPSHOT + ../../pom.xml + + 4.0.0 + + keycloak-scim-federation + Keycloak Federation from SCIM endpoints + + This extension add SCIM2 client capabilities to Keycloak. + It allows to : + * Declare SCIM endpoints (through the identity federation UI). Any tool implementing SCIM protocol can be wired to the + Keycloack instance through this declaration. + * Propagate users and groups from Keycloack to SCIM endpoints : when a user/group gets created or modified in Keycloack, + the modification is fowarded to all declared SCIM endpoints through SCIM calls within the transaction scope. If + propagation fails, changes can be rolled back or not according to a configurable rollback strategy. + * Import users and groups from SCIM endpoints (through the Keycloack synchronization mechanism). + See [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643) + and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644)) for further details + + + + 1.26.0 + 2.2.0 + + + + junit + junit + test + + + net.java.dev.jna + jna + provided + + + org.keycloak + keycloak-core + provided + + + org.keycloak + keycloak-server-spi + provided + + + org.keycloak + keycloak-server-spi-private + provided + + + org.keycloak + keycloak-services + provided + + + org.keycloak + keycloak-model-jpa + provided + + + org.jboss.logging + jboss-logging + provided + + + io.github.resilience4j + resilience4j-retry + ${r4j-version} + provided + + + de.captaingoldfish + scim-sdk-common + ${scim-sdk-version} + provided + + + de.captaingoldfish + scim-sdk-client + ${scim-sdk-version} + provided + + + + diff --git a/federation/scim/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/federation/scim/src/main/java/sh/libre/scim/core/ScimDispatcher.java new file mode 100644 index 0000000000..d3d675108b --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -0,0 +1,171 @@ +package sh.libre.scim.core; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import sh.libre.scim.core.exceptions.ScimExceptionHandler; +import sh.libre.scim.core.exceptions.ScimPropagationException; +import sh.libre.scim.core.exceptions.SkipOrStopApproach; +import sh.libre.scim.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.service.AbstractScimService; +import sh.libre.scim.core.service.GroupScimService; +import sh.libre.scim.core.service.UserScimService; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * In charge of sending SCIM Request to all registered Scim endpoints. + */ +public class ScimDispatcher { + + private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); + + private final KeycloakSession session; + private final ScimExceptionHandler exceptionHandler; + private final SkipOrStopStrategy skipOrStopStrategy; + private boolean clientsInitialized = false; + private final List userScimServices = new ArrayList<>(); + private final List groupScimServices = new ArrayList<>(); + + + public ScimDispatcher(KeycloakSession session) { + this.session = session; + this.exceptionHandler = new ScimExceptionHandler(session); + // By default, use a permissive Skip or Stop strategy + this.skipOrStopStrategy = SkipOrStopApproach.ALWAYS_SKIP_AND_CONTINUE; + } + + /** + * Lists all active ScimStorageProviderFactory and create new ScimClients for each of them + */ + public void refreshActiveScimEndpoints() { + // Step 1: close existing clients (as configuration may have changed) + groupScimServices.forEach(GroupScimService::close); + groupScimServices.clear(); + userScimServices.forEach(UserScimService::close); + userScimServices.clear(); + + // Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory) + session.getContext().getRealm().getComponentsStream() + .filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId()) + && m.get("enabled", true)) + .forEach(scimEndpointConfigurationRaw -> { + try { + ScrimEndPointConfiguration scrimEndPointConfiguration = new ScrimEndPointConfiguration(scimEndpointConfigurationRaw); + + // Step 3 : create scim clients for each endpoint + if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { + GroupScimService groupScimService = new GroupScimService(session, scrimEndPointConfiguration, skipOrStopStrategy); + groupScimServices.add(groupScimService); + } + if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER, false)) { + UserScimService userScimService = new UserScimService(session, scrimEndPointConfiguration, skipOrStopStrategy); + userScimServices.add(userScimService); + } + } catch (IllegalArgumentException e) { + if (skipOrStopStrategy.allowInvalidEndpointConfiguration()) { + LOGGER.warn("[SCIM] Invalid Endpoint configuration " + scimEndpointConfigurationRaw.getId(), e); + } else { + throw e; + } + } + }); + } + + public void dispatchUserModificationToAll(SCIMPropagationConsumer operationToDispatch) { + initializeClientsIfNeeded(); + Set servicesCorrectlyPropagated = new LinkedHashSet<>(); + userScimServices.forEach(userScimService -> { + try { + operationToDispatch.acceptThrows(userScimService); + servicesCorrectlyPropagated.add(userScimService); + } catch (ScimPropagationException e) { + exceptionHandler.handleException(userScimService.getConfiguration(), e); + } + }); + // TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints + LOGGER.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); + } + + public void dispatchGroupModificationToAll(SCIMPropagationConsumer operationToDispatch) { + initializeClientsIfNeeded(); + Set servicesCorrectlyPropagated = new LinkedHashSet<>(); + groupScimServices.forEach(groupScimService -> { + try { + operationToDispatch.acceptThrows(groupScimService); + servicesCorrectlyPropagated.add(groupScimService); + } catch (ScimPropagationException e) { + exceptionHandler.handleException(groupScimService.getConfiguration(), e); + } + }); + // TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints + LOGGER.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); + } + + public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer operationToDispatch) { + initializeClientsIfNeeded(); + // Scim client should already have been created + Optional matchingClient = userScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + if (matchingClient.isPresent()) { + try { + operationToDispatch.acceptThrows(matchingClient.get()); + LOGGER.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName()); + } catch (ScimPropagationException e) { + exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); + } + } else { + LOGGER.error("[SCIM] Could not find a Scim Client matching User endpoint configuration" + scimServerConfiguration.getId()); + } + } + + + public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer operationToDispatch) { + initializeClientsIfNeeded(); + // Scim client should already have been created + Optional matchingClient = groupScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + if (matchingClient.isPresent()) { + try { + operationToDispatch.acceptThrows(matchingClient.get()); + LOGGER.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName()); + } catch (ScimPropagationException e) { + exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); + } + } else { + LOGGER.error("[SCIM] Could not find a Scim Client matching Group endpoint configuration" + scimServerConfiguration.getId()); + } + } + + public void close() { + for (GroupScimService c : groupScimServices) { + c.close(); + } + for (UserScimService c : userScimServices) { + c.close(); + } + groupScimServices.clear(); + userScimServices.clear(); + } + + private void initializeClientsIfNeeded() { + if (!clientsInitialized) { + clientsInitialized = true; + refreshActiveScimEndpoints(); + } + } + + /** + * A Consumer that throws ScimPropagationException. + * + * @param An {@link AbstractScimService to call} + */ + @FunctionalInterface + public interface SCIMPropagationConsumer { + + void acceptThrows(T elem) throws ScimPropagationException; + + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java b/federation/scim/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java new file mode 100644 index 0000000000..b73df47725 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java @@ -0,0 +1,165 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.common.constants.HttpHeader; +import jakarta.ws.rs.core.MediaType; +import org.apache.commons.lang3.BooleanUtils; +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.UserStorageProviderFactory; +import org.keycloak.storage.UserStorageProviderModel; +import org.keycloak.storage.user.ImportSynchronization; +import org.keycloak.storage.user.SynchronizationResult; +import sh.libre.scim.event.ScimBackgroundGroupMembershipUpdater; + +import java.util.Date; +import java.util.List; + +/** + * Allows to register and configure Scim endpoints through Admin console, using the provided config properties. + */ +public class ScimEndpointConfigurationStorageProviderFactory + implements UserStorageProviderFactory, ImportSynchronization { + public static final String ID = "scim"; + private static final Logger LOGGER = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class); + + @Override + public String getId() { + return ID; + } + + + @Override + public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, + UserStorageProviderModel model) { + // Manually Launch a synchronization between keycloack and the SCIM endpoint described in the given model + LOGGER.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getName()); + SynchronizationResult result = new SynchronizationResult(); + KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + ScimDispatcher dispatcher = new ScimDispatcher(session); + if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER))) { + dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); + } + if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP))) { + dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); + } + dispatcher.close(); + }); + return result; + } + + @Override + public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, + UserStorageProviderModel model) { + return this.sync(sessionFactory, realmId, model); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + ScimBackgroundGroupMembershipUpdater scimBackgroundGroupMembershipUpdater = new ScimBackgroundGroupMembershipUpdater(factory); + scimBackgroundGroupMembershipUpdater.startBackgroundUpdates(); + } + + @Override + public List getConfigProperties() { + // These Config Properties will be use to generate configuration page in Admin Console + return ProviderConfigurationBuilder.create() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_ENDPOINT) + .type(ProviderConfigProperty.STRING_TYPE) + .required(true) + .label("SCIM 2.0 endpoint") + .helpText("External SCIM 2.0 base " + + "URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_CONTENT_TYPE) + .type(ProviderConfigProperty.LIST_TYPE) + .label("Endpoint content type") + .helpText("Only used when endpoint doesn't support application/scim+json") + .options(MediaType.APPLICATION_JSON, HttpHeader.SCIM_CONTENT_TYPE) + .defaultValue(HttpHeader.SCIM_CONTENT_TYPE) + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_MODE) + .type(ProviderConfigProperty.LIST_TYPE) + .label("Auth mode") + .helpText("Select the authorization mode") + .options("NONE", "BASIC_AUTH", "BEARER") + .defaultValue("NONE") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_USER) + .type(ProviderConfigProperty.STRING_TYPE) + .label("Auth username") + .helpText("Required for basic authentication.") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_PASSWORD) + .type(ProviderConfigProperty.PASSWORD) + .label("Auth password/token") + .helpText("Password or token required for basic or bearer authentication.") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable user propagation") + .helpText("Should operation on users be propagated to this provider?") + .defaultValue(BooleanUtils.TRUE) + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable group propagation") + .helpText("Should operation on groups be propagated to this provider?") + .defaultValue(BooleanUtils.TRUE) + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable import during sync") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT_ACTION) + .type(ProviderConfigProperty.LIST_TYPE) + .label("Import action") + .helpText("What to do when the user doesn't exists in Keycloak.") + .options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE") + .defaultValue("CREATE_LOCAL") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_REFRESH) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable refresh during sync") + .name(ScrimEndPointConfiguration.CONF_KEY_LOG_ALL_SCIM_REQUESTS) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Log SCIM requests and responses") + .helpText("If true, all sent SCIM requests and responses will be logged") + .add() + .build(); + } + + + @Override + public ScimEndpointConfigurationStorageProvider create(KeycloakSession session, ComponentModel model) { + return new ScimEndpointConfigurationStorageProvider(); + } + + /** + * Empty implementation : we used this {@link ScimEndpointConfigurationStorageProviderFactory} to generate Admin Console page. + */ + public static final class ScimEndpointConfigurationStorageProvider implements UserStorageProvider { + @Override + public void close() { + // Nothing to close here + } + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java b/federation/scim/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java new file mode 100644 index 0000000000..6359b57153 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java @@ -0,0 +1,101 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.client.http.BasicAuth; +import org.keycloak.component.ComponentModel; + +public class ScrimEndPointConfiguration { + // Configuration keys : also used in Admin Console page + public static final String CONF_KEY_AUTH_MODE = "auth-mode"; + public static final String CONF_KEY_AUTH_PASSWORD = "auth-pass"; + public static final String CONF_KEY_AUTH_USER = "auth-user"; + public static final String CONF_KEY_CONTENT_TYPE = "content-type"; + public static final String CONF_KEY_ENDPOINT = "endpoint"; + public static final String CONF_KEY_SYNC_IMPORT_ACTION = "sync-import-action"; + public static final String CONF_KEY_SYNC_IMPORT = "sync-import"; + public static final String CONF_KEY_SYNC_REFRESH = "sync-refresh"; + public static final String CONF_KEY_PROPAGATION_USER = "propagation-user"; + public static final String CONF_KEY_PROPAGATION_GROUP = "propagation-group"; + public static final String CONF_KEY_LOG_ALL_SCIM_REQUESTS = "log-all-scim-requests"; + + private final String endPoint; + private final String id; + private final String name; + private final String contentType; + private final String authorizationHeaderValue; + private final ImportAction importAction; + private final boolean pullFromScimSynchronisationActivated; + private final boolean pushToScimSynchronisationActivated; + private final boolean logAllScimRequests; + + public ScrimEndPointConfiguration(ComponentModel scimProviderConfiguration) { + try { + AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE)); + + authorizationHeaderValue = switch (authMode) { + case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD); + case BASIC_AUTH -> { + BasicAuth basicAuth = BasicAuth.builder() + .username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER)) + .password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD)) + .build(); + yield basicAuth.getAuthorizationHeaderValue(); + } + case NONE -> ""; + }; + contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE, ""); + endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT, ""); + id = scimProviderConfiguration.getId(); + name = scimProviderConfiguration.getName(); + importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); + pullFromScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); + pushToScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); + logAllScimRequests = scimProviderConfiguration.get(CONF_KEY_LOG_ALL_SCIM_REQUESTS, false); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("authMode '" + scimProviderConfiguration.get(CONF_KEY_AUTH_MODE) + "' is not supported"); + } + } + + public boolean isPushToScimSynchronisationActivated() { + return pushToScimSynchronisationActivated; + } + + public boolean isPullFromScimSynchronisationActivated() { + return pullFromScimSynchronisationActivated; + } + + public String getContentType() { + return contentType; + } + + public String getAuthorizationHeaderValue() { + return authorizationHeaderValue; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public ImportAction getImportAction() { + return importAction; + } + + public String getEndPoint() { + return endPoint; + } + + public boolean isLogAllScimRequests() { + return logAllScimRequests; + } + + public enum AuthMode { + BEARER, BASIC_AUTH, NONE + } + + public enum ImportAction { + CREATE_LOCAL, DELETE_REMOTE, NOTHING + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java new file mode 100644 index 0000000000..44f7eb46e6 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.exceptions; + +public class InconsistentScimMappingException extends ScimPropagationException { + public InconsistentScimMappingException(String message) { + super(message); + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java new file mode 100644 index 0000000000..079443622b --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java @@ -0,0 +1,29 @@ +package sh.libre.scim.core.exceptions; + +import de.captaingoldfish.scim.sdk.client.response.ServerResponse; + +import java.util.Optional; + +public class InvalidResponseFromScimEndpointException extends ScimPropagationException { + + private final transient Optional response; + + public InvalidResponseFromScimEndpointException(ServerResponse response, String message) { + super(message); + this.response = Optional.of(response); + } + + public InvalidResponseFromScimEndpointException(String message, Exception e) { + super(message, e); + this.response = Optional.empty(); + } + + + /** + * Empty response can occur if a major exception was thrown while retrying the request. + */ + public Optional getResponse() { + return response; + } + +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java new file mode 100644 index 0000000000..d1fb108930 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java @@ -0,0 +1,55 @@ +package sh.libre.scim.core.exceptions; + +import com.google.common.collect.Lists; +import sh.libre.scim.core.ScrimEndPointConfiguration; + +import java.util.ArrayList; + + +public enum RollbackApproach implements RollbackStrategy { + ALWAYS_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + return true; + } + }, + NEVER_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + return false; + } + }, + CRITICAL_ONLY_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + if (e instanceof InconsistentScimMappingException) { + // Occurs when mapping between a SCIM resource and a keycloak user failed (missing, ambiguous..) + // Log can be sufficient here, no rollback required + return false; + } + if (e instanceof UnexpectedScimDataException) { + // Occurs when a SCIM endpoint sends invalid date (e.g. group with empty name, user without ids...) + // No rollback required : we cannot recover. This needs to be fixed in the SCIM endpoint data + return false; + } + if (e instanceof InvalidResponseFromScimEndpointException invalidResponseFromScimEndpointException) { + return shouldRollbackBecauseOfResponse(invalidResponseFromScimEndpointException); + } + // Should not occur + throw new IllegalStateException("Unkown ScimPropagationException", e); + } + + private boolean shouldRollbackBecauseOfResponse(InvalidResponseFromScimEndpointException e) { + // If we have a response + return e.getResponse().map(r -> { + // We consider that 404 are acceptable, otherwise rollback + ArrayList acceptableStatus = Lists.newArrayList(200, 204, 404); + return !acceptableStatus.contains(r.getHttpStatus()); + }).orElse( + // Never got an answer, server was either misconfigured or unreachable + // No rollback in that case. + false + ); + } + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java new file mode 100644 index 0000000000..90d859305c --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java @@ -0,0 +1,22 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +/** + * In charge of deciding, when facing a SCIM-related issue during an operation (e.g User creation), + * whether we should : + * - Log the issue and let the operation succeed in Keycloack database (potentially unsynchronising + * Keycloack with the SCIM servers) + * - Rollback the whole operation + */ +public interface RollbackStrategy { + + /** + * Indicates whether we should rollback the whole transaction because of the given exception. + * + * @param configuration The SCIM Endpoint configuration for which the exception occured + * @param e the exception that we have to handle + * @return true if transaction should be rolled back, false if we should log and continue operation + */ + boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e); +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java new file mode 100644 index 0000000000..78973d2ad6 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java @@ -0,0 +1,43 @@ +package sh.libre.scim.core.exceptions; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import sh.libre.scim.core.ScrimEndPointConfiguration; + +/** + * In charge of dealing with SCIM exceptions by ignoring, logging or rollback transaction according to : + * - The context in which it occurs (sync, user creation...) + * - The related SCIM endpoint and its configuration + * - The thrown exception itself + */ +public class ScimExceptionHandler { + private static final Logger LOGGER = Logger.getLogger(ScimExceptionHandler.class); + + private final KeycloakSession session; + private final RollbackStrategy rollbackStrategy; + + public ScimExceptionHandler(KeycloakSession session) { + this(session, RollbackApproach.CRITICAL_ONLY_ROLLBACK); + } + + public ScimExceptionHandler(KeycloakSession session, RollbackStrategy rollbackStrategy) { + this.session = session; + this.rollbackStrategy = rollbackStrategy; + } + + /** + * Handles the given exception by loggin and/or rollback transaction. + * + * @param scimProviderConfiguration the configuration of the endpoint for which the propagation exception occured + * @param e the occuring exception + */ + public void handleException(ScrimEndPointConfiguration scimProviderConfiguration, ScimPropagationException e) { + String errorMessage = "[SCIM] Error while propagating to SCIM endpoint " + scimProviderConfiguration.getName(); + if (rollbackStrategy.shouldRollback(scimProviderConfiguration, e)) { + session.getTransactionManager().rollback(); + LOGGER.error("TRANSACTION ROLLBACK - " + errorMessage, e); + } else { + LOGGER.warn(errorMessage, e); + } + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java new file mode 100644 index 0000000000..bee5ee18fd --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java @@ -0,0 +1,12 @@ +package sh.libre.scim.core.exceptions; + +public abstract class ScimPropagationException extends Exception { + + protected ScimPropagationException(String message) { + super(message); + } + + protected ScimPropagationException(String message, Exception e) { + super(message, e); + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java new file mode 100644 index 0000000000..e0669d59db --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java @@ -0,0 +1,59 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + + +public enum SkipOrStopApproach implements SkipOrStopStrategy { + ALWAYS_SKIP_AND_CONTINUE { + @Override + public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowInvalidEndpointConfiguration() { + return false; + } + + @Override + public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) { + return false; + } + }, + ALWAYS_STOP { + @Override + public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowInvalidEndpointConfiguration() { + return true; + } + + @Override + public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) { + return true; + } + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java new file mode 100644 index 0000000000..8ad46c7ff9 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java @@ -0,0 +1,66 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +/** + * In charge of deciding, when facing a SCIM-related issue, whether we should : + * - log a warning, skip the problematic element and continue the rest of the operation + * - stop immediately the whole operation (typically, a synchronisation between SCIM and Keycloack) + */ +public interface SkipOrStopStrategy { + /** + * Indicates if, during a synchronisation from Keycloack to a SCIM endpoint, we should : + * - cancel the whole synchronisation if an element CRUD fail, or + * - keep on with synchronisation, allowing a partial synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial synchronisation is allowed, + * false if we should stop the whole synchronisation at first issue + */ + boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration); + + /** + * Indicates if, during a synchronisation from a SCIM endpoint to Keycloack, we should : + * - cancel the whole synchronisation if an element CRUD fail, or + * - keep on with synchronisation, allowing a partial synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial synchronisation is allowed, + * false if we should interrupt the whole synchronisation at first issue + */ + boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration); + + + /** + * Indicates if, when we propagate a group creation or update to a SCIM endpoint and some + * of its members are not mapped to SCIM, we should allow partial group update or interrupt completely. + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial group update is allowed, + * false if we should interrupt the group update in case of any unmapped member + */ + boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration); + + /** + * Indicates if, when facing an invalid SCIM endpoint configuration (resulting in a unreachable SCIM server), + * we should stop or ignore this configuration. + * + * @return true the invalid endpoint should be ignored, + * * false if we should interrupt the rest of the synchronisation + */ + boolean allowInvalidEndpointConfiguration(); + + /** + * Indicates if, when trying to pull User or Groups from a SCIM endpoint, + * we encounter a invalid data (e.g. group with empty name), we should : + * - Skip the invalid element pull and continue + * - Cancel the whole synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if we should skip the invalid data synchronisation and pursue, + * false if we should interrupt immediately the whole synchronisation + */ + boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration); + + +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java new file mode 100644 index 0000000000..918127ef0b --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.exceptions; + +public class UnexpectedScimDataException extends ScimPropagationException { + public UnexpectedScimDataException(String message) { + super(message); + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/service/AbstractScimService.java b/federation/scim/src/main/java/sh/libre/scim/core/service/AbstractScimService.java new file mode 100644 index 0000000000..b22b6a9a8d --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/service/AbstractScimService.java @@ -0,0 +1,281 @@ +package sh.libre.scim.core.service; + +import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; +import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RoleMapperModel; +import org.keycloak.storage.user.SynchronizationResult; +import sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.core.exceptions.InconsistentScimMappingException; +import sh.libre.scim.core.exceptions.InvalidResponseFromScimEndpointException; +import sh.libre.scim.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.exceptions.UnexpectedScimDataException; +import sh.libre.scim.jpa.ScimResourceDao; +import sh.libre.scim.jpa.ScimResourceMapping; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A service in charge of synchronisation (CRUD) between + * a Keykloak Role (UserModel, GroupModel) and a SCIM Resource (User,Group). + * + * @param The Keycloack Model (e.g. UserModel, GroupModel) + * @param The SCIM Resource (e.g. User, Group) + */ +public abstract class AbstractScimService implements AutoCloseable { + + private static final Logger LOGGER = Logger.getLogger(AbstractScimService.class); + + private final KeycloakSession keycloakSession; + protected final SkipOrStopStrategy skipOrStopStrategy; + private final ScrimEndPointConfiguration scimProviderConfiguration; + private final ScimResourceType type; + private final ScimClient scimClient; + + protected AbstractScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType type, SkipOrStopStrategy skipOrStopStrategy) { + this.keycloakSession = keycloakSession; + this.scimProviderConfiguration = scimProviderConfiguration; + this.type = type; + this.scimClient = ScimClient.open(scimProviderConfiguration, type); + this.skipOrStopStrategy = skipOrStopStrategy; + } + + public void create(K roleMapperModel) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + if (isMarkedToIgnore(roleMapperModel)) { + // Silently return: resource is explicitly marked as to ignore + return; + } + // If mapping, then we are trying to recreate a user that was already created by import + KeycloakId id = getId(roleMapperModel); + if (findMappingById(id).isPresent()) { + throw new InconsistentScimMappingException("Trying to create user with id " + id + ": id already exists in Keycloak database"); + } + S scimForCreation = scimRequestBodyForCreate(roleMapperModel); + EntityOnRemoteScimId externalId = scimClient.create(id, scimForCreation); + createMapping(id, externalId); + } + + public void update(K roleMapperModel) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + if (isMarkedToIgnore(roleMapperModel)) { + // Silently return: resource is explicitly marked as to ignore + return; + } + KeycloakId keycloakId = getId(roleMapperModel); + EntityOnRemoteScimId entityOnRemoteScimId = findMappingById(keycloakId) + .map(ScimResourceMapping::getExternalIdAsEntityOnRemoteScimId) + .orElseThrow(() -> new InconsistentScimMappingException("Failed to find SCIM mapping for " + keycloakId)); + S scimForReplace = scimRequestBodyForUpdate(roleMapperModel, entityOnRemoteScimId); + scimClient.update(entityOnRemoteScimId, scimForReplace); + } + + protected abstract S scimRequestBodyForUpdate(K roleMapperModel, EntityOnRemoteScimId externalId) throws InconsistentScimMappingException; + + public void delete(KeycloakId id) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + ScimResourceMapping resource = findMappingById(id) + .orElseThrow(() -> new InconsistentScimMappingException("Failed to delete resource %s, scim mapping not found: ".formatted(id))); + EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId(); + scimClient.delete(externalId); + getScimResourceDao().delete(resource); + } + + public void pushAllResourcesToScim(SynchronizationResult syncRes) throws InvalidResponseFromScimEndpointException, InconsistentScimMappingException { + LOGGER.info("[SCIM] Push resources to endpoint " + this.getConfiguration().getEndPoint()); + try (Stream resourcesStream = getResourceStream()) { + Set resources = resourcesStream.collect(Collectors.toUnmodifiableSet()); + for (K resource : resources) { + KeycloakId id = getId(resource); + pushSingleResourceToScim(syncRes, resource, id); + } + } + } + + public void pullAllResourcesFromScim(SynchronizationResult syncRes) throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + LOGGER.info("[SCIM] Pull resources from endpoint " + this.getConfiguration().getEndPoint()); + for (S resource : scimClient.listResources()) { + pullSingleResourceFromScim(syncRes, resource); + } + } + + private void pushSingleResourceToScim(SynchronizationResult syncRes, K resource, KeycloakId id) throws InvalidResponseFromScimEndpointException, InconsistentScimMappingException { + try { + LOGGER.infof("[SCIM] Reconciling local resource %s", id); + if (shouldIgnoreForScimSynchronization(resource)) { + LOGGER.infof("[SCIM] Skip local resource %s", id); + return; + } + if (findMappingById(id).isPresent()) { + LOGGER.info("[SCIM] Replacing it"); + update(resource); + } else { + LOGGER.info("[SCIM] Creating it"); + create(resource); + } + syncRes.increaseUpdated(); + } catch (InvalidResponseFromScimEndpointException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPushingToScim(this.getConfiguration())) { + LOGGER.warn("Error while syncing " + id + " to endpoint " + getConfiguration().getEndPoint(), e); + } else { + throw e; + } + } catch (InconsistentScimMappingException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPushingToScim(this.getConfiguration())) { + LOGGER.warn("Inconsistent data for element " + id + " and endpoint " + getConfiguration().getEndPoint(), e); + } else { + throw e; + } + } + } + + + private void pullSingleResourceFromScim(SynchronizationResult syncRes, S resource) throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + try { + LOGGER.infof("[SCIM] Reconciling remote resource %s", resource); + EntityOnRemoteScimId externalId = resource.getId() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new UnexpectedScimDataException("Remote SCIM resource doesn't have an id, cannot import it in Keycloak")); + if (validMappingAlreadyExists(externalId)) return; + + // Here no keycloak user/group matching the SCIM external id exists + // Try to match existing keycloak resource by properties (username, email, name) + Optional mapped = matchKeycloakMappingByScimProperties(resource); + if (mapped.isPresent()) { + // If found a mapped, update + LOGGER.info("[SCIM] Matched SCIM resource " + externalId + " from properties with keycloak entity " + mapped.get()); + createMapping(mapped.get(), externalId); + syncRes.increaseUpdated(); + } else { + // If not, create it locally or deleting it remotely (according to the configured Import Action) + createLocalOrDeleteRemote(syncRes, resource, externalId); + } + } catch (UnexpectedScimDataException e) { + if (skipOrStopStrategy.skipInvalidDataFromScimEndpoint(getConfiguration())) { + LOGGER.warn("[SCIM] Skipping element synchronisation because of invalid Scim Data for element " + resource.getId() + " : " + e.getMessage(), e); + } else { + throw e; + } + } catch (InconsistentScimMappingException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPullingFromScim(getConfiguration())) { + LOGGER.warn("[SCIM] Skipping element synchronisation because of inconsistent mapping for element " + resource.getId() + " : " + e.getMessage(), e); + } else { + throw e; + } + } catch (InvalidResponseFromScimEndpointException e) { + // Can only occur in case of a DELETE_REMOTE conflict action + if (skipOrStopStrategy.allowPartialSynchronizationWhenPullingFromScim(getConfiguration())) { + LOGGER.warn("[SCIM] Could not delete SCIM resource " + resource.getId() + " during synchronisation", e); + } else { + throw e; + } + } + + } + + private boolean validMappingAlreadyExists(EntityOnRemoteScimId externalId) { + Optional optionalMapping = getScimResourceDao().findByExternalId(externalId, type); + // If an existing mapping exists, delete potential dangling references + if (optionalMapping.isPresent()) { + ScimResourceMapping mapping = optionalMapping.get(); + if (entityExists(mapping.getIdAsKeycloakId())) { + LOGGER.info("[SCIM] Valid mapping found, skipping"); + return true; + } else { + LOGGER.info("[SCIM] Delete a dangling mapping"); + getScimResourceDao().delete(mapping); + } + } + return false; + } + + private void createLocalOrDeleteRemote(SynchronizationResult syncRes, S resource, EntityOnRemoteScimId externalId) throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + switch (scimProviderConfiguration.getImportAction()) { + case CREATE_LOCAL -> { + LOGGER.info("[SCIM] Create local resource for SCIM resource " + externalId); + KeycloakId id = createEntity(resource); + createMapping(id, externalId); + syncRes.increaseAdded(); + } + case DELETE_REMOTE -> { + LOGGER.info("[SCIM] Delete remote resource " + externalId); + scimClient.delete(externalId); + } + case NOTHING -> LOGGER.info("[SCIM] Import action set to NOTHING"); + } + } + + + protected abstract S scimRequestBodyForCreate(K roleMapperModel) throws InconsistentScimMappingException; + + protected abstract KeycloakId getId(K roleMapperModel); + + protected abstract boolean isMarkedToIgnore(K roleMapperModel); + + private void createMapping(KeycloakId keycloakId, EntityOnRemoteScimId externalId) { + getScimResourceDao().create(keycloakId, externalId, type); + } + + protected ScimResourceDao getScimResourceDao() { + return ScimResourceDao.newInstance(getKeycloakSession(), scimProviderConfiguration.getId()); + } + + private Optional findMappingById(KeycloakId keycloakId) { + return getScimResourceDao().findById(keycloakId, type); + } + + private KeycloakSession getKeycloakSession() { + return keycloakSession; + } + + + protected abstract boolean shouldIgnoreForScimSynchronization(K resource); + + protected abstract Stream getResourceStream(); + + protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException, InconsistentScimMappingException; + + protected abstract Optional matchKeycloakMappingByScimProperties(S resource) throws InconsistentScimMappingException; + + protected abstract boolean entityExists(KeycloakId keycloakId); + + public void sync(SynchronizationResult syncRes) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException, UnexpectedScimDataException { + if (this.scimProviderConfiguration.isPullFromScimSynchronisationActivated()) { + this.pullAllResourcesFromScim(syncRes); + } + if (this.scimProviderConfiguration.isPushToScimSynchronisationActivated()) { + this.pushAllResourcesToScim(syncRes); + } + } + + protected Meta newMetaLocation(EntityOnRemoteScimId externalId) { + Meta meta = new Meta(); + URI uri = getUri(type, externalId); + meta.setLocation(uri.toString()); + return meta; + } + + protected URI getUri(ScimResourceType type, EntityOnRemoteScimId externalId) { + try { + return new URI("%s/%s".formatted(type.getEndpoint(), externalId.asString())); + } catch (URISyntaxException e) { + throw new IllegalStateException("should never occur: can not format URI for type %s and id %s".formatted(type, externalId), e); + } + } + + protected KeycloakDao getKeycloakDao() { + return new KeycloakDao(getKeycloakSession()); + } + + @Override + public void close() { + scimClient.close(); + } + + public ScrimEndPointConfiguration getConfiguration() { + return scimProviderConfiguration; + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java b/federation/scim/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java new file mode 100644 index 0000000000..df96a12323 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java @@ -0,0 +1,6 @@ +package sh.libre.scim.core.service; + +public record EntityOnRemoteScimId( + String asString +) { +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/service/GroupScimService.java b/federation/scim/src/main/java/sh/libre/scim/core/service/GroupScimService.java new file mode 100644 index 0000000000..bd09f3e9b2 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/service/GroupScimService.java @@ -0,0 +1,131 @@ +package sh.libre.scim.core.service; + +import de.captaingoldfish.scim.sdk.common.resources.Group; +import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.jboss.logging.Logger; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.core.exceptions.InconsistentScimMappingException; +import sh.libre.scim.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.exceptions.UnexpectedScimDataException; +import sh.libre.scim.jpa.ScimResourceMapping; + +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; + +public class GroupScimService extends AbstractScimService { + private static final Logger LOGGER = Logger.getLogger(GroupScimService.class); + + public GroupScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration, SkipOrStopStrategy skipOrStopStrategy) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP, skipOrStopStrategy); + } + + @Override + protected Stream getResourceStream() { + return getKeycloakDao().getGroupsStream(); + } + + @Override + protected boolean entityExists(KeycloakId keycloakId) { + return getKeycloakDao().groupExists(keycloakId); + } + + @Override + protected Optional matchKeycloakMappingByScimProperties(Group resource) { + Set names = new TreeSet<>(); + resource.getId().ifPresent(names::add); + resource.getDisplayName().ifPresent(names::add); + try (Stream groupsStream = getKeycloakDao().getGroupsStream()) { + Optional group = groupsStream + .filter(groupModel -> names.contains(groupModel.getName())) + .findFirst(); + return group + .map(GroupModel::getId) + .map(KeycloakId::new); + } + } + + @Override + protected KeycloakId createEntity(Group resource) throws UnexpectedScimDataException, InconsistentScimMappingException { + String displayName = resource.getDisplayName() + .filter(StringUtils::isNotBlank) + .orElseThrow(() -> new UnexpectedScimDataException("Remote Scim group has empty name, can't create. Resource id = %s".formatted(resource.getId()))); + GroupModel group = getKeycloakDao().createGroup(displayName); + List groupMembers = resource.getMembers(); + if (CollectionUtils.isNotEmpty(groupMembers)) { + for (Member groupMember : groupMembers) { + EntityOnRemoteScimId externalId = groupMember.getValue() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new UnexpectedScimDataException("can't create group member for group '%s' without id: ".formatted(displayName) + resource)); + KeycloakId userId = getScimResourceDao().findUserByExternalId(externalId) + .map(ScimResourceMapping::getIdAsKeycloakId) + .orElseThrow(() -> new InconsistentScimMappingException("can't find mapping for group member %s".formatted(externalId))); + UserModel userModel = getKeycloakDao().getUserById(userId); + userModel.joinGroup(group); + } + } + return new KeycloakId(group.getId()); + } + + @Override + protected boolean isMarkedToIgnore(GroupModel groupModel) { + return BooleanUtils.TRUE.equals(groupModel.getFirstAttribute("scim-skip")); + } + + @Override + protected KeycloakId getId(GroupModel groupModel) { + return new KeycloakId(groupModel.getId()); + } + + @Override + protected Group scimRequestBodyForCreate(GroupModel groupModel) throws InconsistentScimMappingException { + Set members = getKeycloakDao().getGroupMembers(groupModel); + Group group = new Group(); + group.setExternalId(groupModel.getId()); + group.setDisplayName(groupModel.getName()); + for (KeycloakId member : members) { + Member groupMember = new Member(); + Optional optionalGroupMemberMapping = getScimResourceDao().findUserById(member); + if (optionalGroupMemberMapping.isPresent()) { + ScimResourceMapping groupMemberMapping = optionalGroupMemberMapping.get(); + EntityOnRemoteScimId externalIdAsEntityOnRemoteScimId = groupMemberMapping.getExternalIdAsEntityOnRemoteScimId(); + groupMember.setValue(externalIdAsEntityOnRemoteScimId.asString()); + URI ref = getUri(ScimResourceType.USER, externalIdAsEntityOnRemoteScimId); + groupMember.setRef(ref.toString()); + group.addMember(groupMember); + } else { + String message = "Unmapped member " + member + " for group " + groupModel.getId(); + if (skipOrStopStrategy.allowMissingMembersWhenPushingGroupToScim(this.getConfiguration())) { + LOGGER.warn(message); + } else { + throw new InconsistentScimMappingException(message); + } + } + } + return group; + } + + @Override + protected Group scimRequestBodyForUpdate(GroupModel groupModel, EntityOnRemoteScimId externalId) throws InconsistentScimMappingException { + Group group = scimRequestBodyForCreate(groupModel); + group.setId(externalId.asString()); + Meta meta = newMetaLocation(externalId); + group.setMeta(meta); + return group; + } + + @Override + protected boolean shouldIgnoreForScimSynchronization(GroupModel resource) { + return false; + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/service/KeycloakDao.java b/federation/scim/src/main/java/sh/libre/scim/core/service/KeycloakDao.java new file mode 100644 index 0000000000..f4c406c351 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/service/KeycloakDao.java @@ -0,0 +1,81 @@ +package sh.libre.scim.core.service; + +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class KeycloakDao { + + private final KeycloakSession keycloakSession; + + public KeycloakDao(KeycloakSession keycloakSession) { + this.keycloakSession = keycloakSession; + } + + private KeycloakSession getKeycloakSession() { + return keycloakSession; + } + + private RealmModel getRealm() { + return getKeycloakSession().getContext().getRealm(); + } + + public boolean groupExists(KeycloakId groupId) { + GroupModel group = getKeycloakSession().groups().getGroupById(getRealm(), groupId.asString()); + return group != null; + } + + public boolean userExists(KeycloakId userId) { + UserModel user = getUserById(userId); + return user != null; + } + + public UserModel getUserById(KeycloakId userId) { + return getKeycloakSession().users().getUserById(getRealm(), userId.asString()); + } + + public GroupModel getGroupById(KeycloakId groupId) { + return getKeycloakSession().groups().getGroupById(getRealm(), groupId.asString()); + } + + + public Stream getGroupsStream() { + return getKeycloakSession().groups().getGroupsStream(getRealm()); + } + + public GroupModel createGroup(String displayName) { + return getKeycloakSession().groups().createGroup(getRealm(), displayName); + } + + public Set getGroupMembers(GroupModel groupModel) { + return getKeycloakSession().users() + .getGroupMembersStream(getRealm(), groupModel) + .map(UserModel::getId) + .map(KeycloakId::new) + .collect(Collectors.toSet()); + } + + public Stream getUsersStream() { + return getKeycloakSession().users().searchForUserStream(getRealm(), Collections.emptyMap()); + } + + public UserModel getUserByUsername(String username) { + return getKeycloakSession().users().getUserByUsername(getRealm(), username); + } + + public UserModel getUserByEmail(String email) { + return getKeycloakSession().users().getUserByEmail(getRealm(), email); + } + + public UserModel addUser(String username) { + return getKeycloakSession().users().addUser(getRealm(), username); + } + + +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/service/KeycloakId.java b/federation/scim/src/main/java/sh/libre/scim/core/service/KeycloakId.java new file mode 100644 index 0000000000..04bad470b3 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/service/KeycloakId.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.service; + +public record KeycloakId( + String asString +) { + +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/service/ScimClient.java b/federation/scim/src/main/java/sh/libre/scim/core/service/ScimClient.java new file mode 100644 index 0000000000..de3b4d7c0f --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/service/ScimClient.java @@ -0,0 +1,155 @@ +package sh.libre.scim.core.service; + +import com.google.common.net.HttpHeaders; +import de.captaingoldfish.scim.sdk.client.ScimClientConfig; +import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder; +import de.captaingoldfish.scim.sdk.client.response.ServerResponse; +import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; +import de.captaingoldfish.scim.sdk.common.response.ListResponse; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import jakarta.ws.rs.ProcessingException; +import org.jboss.logging.Logger; +import sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.core.exceptions.InvalidResponseFromScimEndpointException; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class ScimClient implements AutoCloseable { + private static final Logger LOGGER = Logger.getLogger(ScimClient.class); + + private final RetryRegistry retryRegistry; + + private final ScimRequestBuilder scimRequestBuilder; + + private final ScimResourceType scimResourceType; + private final boolean logAllRequests; + + private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType, boolean detailedLogs) { + this.scimRequestBuilder = scimRequestBuilder; + this.scimResourceType = scimResourceType; + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(10) + .intervalFunction(IntervalFunction.ofExponentialBackoff()) + .retryExceptions(ProcessingException.class) + .build(); + retryRegistry = RetryRegistry.of(retryConfig); + this.logAllRequests = detailedLogs; + } + + public static ScimClient open(ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { + String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); + Map httpHeaders = new HashMap<>(); + httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue()); + httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType()); + ScimClientConfig scimClientConfig = ScimClientConfig.builder() + .httpHeaders(httpHeaders) + .connectTimeout(5) + .requestTimeout(5) + .socketTimeout(5) + .build(); + ScimRequestBuilder scimRequestBuilder = + new ScimRequestBuilder( + scimApplicationBaseUrl, + scimClientConfig + ); + return new ScimClient<>(scimRequestBuilder, scimResourceType, scimProviderConfiguration.isLogAllScimRequests()); + } + + public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws InvalidResponseFromScimEndpointException { + Optional scimForCreationId = scimForCreation.getId(); + if (scimForCreationId.isPresent()) { + throw new IllegalArgumentException( + "User to create should never have an existing id: %s %s".formatted(id, scimForCreationId.get()) + ); + } + try { + Retry retry = retryRegistry.retry("create-%s".formatted(id.asString())); + if (logAllRequests) { + LOGGER.info("[SCIM] Sending CREATE " + scimForCreation.toPrettyString() + "\n to " + getScimEndpoint()); + } + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .create(getResourceClass(), getScimEndpoint()) + .setResource(scimForCreation) + .sendRequest() + ); + checkResponseIsSuccess(response); + S resource = response.getResource(); + return resource.getId() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new InvalidResponseFromScimEndpointException(response, "Created SCIM resource does not have id")); + + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying create " + e.getMessage(), e); + } + } + + private void checkResponseIsSuccess(ServerResponse response) throws InvalidResponseFromScimEndpointException { + if (logAllRequests) { + LOGGER.info("[SCIM] Server response " + response.getHttpStatus() + "\n" + response.getResponseBody()); + } + if (!response.isSuccess()) { + throw new InvalidResponseFromScimEndpointException(response, "Server answered with status " + response.getResponseBody() + ": " + response.getResponseBody()); + } + } + + private String getScimEndpoint() { + return scimResourceType.getEndpoint(); + } + + private Class getResourceClass() { + return scimResourceType.getResourceClass(); + } + + public void update(EntityOnRemoteScimId externalId, S scimForReplace) throws InvalidResponseFromScimEndpointException { + Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); + try { + if (logAllRequests) { + LOGGER.info("[SCIM] Sending UPDATE " + scimForReplace.toPrettyString() + "\n to " + getScimEndpoint()); + } + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .update(getResourceClass(), getScimEndpoint(), externalId.asString()) + .setResource(scimForReplace) + .sendRequest() + ); + checkResponseIsSuccess(response); + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying update " + e.getMessage(), e); + } + } + + public void delete(EntityOnRemoteScimId externalId) throws InvalidResponseFromScimEndpointException { + Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString())); + if (logAllRequests) { + LOGGER.info("[SCIM] Sending DELETE to " + getScimEndpoint()); + } + try { + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .delete(getResourceClass(), getScimEndpoint(), externalId.asString()) + .sendRequest() + ); + checkResponseIsSuccess(response); + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying delete " + e.getMessage(), e); + } + } + + @Override + public void close() { + scimRequestBuilder.close(); + } + + public List listResources() { + ServerResponse> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get().sendRequest(); + ListResponse resourceTypeListResponse = response.getResource(); + return resourceTypeListResponse.getListedResources(); + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/service/ScimResourceType.java b/federation/scim/src/main/java/sh/libre/scim/core/service/ScimResourceType.java new file mode 100644 index 0000000000..b90845b6cf --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/service/ScimResourceType.java @@ -0,0 +1,29 @@ +package sh.libre.scim.core.service; + +import de.captaingoldfish.scim.sdk.common.resources.Group; +import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; +import de.captaingoldfish.scim.sdk.common.resources.User; + +public enum ScimResourceType { + + USER("/Users", User.class), + + GROUP("/Groups", Group.class); + + private final String endpoint; + + private final Class resourceClass; + + ScimResourceType(String endpoint, Class resourceClass) { + this.endpoint = endpoint; + this.resourceClass = resourceClass; + } + + public String getEndpoint() { + return endpoint; + } + + public Class getResourceClass() { + return (Class) resourceClass; + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/core/service/UserScimService.java b/federation/scim/src/main/java/sh/libre/scim/core/service/UserScimService.java new file mode 100644 index 0000000000..c0262f3d4a --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/core/service/UserScimService.java @@ -0,0 +1,145 @@ +package sh.libre.scim.core.service; + +import de.captaingoldfish.scim.sdk.common.resources.User; +import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; +import de.captaingoldfish.scim.sdk.common.resources.complex.Name; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Email; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.MultiComplexNode; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.PersonRole; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RoleMapperModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.core.exceptions.InconsistentScimMappingException; +import sh.libre.scim.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.exceptions.UnexpectedScimDataException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +public class UserScimService extends AbstractScimService { + private static final Logger LOGGER = Logger.getLogger(UserScimService.class); + + public UserScimService( + KeycloakSession keycloakSession, + ScrimEndPointConfiguration scimProviderConfiguration, + SkipOrStopStrategy skipOrStopStrategy) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER, skipOrStopStrategy); + } + + @Override + protected Stream getResourceStream() { + return getKeycloakDao().getUsersStream(); + } + + @Override + protected boolean entityExists(KeycloakId keycloakId) { + return getKeycloakDao().userExists(keycloakId); + } + + @Override + protected Optional matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimMappingException { + Optional matchedByUsername = resource.getUserName() + .map(getKeycloakDao()::getUserByUsername) + .map(this::getId); + Optional matchedByEmail = resource.getEmails().stream() + .findFirst() + .flatMap(MultiComplexNode::getValue) + .map(getKeycloakDao()::getUserByEmail) + .map(this::getId); + if (matchedByUsername.isPresent() + && matchedByEmail.isPresent() + && !matchedByUsername.equals(matchedByEmail)) { + String inconstencyErrorMessage = "Found 2 possible users for remote user " + matchedByUsername.get() + " - " + matchedByEmail.get(); + LOGGER.warn(inconstencyErrorMessage); + throw new InconsistentScimMappingException(inconstencyErrorMessage); + } + if (matchedByUsername.isPresent()) { + return matchedByUsername; + } + return matchedByEmail; + } + + @Override + protected KeycloakId createEntity(User resource) throws UnexpectedScimDataException { + String username = resource.getUserName() + .filter(StringUtils::isNotBlank) + .orElseThrow(() -> new UnexpectedScimDataException("Remote Scim user has empty username, can't create. Resource id = %s".formatted(resource.getId()))); + UserModel user = getKeycloakDao().addUser(username); + resource.getEmails().stream() + .findFirst() + .flatMap(MultiComplexNode::getValue) + .ifPresent(user::setEmail); + boolean userEnabled = resource.isActive().orElse(false); + user.setEnabled(userEnabled); + return new KeycloakId(user.getId()); + } + + @Override + protected boolean isMarkedToIgnore(UserModel userModel) { + return BooleanUtils.TRUE.equals(userModel.getFirstAttribute("scim-skip")); + } + + @Override + protected KeycloakId getId(UserModel userModel) { + return new KeycloakId(userModel.getId()); + } + + @Override + protected User scimRequestBodyForCreate(UserModel roleMapperModel) { + String firstAndLastName = String.format("%s %s", + StringUtils.defaultString(roleMapperModel.getFirstName()), + StringUtils.defaultString(roleMapperModel.getLastName())).trim(); + String displayName = Objects.toString(firstAndLastName, roleMapperModel.getUsername()); + Stream groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream); + Stream roleModels = roleMapperModel.getRoleMappingsStream(); + Stream allRoleModels = Stream.concat(groupRoleModels, roleModels); + List roles = allRoleModels + .filter(r -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) + .map(RoleModel::getName) + .map(roleName -> { + PersonRole personRole = new PersonRole(); + personRole.setValue(roleName); + return personRole; + }) + .toList(); + User user = new User(); + user.setRoles(roles); + user.setExternalId(roleMapperModel.getId()); + user.setUserName(roleMapperModel.getUsername()); + user.setDisplayName(displayName); + Name name = new Name(); + name.setFamilyName(roleMapperModel.getLastName()); + name.setGivenName(roleMapperModel.getFirstName()); + user.setName(name); + List emails = new ArrayList<>(); + if (roleMapperModel.getEmail() != null) { + emails.add( + Email.builder().value(roleMapperModel.getEmail()).build()); + } + user.setEmails(emails); + user.setActive(roleMapperModel.isEnabled()); + return user; + } + + @Override + protected User scimRequestBodyForUpdate(UserModel userModel, EntityOnRemoteScimId externalId) { + User user = scimRequestBodyForCreate(userModel); + user.setId(externalId.asString()); + Meta meta = newMetaLocation(externalId); + user.setMeta(meta); + return user; + } + + @Override + protected boolean shouldIgnoreForScimSynchronization(UserModel userModel) { + return "admin".equals(userModel.getUsername()); + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java b/federation/scim/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java new file mode 100644 index 0000000000..4c49f74f67 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java @@ -0,0 +1,74 @@ +package sh.libre.scim.event; + +import org.jboss.logging.Logger; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.timer.TimerProvider; +import sh.libre.scim.core.ScimDispatcher; + +import java.time.Duration; + +/** + * In charge of making background checks and sent + * UPDATE requests from group for which membership information has changed. + *

+ * This is required to avoid immediate group membership updates which could cause + * to incorrect group members list in case of concurrent group membership changes. + */ +public class ScimBackgroundGroupMembershipUpdater { + public static final String GROUP_DIRTY_SINCE_ATTRIBUTE_NAME = "scim-dirty-since"; + + private static final Logger LOGGER = Logger.getLogger(ScimBackgroundGroupMembershipUpdater.class); + // Update check loop will run every time this delay has passed + private static final long UPDATE_CHECK_DELAY_MS = 2000; + // If a group is marked dirty since less that this debounce delay, wait for the next update check loop + private static final long DEBOUNCE_DELAY_MS = 1200; + private final KeycloakSessionFactory sessionFactory; + + public ScimBackgroundGroupMembershipUpdater(KeycloakSessionFactory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + public void startBackgroundUpdates() { + // Every UPDATE_CHECK_DELAY_MS, check for dirty groups and send updates if required + try (KeycloakSession keycloakSession = sessionFactory.create()) { + TimerProvider timer = keycloakSession.getProvider(TimerProvider.class); + timer.scheduleTask(taskSession -> { + for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { + dispatchDirtyGroupsUpdates(realm); + } + }, Duration.ofMillis(UPDATE_CHECK_DELAY_MS).toMillis(), "scim-background"); + } + } + + private void dispatchDirtyGroupsUpdates(RealmModel realm) { + KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { + session.getContext().setRealm(realm); + ScimDispatcher dispatcher = new ScimDispatcher(session); + // Identify groups marked as dirty by the ScimEventListenerProvider + for (GroupModel group : session.groups().getGroupsStream(realm) + .filter(this::isDirtyGroup).toList()) { + LOGGER.infof("[SCIM] Group %s is dirty, dispatch an update", group.getName()); + // If dirty : dispatch a group update to all clients and mark it clean + dispatcher.dispatchGroupModificationToAll(client -> client.update(group)); + group.removeAttribute(GROUP_DIRTY_SINCE_ATTRIBUTE_NAME); + } + dispatcher.close(); + }); + } + + private boolean isDirtyGroup(GroupModel g) { + String groupDirtySinceAttribute = g.getFirstAttribute(GROUP_DIRTY_SINCE_ATTRIBUTE_NAME); + try { + long groupDirtySince = Long.parseLong(groupDirtySinceAttribute); + // Must be dirty for more than DEBOUNCE_DELAY_MS + // (otherwise update will be dispatched in next scheduled loop) + return System.currentTimeMillis() - groupDirtySince > DEBOUNCE_DELAY_MS; + } catch (NumberFormatException e) { + return false; + } + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/federation/scim/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java new file mode 100644 index 0000000000..2c177b0e47 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -0,0 +1,247 @@ +package sh.libre.scim.event; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventType; +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.UserModel; +import sh.libre.scim.core.ScimDispatcher; +import sh.libre.scim.core.ScimEndpointConfigurationStorageProviderFactory; +import sh.libre.scim.core.service.KeycloakDao; +import sh.libre.scim.core.service.KeycloakId; +import sh.libre.scim.core.service.ScimResourceType; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * An Event listener reacting to Keycloak models modification + * (e.g. User creation, Group deletion, membership modifications, endpoint configuration change...) + * by propagating it to all registered Scim endpoints. + */ +public class ScimEventListenerProvider implements EventListenerProvider { + + private static final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class); + + private final ScimDispatcher dispatcher; + + private final KeycloakSession session; + + private final KeycloakDao keycloakDao; + + private final Map listenedEventPathPatterns = Map.of( + ResourceType.USER, Pattern.compile("users/(.+)"), + ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"), + ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)"), + ResourceType.REALM_ROLE_MAPPING, Pattern.compile("^(.+)/(.+)/role-mappings"), + ResourceType.COMPONENT, Pattern.compile("components/(.+)") + ); + + public ScimEventListenerProvider(KeycloakSession session) { + this.session = session; + this.keycloakDao = new KeycloakDao(session); + this.dispatcher = new ScimDispatcher(session); + } + + @Override + public void onEvent(Event event) { + // React to User-related event : creation, deletion, update + EventType eventType = event.getType(); + KeycloakId eventUserId = new KeycloakId(event.getUserId()); + switch (eventType) { + case REGISTER -> { + LOGGER.infof("[SCIM] Propagate User Registration - %s", eventUserId); + UserModel user = getUser(eventUserId); + dispatcher.dispatchUserModificationToAll(client -> client.create(user)); + } + case UPDATE_EMAIL, UPDATE_PROFILE -> { + LOGGER.infof("[SCIM] Propagate User %s - %s", eventType, eventUserId); + UserModel user = getUser(eventUserId); + dispatcher.dispatchUserModificationToAll(client -> client.update(user)); + } + case DELETE_ACCOUNT -> { + LOGGER.infof("[SCIM] Propagate User deletion - %s", eventUserId); + dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId)); + } + default -> { + // No other event has to be propagated to Scim endpoints + } + } + } + + + @Override + public void onEvent(AdminEvent event, boolean includeRepresentation) { + // Step 1: check if event is relevant for propagation through SCIM + Pattern pattern = listenedEventPathPatterns.get(event.getResourceType()); + if (pattern == null) + return; + Matcher matcher = pattern.matcher(event.getResourcePath()); + if (!matcher.find()) + return; + + + // Step 2: propagate event (if needed) according to its resource type + switch (event.getResourceType()) { + case USER -> { + KeycloakId userId = new KeycloakId(matcher.group(1)); + handleUserEvent(event, userId); + } + case GROUP -> { + KeycloakId groupId = new KeycloakId(matcher.group(1)); + handleGroupEvent(event, groupId); + } + case GROUP_MEMBERSHIP -> { + KeycloakId userId = new KeycloakId(matcher.group(1)); + KeycloakId groupId = new KeycloakId(matcher.group(2)); + handleGroupMemberShipEvent(event, userId, groupId); + } + case REALM_ROLE_MAPPING -> { + String rawResourceType = matcher.group(1); + ScimResourceType type = switch (rawResourceType) { + case "users" -> ScimResourceType.USER; + case "groups" -> ScimResourceType.GROUP; + default -> throw new IllegalArgumentException("Unsupported resource type: " + rawResourceType); + }; + KeycloakId id = new KeycloakId(matcher.group(2)); + handleRoleMappingEvent(event, type, id); + } + case COMPONENT -> { + String id = matcher.group(1); + handleScimEndpointConfigurationEvent(event, id); + + } + default -> { + // No other resource modification has to be propagated to Scim endpoints + } + } + } + + + private void handleUserEvent(AdminEvent userEvent, KeycloakId userId) { + LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId); + switch (userEvent.getOperationType()) { + case CREATE -> { + UserModel user = getUser(userId); + dispatcher.dispatchUserModificationToAll(client -> client.create(user)); + user.getGroupsStream().forEach(group -> + dispatcher.dispatchGroupModificationToAll(client -> client.update(group) + )); + } + case UPDATE -> { + UserModel user = getUser(userId); + dispatcher.dispatchUserModificationToAll(client -> client.update(user)); + } + case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId)); + default -> { + // ACTION userEvent are not relevant, nothing to do + } + } + } + + /** + * Propagating the given group-related event to Scim endpoints. + * + * @param event the event to propagate + * @param groupId event target's id + */ + private void handleGroupEvent(AdminEvent event, KeycloakId groupId) { + LOGGER.infof("[SCIM] Propagate Group %s - %s", event.getOperationType(), groupId); + switch (event.getOperationType()) { + case CREATE -> { + GroupModel group = getGroup(groupId); + dispatcher.dispatchGroupModificationToAll(client -> client.create(group)); + } + case UPDATE -> { + GroupModel group = getGroup(groupId); + dispatcher.dispatchGroupModificationToAll(client -> client.update(group)); + } + case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId)); + default -> { + // ACTION event are not relevant, nothing to do + } + } + } + + private void handleGroupMemberShipEvent(AdminEvent groupMemberShipEvent, KeycloakId userId, KeycloakId groupId) { + LOGGER.infof("[SCIM] Propagate GroupMemberShip %s - User %s Group %s", groupMemberShipEvent.getOperationType(), userId, groupId); + // Step 1: update USER immediately + GroupModel group = getGroup(groupId); + UserModel user = getUser(userId); + dispatcher.dispatchUserModificationToAll(client -> client.update(user)); + + // Step 2: delayed GROUP update : + // if several users are added to the group simultaneously in different Keycloack sessions + // update the group in the context of the current session may not reflect those other changes + // We trigger a delayed update by setting an attribute on the group (that will be handled by ScimBackgroundGroupMembershipUpdaters) + group.setSingleAttribute(ScimBackgroundGroupMembershipUpdater.GROUP_DIRTY_SINCE_ATTRIBUTE_NAME, "" + System.currentTimeMillis()); + } + + private void handleRoleMappingEvent(AdminEvent roleMappingEvent, ScimResourceType type, KeycloakId id) { + LOGGER.infof("[SCIM] Propagate RoleMapping %s - %s %s", roleMappingEvent.getOperationType(), type, id); + switch (type) { + case USER -> { + UserModel user = getUser(id); + dispatcher.dispatchUserModificationToAll(client -> client.update(user)); + } + case GROUP -> { + GroupModel group = getGroup(id); + session.users() + .getGroupMembersStream(session.getContext().getRealm(), group) + .forEach(user -> + dispatcher.dispatchUserModificationToAll(client -> client.update(user) + )); + } + default -> { + // No other type is relevant for propagation + } + } + } + + private void handleScimEndpointConfigurationEvent(AdminEvent event, String id) { + // In case of a component deletion + if (event.getOperationType() == OperationType.DELETE) { + // Check if it was a Scim endpoint configuration, and forward deletion if so + Stream scimEndpointConfigurationsWithDeletedId = session.getContext().getRealm().getComponentsStream() + .filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId()) + && id.equals(m.getId())); + if (scimEndpointConfigurationsWithDeletedId.iterator().hasNext()) { + LOGGER.infof("[SCIM] SCIM Endpoint configuration DELETE - %s ", id); + dispatcher.refreshActiveScimEndpoints(); + } + } else { + // In case of CREATE or UPDATE, we can directly use the string representation + // to check if it defines a SCIM endpoint (faster) + if (event.getRepresentation() != null + && event.getRepresentation().contains("\"providerId\":\"scim\"")) { + LOGGER.infof("[SCIM] SCIM Endpoint configuration CREATE - %s ", id); + dispatcher.refreshActiveScimEndpoints(); + } + } + + } + + + private UserModel getUser(KeycloakId id) { + return keycloakDao.getUserById(id); + } + + private GroupModel getGroup(KeycloakId id) { + return keycloakDao.getGroupById(id); + } + + @Override + public void close() { + dispatcher.close(); + } + + +} diff --git a/federation/scim/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java b/federation/scim/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java new file mode 100644 index 0000000000..c7b437a287 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java @@ -0,0 +1,36 @@ +package sh.libre.scim.event; + +import org.keycloak.Config.Scope; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class ScimEventListenerProviderFactory implements EventListenerProviderFactory { + + @Override + public EventListenerProvider create(KeycloakSession session) { + return new ScimEventListenerProvider(session); + } + + @Override + public String getId() { + return "scim"; + } + + @Override + public void init(Scope config) { + // Nothing to initialize + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // Nothing to initialize + } + + @Override + public void close() { + // Nothing to close + } + +} diff --git a/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java new file mode 100644 index 0000000000..4deec373b9 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -0,0 +1,96 @@ +package sh.libre.scim.jpa; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.TypedQuery; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.KeycloakSession; +import sh.libre.scim.core.service.EntityOnRemoteScimId; +import sh.libre.scim.core.service.KeycloakId; +import sh.libre.scim.core.service.ScimResourceType; + +import java.util.Optional; + +public class ScimResourceDao { + + private final String realmId; + + private final String componentId; + + private final EntityManager entityManager; + + private ScimResourceDao(String realmId, String componentId, EntityManager entityManager) { + this.realmId = realmId; + this.componentId = componentId; + this.entityManager = entityManager; + } + + public static ScimResourceDao newInstance(KeycloakSession keycloakSession, String componentId) { + String realmId = keycloakSession.getContext().getRealm().getId(); + EntityManager entityManager = keycloakSession.getProvider(JpaConnectionProvider.class).getEntityManager(); + return new ScimResourceDao(realmId, componentId, entityManager); + } + + private EntityManager getEntityManager() { + return entityManager; + } + + private String getRealmId() { + return realmId; + } + + private String getComponentId() { + return componentId; + } + + public void create(KeycloakId id, EntityOnRemoteScimId externalId, ScimResourceType type) { + ScimResourceMapping entity = new ScimResourceMapping(); + entity.setType(type.name()); + entity.setExternalId(externalId.asString()); + entity.setComponentId(componentId); + entity.setRealmId(realmId); + entity.setId(id.asString()); + entityManager.persist(entity); + } + + private TypedQuery getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) { + return getEntityManager() + .createNamedQuery(queryName, ScimResourceMapping.class) + .setParameter("type", type.name()) + .setParameter("realmId", getRealmId()) + .setParameter("componentId", getComponentId()) + .setParameter("id", id); + } + + public Optional findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) { + try { + return Optional.of( + getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult() + ); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + public Optional findById(KeycloakId keycloakId, ScimResourceType type) { + try { + return Optional.of( + getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult() + ); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + public Optional findUserById(KeycloakId id) { + return findById(id, ScimResourceType.USER); + } + + public Optional findUserByExternalId(EntityOnRemoteScimId externalId) { + return findByExternalId(externalId, ScimResourceType.USER); + } + + public void delete(ScimResourceMapping resource) { + entityManager.remove(resource); + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceId.java new file mode 100644 index 0000000000..d0abddf2b1 --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -0,0 +1,83 @@ +package sh.libre.scim.jpa; + +import org.apache.commons.lang3.StringUtils; + +import java.io.Serializable; +import java.util.Objects; + +public class ScimResourceId implements Serializable { + private String id; + private String realmId; + private String componentId; + private String type; + private String externalId; + + public ScimResourceId() { + } + + public ScimResourceId(String id, String realmId, String componentId, String type, String externalId) { + this.setId(id); + this.setRealmId(realmId); + this.setComponentId(componentId); + this.setType(type); + this.setExternalId(externalId); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRealmId() { + return realmId; + } + + public void setRealmId(String realmId) { + this.realmId = realmId; + } + + public String getComponentId() { + return componentId; + } + + public void setComponentId(String componentId) { + this.componentId = componentId; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getExternalId() { + return externalId; + } + + public void setExternalId(String externalId) { + this.externalId = externalId; + } + + @Override + public boolean equals(Object other) { + if (this == other) + return true; + if (!(other instanceof ScimResourceId o)) + return false; + return (StringUtils.equals(o.id, id) && + StringUtils.equals(o.realmId, realmId) && + StringUtils.equals(o.componentId, componentId) && + StringUtils.equals(o.type, type) && + StringUtils.equals(o.externalId, externalId)); + } + + @Override + public int hashCode() { + return Objects.hash(realmId, componentId, type, id, externalId); + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java new file mode 100644 index 0000000000..ade6848ccd --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java @@ -0,0 +1,89 @@ +package sh.libre.scim.jpa; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; +import sh.libre.scim.core.service.EntityOnRemoteScimId; +import sh.libre.scim.core.service.KeycloakId; + +@Entity +@IdClass(ScimResourceId.class) +@Table(name = "SCIM_RESOURCE_MAPPING") +@NamedQueries({ + @NamedQuery(name = "findById", query = "from ScimResourceMapping where realmId = :realmId and componentId = :componentId and type = :type and id = :id"), + @NamedQuery(name = "findByExternalId", query = "from ScimResourceMapping where realmId = :realmId and componentId = :componentId and type = :type and externalId = :id") +}) +public class ScimResourceMapping { + + @Id + @Column(name = "ID", nullable = false) + private String id; + + @Id + @Column(name = "REALM_ID", nullable = false) + private String realmId; + + @Id + @Column(name = "COMPONENT_ID", nullable = false) + private String componentId; + + @Id + @Column(name = "TYPE", nullable = false) + private String type; + + @Id + @Column(name = "EXTERNAL_ID", nullable = false) + private String externalId; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRealmId() { + return realmId; + } + + public void setRealmId(String realmId) { + this.realmId = realmId; + } + + public String getComponentId() { + return componentId; + } + + public void setComponentId(String componentId) { + this.componentId = componentId; + } + + public String getExternalId() { + return externalId; + } + + public void setExternalId(String externalId) { + this.externalId = externalId; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public KeycloakId getIdAsKeycloakId() { + return new KeycloakId(id); + } + + public EntityOnRemoteScimId getExternalIdAsEntityOnRemoteScimId() { + return new EntityOnRemoteScimId(externalId); + } +} diff --git a/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java new file mode 100644 index 0000000000..6ef55a060e --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java @@ -0,0 +1,29 @@ +package sh.libre.scim.jpa; + +import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider; + +import java.util.Collections; +import java.util.List; + +public class ScimResourceProvider implements JpaEntityProvider { + + @Override + public List> getEntities() { + return Collections.singletonList(ScimResourceMapping.class); + } + + @Override + public String getChangelogLocation() { + return "META-INF/scim-resource-changelog.xml"; + } + + @Override + public void close() { + // Nothing to close + } + + @Override + public String getFactoryId() { + return ScimResourceProviderFactory.ID; + } +} \ No newline at end of file diff --git a/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java new file mode 100644 index 0000000000..7f3cc323dd --- /dev/null +++ b/federation/scim/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java @@ -0,0 +1,39 @@ +package sh.libre.scim.jpa; + +import org.keycloak.Config.Scope; +import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider; +import org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class ScimResourceProviderFactory implements JpaEntityProviderFactory { + + static final String ID = "scim-resource"; + + @Override + public JpaEntityProvider create(KeycloakSession session) { + return new ScimResourceProvider(); + } + + @Override + public String getId() { + return ID; + } + + @Override + public void init(Scope scope) { + // Nothing to initialise + } + + @Override + public void postInit(KeycloakSessionFactory sessionFactory) { + // Nothing to do + } + + + @Override + public void close() { + // Nothing to close + } + +} diff --git a/federation/scim/src/main/resources/META-INF/jboss-deployment-structure.xml b/federation/scim/src/main/resources/META-INF/jboss-deployment-structure.xml new file mode 100644 index 0000000000..42007fe638 --- /dev/null +++ b/federation/scim/src/main/resources/META-INF/jboss-deployment-structure.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/federation/scim/src/main/resources/META-INF/scim-resource-changelog.xml b/federation/scim/src/main/resources/META-INF/scim-resource-changelog.xml new file mode 100644 index 0000000000..d3e2687a57 --- /dev/null +++ b/federation/scim/src/main/resources/META-INF/scim-resource-changelog.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/federation/scim/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory b/federation/scim/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory new file mode 100644 index 0000000000..b3cb1a13e3 --- /dev/null +++ b/federation/scim/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory @@ -0,0 +1 @@ +sh.libre.scim.jpa.ScimResourceProviderFactory diff --git a/federation/scim/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory b/federation/scim/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory new file mode 100644 index 0000000000..7e2a6edd9c --- /dev/null +++ b/federation/scim/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory @@ -0,0 +1 @@ +sh.libre.scim.event.ScimEventListenerProviderFactory diff --git a/federation/scim/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/federation/scim/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory new file mode 100644 index 0000000000..308796c862 --- /dev/null +++ b/federation/scim/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -0,0 +1 @@ +sh.libre.scim.core.ScimEndpointConfigurationStorageProviderFactory diff --git a/quarkus/server/pom.xml b/quarkus/server/pom.xml index 8eb0d9b29f..ecc90b79bd 100644 --- a/quarkus/server/pom.xml +++ b/quarkus/server/pom.xml @@ -40,6 +40,10 @@ ojdbc11 provided + + com.oracle.database.jdbc + ojdbc8 +