Provide SCIM2 client capabilities behing an experimental Feature Profile
Some checks failed
Keycloak Guides / Check conditional workflows and jobs (push) Has been cancelled
Keycloak CI / Check conditional workflows and jobs (push) Has been cancelled
CodeQL / Check conditional workflows and jobs (push) Has been cancelled
Keycloak Documentation / Check conditional workflows and jobs (push) Has been cancelled
Keycloak JavaScript CI / Check conditional workflows and jobs (push) Has been cancelled
Keycloak Operator CI / Check conditional workflows and jobs (push) Has been cancelled
Keycloak CI / FIPS IT (push) Has been cancelled
Keycloak Guides / Build (push) Has been cancelled
Keycloak Guides / Status Check - Keycloak Guides (push) Has been cancelled
Keycloak Operator CI / Build distribution (push) Has been cancelled
Keycloak Operator CI / Test local (push) Has been cancelled
Keycloak CI / Migration Tests (push) Has been cancelled
Keycloak CI / Build (push) Has been cancelled
Keycloak CI / Base UT (push) Has been cancelled
Keycloak CI / Base IT (push) Has been cancelled
Keycloak CI / Quarkus IT (push) Has been cancelled
Keycloak CI / Adapter IT (push) Has been cancelled
Keycloak CI / Quarkus UT (push) Has been cancelled
Keycloak CI / Java Distribution IT (push) Has been cancelled
Keycloak CI / Volatile Sessions IT (push) Has been cancelled
Keycloak CI / External Infinispan IT (push) Has been cancelled
Keycloak CI / AuroraDB IT (push) Has been cancelled
Keycloak CI / Store IT (push) Has been cancelled
Keycloak CI / Store Model Tests (push) Has been cancelled
Keycloak CI / Clustering IT (push) Has been cancelled
Keycloak CI / FIPS UT (push) Has been cancelled
Keycloak CI / Forms IT (push) Has been cancelled
Keycloak CI / WebAuthn IT (push) Has been cancelled
Keycloak CI / SSSD (push) Has been cancelled
Keycloak CI / Test Framework (push) Has been cancelled
Keycloak CI / Base IT (new) (push) Has been cancelled
Keycloak CI / Status Check - Keycloak CI (push) Has been cancelled
CodeQL / CodeQL Java (push) Has been cancelled
CodeQL / CodeQL JavaScript (push) Has been cancelled
CodeQL / CodeQL TypeScript (push) Has been cancelled
CodeQL / Status Check - CodeQL (push) Has been cancelled
Keycloak Documentation / Build (push) Has been cancelled
Keycloak Documentation / External links check (push) Has been cancelled
Keycloak Documentation / Status Check - Keycloak Documentation (push) Has been cancelled
Keycloak JavaScript CI / Build Keycloak (push) Has been cancelled
Keycloak JavaScript CI / Admin Client (push) Has been cancelled
Keycloak JavaScript CI / UI Shared (push) Has been cancelled
Keycloak JavaScript CI / Account UI (push) Has been cancelled
Keycloak JavaScript CI / Admin UI (push) Has been cancelled
Keycloak JavaScript CI / Account UI E2E (push) Has been cancelled
Keycloak JavaScript CI / Generate Test Seed (push) Has been cancelled
Keycloak JavaScript CI / Admin UI E2E (push) Has been cancelled
Keycloak JavaScript CI / Status Check - Keycloak JavaScript CI (push) Has been cancelled
Keycloak Operator CI / Test remote (push) Has been cancelled
Keycloak Operator CI / Test OLM installation (push) Has been cancelled
Keycloak Operator CI / Status Check - Keycloak Operator CI (push) Has been cancelled

Closes #1234

Signed-off-by: Alex Morel <amorel@codelutin.com>
This commit is contained in:
Alex Morel 2024-11-04 17:48:05 +01:00
parent 83065b85a2
commit 1c7f7e7e54
42 changed files with 2383 additions and 0 deletions

View file

@ -114,6 +114,8 @@ public class Profile {
OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL), 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), OPENTELEMETRY("OpenTelemetry Tracing", Type.PREVIEW),
DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL), DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL),

View file

@ -70,6 +70,12 @@
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-kerberos-federation</artifactId> <artifactId>keycloak-kerberos-federation</artifactId>
</dependency> </dependency>
<!-- SCIM federation -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-federation</artifactId>
<scope>compile</scope>
</dependency>
<!-- saml --> <!-- saml -->
<dependency> <dependency>
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -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].

View file

@ -36,6 +36,7 @@
<module>kerberos</module> <module>kerberos</module>
<module>ldap</module> <module>ldap</module>
<module>sssd</module> <module>sssd</module>
<module>scim</module>
</modules> </modules>
</project> </project>

83
federation/scim/pom.xml Normal file
View file

