From f00130d37af527c2ac0c717f25805de8b8eb0f6b Mon Sep 17 00:00:00 2001
From: Alex Morel <amorel@codelutin.com>
Date: Tue, 18 Jun 2024 16:49:24 +0200
Subject: [PATCH] Simplify ScimStorageProvider

---
 .../libre/scim/core/ScimClientInterface.java  |  10 +-
 .../sh/libre/scim/core/ScimDispatcher.java    |  27 +--
 .../scim/core/ScrimProviderConfiguration.java |  33 ++--
 .../scim/event/ScimEventListenerProvider.java |   8 +-
 .../scim/storage/ScimStorageProvider.java     |   9 -
 .../storage/ScimStorageProviderFactory.java   | 164 +++++++++---------
 6 files changed, 125 insertions(+), 126 deletions(-)
 delete mode 100644 src/main/java/sh/libre/scim/storage/ScimStorageProvider.java

diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java
index 1affb82..d28f289 100644
--- a/src/main/java/sh/libre/scim/core/ScimClientInterface.java
+++ b/src/main/java/sh/libre/scim/core/ScimClientInterface.java
@@ -5,14 +5,14 @@ import org.keycloak.storage.user.SynchronizationResult;
 
 /**
  * An interface for defining ScimClient.
- * A ScimClient provides methods for propagating CRUD and sync of a dedicated SCIM Resource (e.g. {@link de.captaingoldfish.scim.sdk.common.resources.User}). to a SCIM server defined in a {@link sh.libre.scim.storage.ScimStorageProvider}.
+ * A ScimClient provides methods for propagating CRUD and sync of a dedicated SCIM Resource (e.g. {@link de.captaingoldfish.scim.sdk.common.resources.User}). to a Scim endpoint defined in a {@link sh.libre.scim.storage.ScimStorageProvider}.
  *
  * @param <M> the keycloack model to synchronize (e.g. UserModel or GroupModel)
  */
 public interface ScimClientInterface<M extends RoleMapperModel> extends AutoCloseable {
 
     /**
-     * Propagates the creation of the given keycloack model to a SCIM server.
+     * Propagates the creation of the given keycloack model to a Scim endpoint.
      *
      * @param resource the created resource to propagate (e.g. a new UserModel)
      */
@@ -20,21 +20,21 @@ public interface ScimClientInterface<M extends RoleMapperModel> extends AutoClos
     void create(M resource);
 
     /**
-     * Propagates the update of the given keycloack model to a SCIM server.
+     * Propagates the update of the given keycloack model to a Scim endpoint.
      *
      * @param resource the resource creation to propagate (e.g. a UserModel)
      */
     void replace(M resource);
 
     /**
-     * Propagates the deletion of an element to a SCIM server.
+     * Propagates the deletion of an element to a Scim endpoint.
      *
      * @param id the deleted resource's id to propagate (e.g. id of a UserModel)
      */
     void delete(String id);
 
     /**
-     * Synchronizes resources between SCIM server and keycloack, according to configuration.
+     * Synchronizes resources between Scim endpoint and keycloack, according to configuration.
      *
      * @param result the synchronization result to update for indicating triggered operations (e.g. user deletions)
      */
diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java
index dcbe886..3434911 100644
--- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java
+++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java
@@ -9,7 +9,7 @@ import java.util.function.Consumer;
 import java.util.stream.Stream;
 
 /**
- * In charge of sending SCIM Request to all registered SCIM servers.
+ * In charge of sending SCIM Request to all registered Scim endpoints.
  */
 public class ScimDispatcher {
 
@@ -22,12 +22,12 @@ public class ScimDispatcher {
     }
 
     public void dispatchUserModificationToAll(Consumer<UserScimClient> operationToDispatch) {
-        getAllSCIMServer(Scope.USER).forEach(scimServer -> dispatchUserModificationToOne(scimServer, operationToDispatch));
+        getAllSCIMServer(Scope.USER).forEach(scimServerConfiguration -> dispatchUserModificationToOne(scimServerConfiguration, operationToDispatch));
     }
 
-    public void dispatchUserModificationToOne(ComponentModel scimServer, Consumer<UserScimClient> operationToDispatch) {
-        LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType());
-        try (UserScimClient client = UserScimClient.newUserScimClient(scimServer, session)) {
+    public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimClient> operationToDispatch) {
+        LOGGER.infof("%s %s %s %s", scimServerConfiguration.getId(), scimServerConfiguration.getName(), scimServerConfiguration.getProviderId(), scimServerConfiguration.getProviderType());
+        try (UserScimClient client = UserScimClient.newUserScimClient(scimServerConfiguration, session)) {
             operationToDispatch.accept(client);
         } catch (Exception e) {
             LOGGER.error(e);
@@ -35,12 +35,12 @@ public class ScimDispatcher {
     }
 
     public void dispatchGroupModificationToAll(Consumer<GroupScimClient> operationToDispatch) {
-        getAllSCIMServer(Scope.GROUP).forEach(scimServer -> dispatchGroupModificationToOne(scimServer, operationToDispatch));
+        getAllSCIMServer(Scope.GROUP).forEach(scimServerConfiguration -> dispatchGroupModificationToOne(scimServerConfiguration, operationToDispatch));
     }
 
-    public void dispatchGroupModificationToOne(ComponentModel scimServer, Consumer<GroupScimClient> operationToDispatch) {
-        LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType());
-        try (GroupScimClient client = GroupScimClient.newGroupScimClient(scimServer, session)) {
+    public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer<GroupScimClient> operationToDispatch) {
+        LOGGER.infof("%s %s %s %s", scimServerConfiguration.getId(), scimServerConfiguration.getName(), scimServerConfiguration.getProviderId(), scimServerConfiguration.getProviderType());
+        try (GroupScimClient client = GroupScimClient.newGroupScimClient(scimServerConfiguration, session)) {
             operationToDispatch.accept(client);
         } catch (Exception e) {
             LOGGER.error(e);
@@ -49,14 +49,19 @@ public class ScimDispatcher {
 
     /**
      * @param scope The {@link Scope} to consider (User or Group)
-     * @return all enabled registered SCIM Servers with propagation enabled for the given scope
+     * @return all enabled registered Scim endpoints with propagation enabled for the given scope
      */
     private Stream<ComponentModel> getAllSCIMServer(Scope scope) {
         // TODO : we could initiative this list once and invalidate it when configuration changes
+
+        String propagationConfKey = switch (scope) {
+            case GROUP -> ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP;
+            case USER -> ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER;
+        };
         return session.getContext().getRealm().getComponentsStream()
                 .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId())
                              && m.get("enabled", true)
-                             && m.get("propagation-" + scope.name(), false));
+                             && m.get(propagationConfKey, false));
     }
 
     public enum Scope {
diff --git a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java
index c689385..cec792d 100644
--- a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java
+++ b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java
@@ -4,6 +4,17 @@ import de.captaingoldfish.scim.sdk.client.http.BasicAuth;
 import org.keycloak.component.ComponentModel;
 
 public class ScrimProviderConfiguration {
+    // 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";
 
     private final String endPoint;
     private final String id;
@@ -14,25 +25,25 @@ public class ScrimProviderConfiguration {
     private final boolean syncRefresh;
 
     public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) {
-        AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get("auth-mode"));
+        AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE));
         authorizationHeaderValue = switch (authMode) {
-            case BEARER -> "Bearer " + scimProviderConfiguration.get("auth-pass");
+            case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD);
             case BASIC_AUTH -> {
                 BasicAuth basicAuth = BasicAuth.builder()
-                        .username(scimProviderConfiguration.get("auth-user"))
-                        .password(scimProviderConfiguration.get("auth-pass"))
+                        .username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER))
+                        .password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD))
                         .build();
                 yield basicAuth.getAuthorizationHeaderValue();
             }
             default ->
                     throw new IllegalArgumentException("authMode " + scimProviderConfiguration + " is not supported");
         };
