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/dependencies/server-all/pom.xml b/dependencies/server-all/pom.xml index c25827c2a3..9feca587de 100755 --- a/dependencies/server-all/pom.xml +++ b/dependencies/server-all/pom.xml @@ -70,6 +70,12 @@ org.keycloak keycloak-kerberos-federation + + + org.keycloak + keycloak-scim-federation + compile + org.keycloak diff --git a/docs/documentation/server_admin/images/scim-event-listener-page.png b/docs/documentation/server_admin/images/scim-event-listener-page.png new file mode 100644 index 0000000000..5c45734c68 Binary files /dev/null and b/docs/documentation/server_admin/images/scim-event-listener-page.png differ diff --git a/docs/documentation/server_admin/images/scim-federation-provider-page.png b/docs/documentation/server_admin/images/scim-federation-provider-page.png new file mode 100644 index 0000000000..390128f581 Binary files /dev/null and b/docs/documentation/server_admin/images/scim-federation-provider-page.png differ diff --git a/docs/documentation/server_admin/topics/user-federation/scim.adoc b/docs/documentation/server_admin/topics/user-federation/scim.adoc new file mode 100644 index 0000000000..59b48677fa --- /dev/null +++ b/docs/documentation/server_admin/topics/user-federation/scim.adoc @@ -0,0 +1,74 @@ +[[_scim]] + +=== SCIM client capabilities + +{project_name} includes a http://www.simplecloud.info[SCIM2] client allowing to : + +* Declare SCIM endpoints (through the identity federation UI). Any tool implementing SCIM protocol can be wired to the +{project_name} instance through this declaration. +* Propagate users and groups from {project_name} to SCIM endpoints : when a user/group gets created or modified in {project_name}, +the modification is forwarded 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. +* Synchronize users and groups from SCIM endpoints (through the {project_name} synchronization mechanism). + +See https://datatracker.ietf.org/doc/html/rfc7643[RFC7643] +and https://datatracker.ietf.org/doc/html/rfc7644[RFC7644] for further details + +==== Enabling SCIM extension + +[NOTE] +==== +This extension is currently in experimental mode, and requires the ```SCIM``` experimental Profile to be enabled +==== + +.Procedure +. Click on *Admin Console > Realm Settings > Events* in the menu. +. Add `scim` to the list of event listeners +image:images/scim-event-listener-page.png[Enable SCIM Event listeners] +. Save + +==== Registering SCIM Service Providers + +.Procedure +. Click on *User federation > Add Scim Providers* +image:images/scim-federation-provider-page.png[Configure SCIM service provider] +. Fill required fields according to the SCIM endpoint you are wiring +. If you enable import during sync then you can choose between to following import actions: + +- Create Local - adds users to keycloak +- Nothing +- Delete Remote - deletes users from the remote application + +==== Sync + +You can set up a periodic sync for all users or just changed users - it's not mandatory. You can either do: + +- Periodic Full Sync +- Periodic Changed User Sync + + +==== Technical notes + +===== Motivation + +We want to build a unified collaborative platform based on multiple applications. To do that, we need a way to propagate +immediately changes made in Keycloak to all these applications. And we want to keep using OIDC or SAML as the +authentication protocol. + +This will allow users to collaborate seamlessly across the platform without requiring every user to have connected once +to each application. This will also ease GDRP compliance because deleting a user in Keycloak will delete the user from +every app. The SCIM protocol is standard, comprehensible and easy to implement. It's a perfect fit for our goal. + +We chose to build application extensions/plugins because it's easier to deploy and thus will benefit to a larger portion +of the FOSS community. + +===== Keycloak specific + +This extension uses 3 concepts in KeyCloak : + +- Event Listener : used to listen for changes within Keycloak (e.g. User creation, Group deletion...) and propagate +them to registered SCIM service providers through SCIM requests. +- Federation Provider : used to set up all the SCIM service providers endpoint without creating our own UI. +- JPA Entity Provider : used to save the mapping between the local IDs and the service providers IDs. + +It is based on https://github.com/Captain-P-Goldfish/SCIM-SDK[Scim SDK]. 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..5f978f840e --- /dev/null +++ b/federation/scim/pom.xml @@ -0,0 +1,83 @@ + + + + keycloak-parent + org.keycloak + 999.0.0-SNAPSHOT + ../../pom.xml + + 4.0.0 + + keycloak-scim-federation + Keycloak Federation from SCIM endpoints + + This extension adds SCIM2 client capabilities to Keycloak using [Scim SDK](https://github.com/Captain-P-Goldfish/SCIM-SDK). + It allows to : + * Declare SCIM endpoints (through the identity federation UI). Any tool implementing SCIM protocol can be wired to the + Keycloak instance through this declaration. + * Propagate users and groups from Keycloak to SCIM endpoints : when a user/group gets created or modified in Keycloak, + the modification is forwarded 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 Keycloak 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 + 2.0.2.Final + + + + 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} + compile + + + de.captaingoldfish + scim-sdk-common + ${scim-sdk-version} + compile + + + de.captaingoldfish + scim-sdk-client + ${scim-sdk-version} + compile + + + + diff --git a/federation/scim/src/main/java/org/keycloak/federation/scim/core/ScimDispatcher.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/ScimDispatcher.java new file mode 100644 index 0000000000..681a468f2f --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/ScimDispatcher.java @@ -0,0 +1,179 @@ +package org.keycloak.federation.scim.core; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.federation.scim.core.exceptions.ScimExceptionHandler; +import org.keycloak.federation.scim.core.exceptions.ScimPropagationException; +import org.keycloak.federation.scim.core.exceptions.SkipOrStopApproach; +import org.keycloak.federation.scim.core.exceptions.SkipOrStopStrategy; +import org.keycloak.federation.scim.core.service.AbstractScimService; +import org.keycloak.federation.scim.core.service.GroupScimService; +import org.keycloak.federation.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 final List userScimServices = new ArrayList<>(); + private final List groupScimServices = new ArrayList<>(); + private boolean clientsInitialized = false; + + 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/org/keycloak/federation/scim/core/ScimEndpointConfigurationStorageProviderFactory.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/ScimEndpointConfigurationStorageProviderFactory.java new file mode 100644 index 0000000000..97343ccf3f --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/ScimEndpointConfigurationStorageProviderFactory.java @@ -0,0 +1,118 @@ +package org.keycloak.federation.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 org.keycloak.federation.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/org/keycloak/federation/scim/core/ScrimEndPointConfiguration.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/ScrimEndPointConfiguration.java new file mode 100644 index 0000000000..e49ed77c69 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/ScrimEndPointConfiguration.java @@ -0,0 +1,100 @@ +package org.keycloak.federation.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/org/keycloak/federation/scim/core/exceptions/InconsistentScimMappingException.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/InconsistentScimMappingException.java new file mode 100644 index 0000000000..4d6526b6f3 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/InconsistentScimMappingException.java @@ -0,0 +1,7 @@ +package org.keycloak.federation.scim.core.exceptions; + +public class InconsistentScimMappingException extends ScimPropagationException { + public InconsistentScimMappingException(String message) { + super(message); + } +} diff --git a/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/InvalidResponseFromScimEndpointException.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/InvalidResponseFromScimEndpointException.java new file mode 100644 index 0000000000..15835f7f02 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/InvalidResponseFromScimEndpointException.java @@ -0,0 +1,28 @@ +package org.keycloak.federation.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/org/keycloak/federation/scim/core/exceptions/RollbackApproach.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/RollbackApproach.java new file mode 100644 index 0000000000..639ca4c563 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/RollbackApproach.java @@ -0,0 +1,53 @@ +package org.keycloak.federation.scim.core.exceptions; + +import com.google.common.collect.Lists; +import org.keycloak.federation.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/org/keycloak/federation/scim/core/exceptions/RollbackStrategy.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/RollbackStrategy.java new file mode 100644 index 0000000000..3316f5115f --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/RollbackStrategy.java @@ -0,0 +1,20 @@ +package org.keycloak.federation.scim.core.exceptions; + +import org.keycloak.federation.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/org/keycloak/federation/scim/core/exceptions/ScimExceptionHandler.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/ScimExceptionHandler.java new file mode 100644 index 0000000000..7d4fefe3c0 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/ScimExceptionHandler.java @@ -0,0 +1,41 @@ +package org.keycloak.federation.scim.core.exceptions; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.federation.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/org/keycloak/federation/scim/core/exceptions/ScimPropagationException.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/ScimPropagationException.java new file mode 100644 index 0000000000..495ec479fa --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/ScimPropagationException.java @@ -0,0 +1,12 @@ +package org.keycloak.federation.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/org/keycloak/federation/scim/core/exceptions/SkipOrStopApproach.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/SkipOrStopApproach.java new file mode 100644 index 0000000000..6b510d11ec --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/SkipOrStopApproach.java @@ -0,0 +1,58 @@ +package org.keycloak.federation.scim.core.exceptions; + +import org.keycloak.federation.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/org/keycloak/federation/scim/core/exceptions/SkipOrStopStrategy.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/SkipOrStopStrategy.java new file mode 100644 index 0000000000..bdfd9f4127 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/SkipOrStopStrategy.java @@ -0,0 +1,58 @@ +package org.keycloak.federation.scim.core.exceptions; + +import org.keycloak.federation.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/org/keycloak/federation/scim/core/exceptions/UnexpectedScimDataException.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/UnexpectedScimDataException.java new file mode 100644 index 0000000000..67afeb28af --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/exceptions/UnexpectedScimDataException.java @@ -0,0 +1,7 @@ +package org.keycloak.federation.scim.core.exceptions; + +public class UnexpectedScimDataException extends ScimPropagationException { + public UnexpectedScimDataException(String message) { + super(message); + } +} diff --git a/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/AbstractScimService.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/AbstractScimService.java new file mode 100644 index 0000000000..3f08eb9bf3 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/AbstractScimService.java @@ -0,0 +1,292 @@ +package org.keycloak.federation.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 org.keycloak.federation.scim.core.ScrimEndPointConfiguration; +import org.keycloak.federation.scim.core.exceptions.InconsistentScimMappingException; +import org.keycloak.federation.scim.core.exceptions.InvalidResponseFromScimEndpointException; +import org.keycloak.federation.scim.core.exceptions.SkipOrStopStrategy; +import org.keycloak.federation.scim.core.exceptions.UnexpectedScimDataException; +import org.keycloak.federation.scim.jpa.ScimResourceDao; +import org.keycloak.federation.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); + protected final SkipOrStopStrategy skipOrStopStrategy; + private final KeycloakSession keycloakSession; + 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/org/keycloak/federation/scim/core/service/EntityOnRemoteScimId.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/EntityOnRemoteScimId.java new file mode 100644 index 0000000000..20bf898439 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/EntityOnRemoteScimId.java @@ -0,0 +1,4 @@ +package org.keycloak.federation.scim.core.service; + +public record EntityOnRemoteScimId(String asString) { +} diff --git a/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/GroupScimService.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/GroupScimService.java new file mode 100644 index 0000000000..d5320850da --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/GroupScimService.java @@ -0,0 +1,130 @@ +package org.keycloak.federation.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 org.keycloak.federation.scim.core.ScrimEndPointConfiguration; +import org.keycloak.federation.scim.core.exceptions.InconsistentScimMappingException; +import org.keycloak.federation.scim.core.exceptions.SkipOrStopStrategy; +import org.keycloak.federation.scim.core.exceptions.UnexpectedScimDataException; +import org.keycloak.federation.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/org/keycloak/federation/scim/core/service/KeycloakDao.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/KeycloakDao.java new file mode 100644 index 0000000000..7ff0c3c581 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/KeycloakDao.java @@ -0,0 +1,76 @@ +package org.keycloak.federation.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/org/keycloak/federation/scim/core/service/KeycloakId.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/KeycloakId.java new file mode 100644 index 0000000000..138ad47c89 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/KeycloakId.java @@ -0,0 +1,5 @@ +package org.keycloak.federation.scim.core.service; + +public record KeycloakId(String asString) { + +} diff --git a/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/ScimClient.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/ScimClient.java new file mode 100644 index 0000000000..974467abbb --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/ScimClient.java @@ -0,0 +1,138 @@ +package org.keycloak.federation.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 org.keycloak.federation.scim.core.ScrimEndPointConfiguration; +import org.keycloak.federation.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/org/keycloak/federation/scim/core/service/ScimResourceType.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/ScimResourceType.java new file mode 100644 index 0000000000..0e20f67f80 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/ScimResourceType.java @@ -0,0 +1,29 @@ +package org.keycloak.federation.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/org/keycloak/federation/scim/core/service/UserScimService.java b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/UserScimService.java new file mode 100644 index 0000000000..9111dde6af --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/core/service/UserScimService.java @@ -0,0 +1,130 @@ +package org.keycloak.federation.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 org.keycloak.federation.scim.core.ScrimEndPointConfiguration; +import org.keycloak.federation.scim.core.exceptions.InconsistentScimMappingException; +import org.keycloak.federation.scim.core.exceptions.SkipOrStopStrategy; +import org.keycloak.federation.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/org/keycloak/federation/scim/event/ScimBackgroundGroupMembershipUpdater.java b/federation/scim/src/main/java/org/keycloak/federation/scim/event/ScimBackgroundGroupMembershipUpdater.java new file mode 100644 index 0000000000..f11efa2177 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/event/ScimBackgroundGroupMembershipUpdater.java @@ -0,0 +1,72 @@ +package org.keycloak.federation.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 org.keycloak.federation.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/org/keycloak/federation/scim/event/ScimEventListenerProvider.java b/federation/scim/src/main/java/org/keycloak/federation/scim/event/ScimEventListenerProvider.java new file mode 100644 index 0000000000..3cc7bc2f8b --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/event/ScimEventListenerProvider.java @@ -0,0 +1,242 @@ +package org.keycloak.federation.scim.event; + +import org.jboss.logging.Logger; +import org.keycloak.common.Profile; +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 org.keycloak.federation.scim.core.ScimDispatcher; +import org.keycloak.federation.scim.core.ScimEndpointConfigurationStorageProviderFactory; +import org.keycloak.federation.scim.core.service.KeycloakDao; +import org.keycloak.federation.scim.core.service.KeycloakId; +import org.keycloak.federation.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) { + if (Profile.isFeatureEnabled(Profile.Feature.SCIM)) { + // 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) { + if (Profile.isFeatureEnabled(Profile.Feature.SCIM)) { + // 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/org/keycloak/federation/scim/event/ScimEventListenerProviderFactory.java b/federation/scim/src/main/java/org/keycloak/federation/scim/event/ScimEventListenerProviderFactory.java new file mode 100644 index 0000000000..d740fdafa5 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/event/ScimEventListenerProviderFactory.java @@ -0,0 +1,36 @@ +package org.keycloak.federation.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/org/keycloak/federation/scim/jpa/ScimResourceDao.java b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceDao.java new file mode 100644 index 0000000000..f91eed0643 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceDao.java @@ -0,0 +1,88 @@ +package org.keycloak.federation.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 org.keycloak.federation.scim.core.service.EntityOnRemoteScimId; +import org.keycloak.federation.scim.core.service.KeycloakId; +import org.keycloak.federation.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/org/keycloak/federation/scim/jpa/ScimResourceId.java b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceId.java new file mode 100644 index 0000000000..d4592a5f29 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceId.java @@ -0,0 +1,81 @@ +package org.keycloak.federation.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/org/keycloak/federation/scim/jpa/ScimResourceMapping.java b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceMapping.java new file mode 100644 index 0000000000..6e2994d18b --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceMapping.java @@ -0,0 +1,88 @@ +package org.keycloak.federation.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 org.keycloak.federation.scim.core.service.EntityOnRemoteScimId; +import org.keycloak.federation.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/org/keycloak/federation/scim/jpa/ScimResourceProvider.java b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceProvider.java new file mode 100644 index 0000000000..3aebe1ebe9 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceProvider.java @@ -0,0 +1,29 @@ +package org.keycloak.federation.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; + } +} diff --git a/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceProviderFactory.java b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceProviderFactory.java new file mode 100644 index 0000000000..30e36f1e98 --- /dev/null +++ b/federation/scim/src/main/java/org/keycloak/federation/scim/jpa/ScimResourceProviderFactory.java @@ -0,0 +1,38 @@ +package org.keycloak.federation.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..6308b8b6bf --- /dev/null +++ b/federation/scim/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory @@ -0,0 +1 @@ +org.keycloak.federation.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..75ff9113c9 --- /dev/null +++ b/federation/scim/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory @@ -0,0 +1 @@ +org.keycloak.federation.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..8b3a77fbb7 --- /dev/null +++ b/federation/scim/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -0,0 +1 @@ +org.keycloak.federation.scim.core.ScimEndpointConfigurationStorageProviderFactory diff --git a/pom.xml b/pom.xml index 1311f05029..0d4fca760d 100644 --- a/pom.xml +++ b/pom.xml @@ -926,6 +926,11 @@ keycloak-ldap-federation ${project.version} + + org.keycloak + keycloak-scim-federation + ${project.version} + org.keycloak keycloak-dependencies-server-min diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml index 0155a9030d..f1d9272e75 100644 --- a/quarkus/runtime/pom.xml +++ b/quarkus/runtime/pom.xml @@ -324,6 +324,11 @@ + + org.keycloak + keycloak-scim-federation + compile + org.keycloak keycloak-config-api