@ -0,0 +1,83 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>keycloak-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-scim-federation</artifactId>
<name>Keycloak Federation from SCIM endpoints</name>
<description>
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
</description>
<properties>
<scim-sdk-version>1.26.0</scim-sdk-version>
<r4j-version>2.2.0</r4j-version>
<maven-wildfly-plugin.version>2.0.2.Final</maven-wildfly-plugin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-jpa</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-retry</artifactId>
<version>${r4j-version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>de.captaingoldfish</groupId>
<artifactId>scim-sdk-common</artifactId>
<version>${scim-sdk-version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>de.captaingoldfish</groupId>
<artifactId>scim-sdk-client</artifactId>
<version>${scim-sdk-version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View file

@ -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<UserScimService> userScimServices = new ArrayList<>();
private final List<GroupScimService> 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<UserScimService> operationToDispatch) {
initializeClientsIfNeeded();
Set<UserScimService> 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<GroupScimService> operationToDispatch) {
initializeClientsIfNeeded();
Set<GroupScimService> 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<UserScimService> operationToDispatch) {
initializeClientsIfNeeded();
// Scim client should already have been created
Optional<UserScimService> 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<GroupScimService> operationToDispatch) {
initializeClientsIfNeeded();
// Scim client should already have been created
Optional<GroupScimService> 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 <T> An {@link AbstractScimService to call}
*/
@FunctionalInterface
public interface SCIMPropagationConsumer<T> {
void acceptThrows(T elem) throws ScimPropagationException;
}
}

View file

@ -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<ScimEndpointConfigurationStorageProviderFactory.ScimEndpointConfigurationStorageProvider>,
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<ProviderConfigProperty> 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
}
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,7 @@
package org.keycloak.federation.scim.core.exceptions;
public class InconsistentScimMappingException extends ScimPropagationException {
public InconsistentScimMappingException(String message) {
super(message);
}
}

View file

@ -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<ServerResponse> 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<ServerResponse> getResponse() {
return response;
}
}

View file

@ -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<Integer> 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);
}
}
}

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}
}

View file

@ -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);
}

View file

@ -0,0 +1,7 @@
package org.keycloak.federation.scim.core.exceptions;
public class UnexpectedScimDataException extends ScimPropagationException {
public UnexpectedScimDataException(String message) {
super(message);
}
}

View file

@ -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 <K> The Keycloack Model (e.g. UserModel, GroupModel)
* @param <S> The SCIM Resource (e.g. User, Group)
*/
public abstract class AbstractScimService<K extends RoleMapperModel, S extends ResourceNode> 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<S> 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<K> resourcesStream = getResourceStream()) {
Set<K> 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<KeycloakId> 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<ScimResourceMapping> 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<ScimResourceMapping> findMappingById(KeycloakId keycloakId) {
return getScimResourceDao().findById(keycloakId, type);
}
private KeycloakSession getKeycloakSession() {
return keycloakSession;
}
protected abstract boolean shouldIgnoreForScimSynchronization(K resource);
protected abstract Stream<K> getResourceStream();
protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException, InconsistentScimMappingException;
protected abstract Optional<KeycloakId> 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;
}
}

View file

@ -0,0 +1,4 @@
package org.keycloak.federation.scim.core.service;
public record EntityOnRemoteScimId(String asString) {
}

View file

@ -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<GroupModel, Group> {
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<GroupModel> getResourceStream() {
return getKeycloakDao().getGroupsStream();
}
@Override
protected boolean entityExists(KeycloakId keycloakId) {
return getKeycloakDao().groupExists(keycloakId);
}
@Override
protected Optional<KeycloakId> matchKeycloakMappingByScimProperties(Group resource) {
Set<String> names = new TreeSet<>();
resource.getId().ifPresent(names::add);
resource.getDisplayName().ifPresent(names::add);
try (Stream<GroupModel> groupsStream = getKeycloakDao().getGroupsStream()) {
Optional<GroupModel> 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<Member> 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<KeycloakId> 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<ScimResourceMapping> 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;
}
}

View file

@ -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<GroupModel> getGroupsStream() {
return getKeycloakSession().groups().getGroupsStream(getRealm());
}
public GroupModel createGroup(String displayName) {
return getKeycloakSession().groups().createGroup(getRealm(), displayName);
}
public Set<KeycloakId> getGroupMembers(GroupModel groupModel) {
return getKeycloakSession().users().getGroupMembersStream(getRealm(), groupModel).map(UserModel::getId)
.map(KeycloakId::new).collect(Collectors.toSet());
}
public Stream<UserModel> 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);
}
}

View file

@ -0,0 +1,5 @@
package org.keycloak.federation.scim.core.service;
public record KeycloakId(String asString) {
}

View file