-        contentType = scimProviderConfiguration.get("content-type");
-        endPoint = scimProviderConfiguration.get("endpoint");
+        contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE);
+        endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT);
         id = scimProviderConfiguration.getId();
-        importAction = ImportAction.valueOf(scimProviderConfiguration.get("sync-import-action"));
-        syncImport = scimProviderConfiguration.get("sync-import", false);
-        syncRefresh = scimProviderConfiguration.get("sync-refresh", false);
+        importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION));
+        syncImport = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false);
+        syncRefresh = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false);
     }
 
     public boolean isSyncRefresh() {
@@ -67,10 +78,6 @@ public class ScrimProviderConfiguration {
         BEARER, BASIC_AUTH, NONE
     }
 
-    public enum EndpointContentType {
-        JSON, SCIM_JSON
-    }
-
     public enum ImportAction {
         CREATE_LOCAL, DELETE_REMOTE, NOTHING
     }
diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java
index ef68c55..abfc3da 100644
--- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java
+++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java
@@ -19,7 +19,7 @@ import java.util.regex.Pattern;
 /**
  * An {@link java.util.EventListener} in charge of reaction to Keycloak models
  * modification (e.g. User creation, Group deletion, membership modifications...)
- * by propagating it to all registered SCIM servers.
+ * by propagating it to all registered Scim endpoints.
  */
 public class ScimEventListenerProvider implements EventListenerProvider {
 
@@ -59,7 +59,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
             }
             case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId));
             default -> {
-                // No other event has to be propagated to SCIM Servers
+                // No other event has to be propagated to Scim endpoints
             }
         }
     }
@@ -95,7 +95,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
                 handleRoleMappingEvent(event, type, id);
             }
             default -> {
-                // No other resource modification has to be propagated to SCIM Servers
+                // No other resource modification has to be propagated to Scim endpoints
             }
         }
     }
@@ -122,7 +122,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
     }
 
     /**
-     * Propagating the given group-related event to SCIM servers.
+     * Propagating the given group-related event to Scim endpoints.
      *
      * @param event   the event to propagate
      * @param groupId event target's id
diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java b/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java
deleted file mode 100644
index 1949606..0000000
--- a/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package sh.libre.scim.storage;
-
-import org.keycloak.storage.UserStorageProvider;
-
-public class ScimStorageProvider implements UserStorageProvider {
-    @Override
-    public void close() {
-    }
-}
diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java
index e5f4b36..62b931b 100644
--- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java
+++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java
@@ -8,45 +8,96 @@ import org.keycloak.component.ComponentModel;
 import org.keycloak.models.GroupModel;
 import org.keycloak.models.KeycloakSession;
 import org.keycloak.models.KeycloakSessionFactory;
-import org.keycloak.models.KeycloakSessionTask;
 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.timer.TimerProvider;
-import sh.libre.scim.core.GroupAdapter;
 import sh.libre.scim.core.ScimDispatcher;
-import sh.libre.scim.core.UserAdapter;
+import sh.libre.scim.core.ScrimProviderConfiguration;
 
 import java.time.Duration;
 import java.util.Date;
 import java.util.List;
 
 public class ScimStorageProviderFactory
-        implements UserStorageProviderFactory<ScimStorageProvider>, ImportSynchronization {
+        implements UserStorageProviderFactory<ScimStorageProviderFactory.ScimStorageProvider>, ImportSynchronization {
+    public static final String ID = "scim";
+    private final Logger logger = Logger.getLogger(ScimStorageProviderFactory.class);
 
-    private final Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class);
+    @Override
+    public String getId() {
+        return ID;
+    }
 
-    public static final String ID = "scim";
 
-    private static final List<ProviderConfigProperty> CONFIG_METADATA;
+    @Override
+    public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId,
+                                      UserStorageProviderModel model) {
+        // TODO if this should be kept here, better document prupose & usage
+        logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s", realmId);
+        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("propagation-user"))) {
+                dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result));
+            }
+            if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) {
+                dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result));
+            }
+        });
+        return result;
+    }
 
-    static {
-        CONFIG_METADATA = ProviderConfigurationBuilder.create()
+    @Override
+    public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId,
+                                           UserStorageProviderModel model) {
+        return this.sync(sessionFactory, realmId, model);
+    }
+
+    @Override
+    public void postInit(KeycloakSessionFactory factory) {
+        // TODO : find a better way to handle scim dirty (use a QUEUE for SCIM queries ?)
+        try (KeycloakSession keycloakSession = factory.create()) {
+            TimerProvider timer = keycloakSession.getProvider(TimerProvider.class);
+            timer.scheduleTask(taskSession -> {
+                for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) {
+                    KeycloakModelUtils.runJobInTransaction(factory, session -> {
+                        session.getContext().setRealm(realm);
+                        ScimDispatcher dispatcher = new ScimDispatcher(session);
+                        for (GroupModel group : session.groups().getGroupsStream(realm)
+                                .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) {
+                            logger.infof("[SCIM] Dirty group : %s", group.getName());
+                            dispatcher.dispatchGroupModificationToAll(client -> client.replace(group));
+                            group.removeAttribute("scim-dirty");
+                        }
+                    });
+                }
+            }, Duration.ofSeconds(30).toMillis(), "scim-background");
+        }
+    }
+
+    @Override
+    public List<ProviderConfigProperty> getConfigProperties() {
+        // These Config Properties will be use to generate configuration page in Admin Console
+        return ProviderConfigurationBuilder.create()
                 .property()
-                .name("endpoint")
+                .name(ScrimProviderConfiguration.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)")
+                          "URL (/ServiceProviderConfig  /Schemas and /ResourcesTypes should be accessible)")
                 .add()
                 .property()
-                .name("content-type")
+                .name(ScrimProviderConfiguration.CONF_KEY_CONTENT_TYPE)
                 .type(ProviderConfigProperty.LIST_TYPE)
                 .label("Endpoint content type")
                 .helpText("Only used when endpoint doesn't support application/scim+json")
@@ -54,7 +105,7 @@ public class ScimStorageProviderFactory
                 .defaultValue(HttpHeader.SCIM_CONTENT_TYPE)
                 .add()
                 .property()
-                .name("auth-mode")
+                .name(ScrimProviderConfiguration.CONF_KEY_AUTH_MODE)
                 .type(ProviderConfigProperty.LIST_TYPE)
                 .label("Auth mode")
                 .helpText("Select the authorization mode")
@@ -62,38 +113,38 @@ public class ScimStorageProviderFactory
                 .defaultValue("NONE")
                 .add()
                 .property()
-                .name("auth-user")
+                .name(ScrimProviderConfiguration.CONF_KEY_AUTH_USER)
                 .type(ProviderConfigProperty.STRING_TYPE)
                 .label("Auth username")
                 .helpText("Required for basic authentication.")
                 .add()
                 .property()
-                .name("auth-pass")
+                .name(ScrimProviderConfiguration.CONF_KEY_AUTH_PASSWORD)
                 .type(ProviderConfigProperty.PASSWORD)
                 .label("Auth password/token")
                 .helpText("Password or token required for basic or bearer authentication.")
                 .add()
                 .property()
-                .name("propagation-user")
+                .name(ScrimProviderConfiguration.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("propagation-group")
+                .name(ScrimProviderConfiguration.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("sync-import")
+                .name(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT)
                 .type(ProviderConfigProperty.BOOLEAN_TYPE)
                 .label("Enable import during sync")
                 .add()
                 .property()
-                .name("sync-import-action")
+                .name(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT_ACTION)
                 .type(ProviderConfigProperty.LIST_TYPE)
                 .label("Import action")
                 .helpText("What to do when the user doesn't exists in Keycloak.")
@@ -101,81 +152,26 @@ public class ScimStorageProviderFactory
                 .defaultValue("CREATE_LOCAL")
                 .add()
                 .property()
-                .name("sync-refresh")
+                .name(ScrimProviderConfiguration.CONF_KEY_SYNC_REFRESH)
                 .type(ProviderConfigProperty.BOOLEAN_TYPE)
                 .label("Enable refresh during sync")
                 .add()
                 .build();
     }
 
+
     @Override
     public ScimStorageProvider create(KeycloakSession session, ComponentModel model) {
-        LOGGER.info("create");
         return new ScimStorageProvider();
     }
 
-    @Override
-    public String getId() {
-        return ID;
-    }
-
-    @Override
-    public List<ProviderConfigProperty> getConfigProperties() {
-        return CONFIG_METADATA;
-    }
-
-    @Override
-    public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId,
-                                      UserStorageProviderModel model) {
-        LOGGER.info("sync");
-        SynchronizationResult result = new SynchronizationResult();
-        KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() {
-
-            @Override
-            public void run(KeycloakSession session) {
-                RealmModel realm = session.realms().getRealm(realmId);
-                session.getContext().setRealm(realm);
-                ScimDispatcher dispatcher = new ScimDispatcher(session);
-                if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) {
-                    dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result));
-                }
-                if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) {
-                    dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result));
-                }
-            }
-
-        });
-
-        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) {
-        TimerProvider timer = factory.create().getProvider(TimerProvider.class);
-        timer.scheduleTask(taskSession -> {
-            for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) {
-                KeycloakModelUtils.runJobInTransaction(factory, new KeycloakSessionTask() {
-                    @Override
-                    public void run(KeycloakSession session) {
-                        session.getContext().setRealm(realm);
-                        ScimDispatcher dispatcher = new ScimDispatcher(session);
-                        for (GroupModel group : session.groups().getGroupsStream(realm)
-                                .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) {
-                            LOGGER.debug(group.getName() + " is dirty");
-                            dispatcher.dispatchGroupModificationToAll(client -> client.replace(group));
-                            group.removeAttribute("scim-dirty");
-                        }
-                    }
-
-                });
-            }
-        }, Duration.ofSeconds(30).toMillis(), "scim-background");
+    /**
+     * Empty implementation : we used this {@link ScimStorageProviderFactory} to generate Admin Console page.
+     */
+    public static final class ScimStorageProvider implements UserStorageProvider {
+        @Override
+        public void close() {
+            // Nothing to close here
+        }
     }
 }
-- 
GitLab