@ -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<S extends ResourceNode> 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 <T extends ResourceNode> ScimClient<T> open(ScrimEndPointConfiguration scimProviderConfiguration,
ScimResourceType scimResourceType) {
String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint();
Map<String, String> 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<String> 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<S> 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<S> 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<S> 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<S> 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<S> 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<S> listResources() {
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get()
.sendRequest();
ListResponse<S> resourceTypeListResponse = response.getResource();
return resourceTypeListResponse.getListedResources();
}
}

View file

@ -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<? extends ResourceNode> resourceClass;
ScimResourceType(String endpoint, Class<? extends ResourceNode> resourceClass) {
this.endpoint = endpoint;
this.resourceClass = resourceClass;
}
public String getEndpoint() {
return endpoint;
}
public <T extends ResourceNode> Class<T> getResourceClass() {
return (Class<T>) resourceClass;
}
}

View file

@ -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<UserModel, User> {
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<UserModel> getResourceStream() {
return getKeycloakDao().getUsersStream();
}
@Override
protected boolean entityExists(KeycloakId keycloakId) {
return getKeycloakDao().userExists(keycloakId);
}
@Override
protected Optional<KeycloakId> matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimMappingException {
Optional<KeycloakId> matchedByUsername = resource.getUserName().map(getKeycloakDao()::getUserByUsername)
.map(this::getId);
Optional<KeycloakId> 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<RoleModel> groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream);
Stream<RoleModel> roleModels = roleMapperModel.getRoleMappingsStream();
Stream<RoleModel> allRoleModels = Stream.concat(groupRoleModels, roleModels);
List<PersonRole> 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<Email> 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());
}
}

View file

@ -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.
* <p>
* 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;
}
}
}

View file

@ -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<ResourceType, Pattern> 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<ComponentModel> 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();
}
}

View file

@ -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
}
}

View file

@ -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<ScimResourceMapping> 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<ScimResourceMapping> findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) {
try {
return Optional.of(getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult());
} catch (NoResultException e) {
return Optional.empty();
}
}
public Optional<ScimResourceMapping> findById(KeycloakId keycloakId, ScimResourceType type) {
try {
return Optional.of(getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult());
} catch (NoResultException e) {
return Optional.empty();
}
}
public Optional<ScimResourceMapping> findUserById(KeycloakId id) {
return findById(id, ScimResourceType.USER);
}
public Optional<ScimResourceMapping> findUserByExternalId(EntityOnRemoteScimId externalId) {
return findByExternalId(externalId, ScimResourceType.USER);
}
public void delete(ScimResourceMapping resource) {
entityManager.remove(resource);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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<Class<?>> 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;
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure>
<deployment>
<dependencies>
<module name="org.keycloak.keycloak-services" />
<module name="org.keycloak.keycloak-model-jpa" />
<module name="org.hibernate" />
</dependencies>
</deployment>
</jboss-deployment-structure>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet author="contact@indiehosters.net" id="scim-resource-1.0">
<createTable tableName="SCIM_RESOURCE_MAPPING">
<column name="ID" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="REALM_ID" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="TYPE" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="COMPONENT_ID" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="EXTERNAL_ID" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
</createTable>
<addPrimaryKey constraintName="PK_SCIM_RESOURCE_MAPPING" tableName="SCIM_RESOURCE_MAPPING"
columnNames="ID,REALM_ID,TYPE,COMPONENT_ID,EXTERNAL_ID"/>
<addForeignKeyConstraint baseTableName="SCIM_RESOURCE_MAPPING" baseColumnNames="REALM_ID"
constraintName="FK_SCIM_RESOURCE_MAPPING_REALM" referencedTableName="REALM"
referencedColumnNames="ID" onDelete="CASCADE" onUpdate="CASCADE"/>
<addForeignKeyConstraint baseTableName="SCIM_RESOURCE_MAPPING" baseColumnNames="COMPONENT_ID"
constraintName="FK_SCIM_RESOURCE_MAPPING_COMPONENT" referencedTableName="COMPONENT"
referencedColumnNames="ID" onDelete="CASCADE" onUpdate="CASCADE"/>
</changeSet>
</databaseChangeLog>

View file

@ -0,0 +1 @@
org.keycloak.federation.scim.jpa.ScimResourceProviderFactory

View file

@ -0,0 +1 @@
org.keycloak.federation.scim.event.ScimEventListenerProviderFactory

View file

@ -0,0 +1 @@
org.keycloak.federation.scim.core.ScimEndpointConfigurationStorageProviderFactory

View file

@ -927,6 +927,11 @@
<artifactId>keycloak-ldap-federation</artifactId> <artifactId>keycloak-ldap-federation</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-federation</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-dependencies-server-min</artifactId> <artifactId>keycloak-dependencies-server-min</artifactId>

View file

@ -324,6 +324,11 @@
</exclusion> </exclusion>
</exclusions> </exclusions>
</dependency> </dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-federation</artifactId>
<scope>compile</scope>
</dependency>
<dependency> <dependency>
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-config-api</artifactId> <artifactId>keycloak-config-api</artifactId>