From 182824a3b4504cf1d810aba7669949e0e4c476ab Mon Sep 17 00:00:00 2001
From: Hugo Renard <hugo.renard@protonmail.com>
Date: Tue, 6 Feb 2024 12:13:30 +0100
Subject: [PATCH] feat: update to keycloak 23 + varrious fixes

---
 build.gradle                                  | 28 +++-----
 docker-compose.yml                            |  3 +-
 legacy-build.gradle                           | 33 ---------
 src/main/java/sh/libre/scim/core/Adapter.java | 15 ++--
 .../java/sh/libre/scim/core/GroupAdapter.java | 48 +++++++------
 .../java/sh/libre/scim/core/ScimClient.java   | 46 +++++-------
 .../sh/libre/scim/core/ScimDispatcher.java    |  5 +-
 .../java/sh/libre/scim/core/UserAdapter.java  | 70 ++++++++++++-------
 .../scim/event/ScimEventListenerProvider.java |  9 +--
 .../java/sh/libre/scim/jpa/ScimResource.java  | 14 ++--
 .../sh/libre/scim/jpa/ScimResourceId.java     |  1 +
 .../storage/ScimStorageProviderFactory.java   | 34 ++++++++-
 12 files changed, 154 insertions(+), 152 deletions(-)
 delete mode 100644 legacy-build.gradle

diff --git a/build.gradle b/build.gradle
index 35ee773..ce8c838 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,13 +1,13 @@
 plugins {
     id 'java'
-    id 'com.github.johnrengelman.shadow' version '7.1.2'
+    id 'com.github.johnrengelman.shadow' version '8.1.1'
 }
 
 group = 'sh.libre.scim'
 version = '1.0-SNAPSHOT'
 description = 'keycloak-scim'
 
-java.sourceCompatibility = JavaVersion.VERSION_11
+java.sourceCompatibility = JavaVersion.VERSION_17
 
 repositories {
     mavenLocal()
@@ -15,20 +15,12 @@ repositories {
 }
 
 dependencies {
-    compileOnly 'org.keycloak:keycloak-core:18.0.0'
-    compileOnly 'org.keycloak:keycloak-server-spi:18.0.0'
-    compileOnly 'org.keycloak:keycloak-server-spi-private:18.0.0'
-    compileOnly 'org.keycloak:keycloak-services:18.0.0'
-    compileOnly 'org.keycloak:keycloak-model-jpa:18.0.0'
-    implementation 'io.github.resilience4j:resilience4j-retry:1.7.1'
-    implementation 'de.captaingoldfish:scim-sdk-common:1.15.3'
-
-    implementation files('/home/marcportabella/Documents/totmicro/Repos/keycloak-scim-aws/scim-sdk-client-1.15.4-SNAPSHOT.jar')
-    //implementation 'de.captaingoldfish:scim-sdk-client:1.15.3'
-    implementation('org.wildfly.client:wildfly-client-config:1.0.1.Final') {
-        transitive false
-    }
-    implementation('org.jboss.resteasy:resteasy-client:4.7.6.Final') {
-        transitive false
-    }
+    compileOnly 'org.keycloak:keycloak-core:23.0.4'
+    compileOnly 'org.keycloak:keycloak-server-spi:23.0.4'
+    compileOnly 'org.keycloak:keycloak-server-spi-private:23.0.4'
+    compileOnly 'org.keycloak:keycloak-services:23.0.4'
+    compileOnly 'org.keycloak:keycloak-model-jpa:23.0.4'
+    implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'
+    implementation 'de.captaingoldfish:scim-sdk-common:1.21.1'
+    implementation 'de.captaingoldfish:scim-sdk-client:1.21.1'
 }
diff --git a/docker-compose.yml b/docker-compose.yml
index d18aa1e..d119247 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,7 +11,7 @@ services:
     ports:
       - 5432:5432
   keycloak:
-    image: quay.io/keycloak/keycloak:18.0.0
+    image: quay.io/keycloak/keycloak:23.0.3
     build: .
     command: start-dev
     volumes:
@@ -23,6 +23,7 @@ services:
       KC_DB_PASSWORD: keycloak
       KEYCLOAK_ADMIN: admin
       KEYCLOAK_ADMIN_PASSWORD: admin
+      KC_LOG_LEVEL: INFO,sh.libre.scim:debug,de.captaingoldfish.scim:debug
     ports:
       - 127.0.0.1:8080:8080
     depends_on:
diff --git a/legacy-build.gradle b/legacy-build.gradle
deleted file mode 100644
index 2f57e15..0000000
--- a/legacy-build.gradle
+++ /dev/null
@@ -1,33 +0,0 @@
-plugins {
-    id 'java'
-    id 'com.github.johnrengelman.shadow' version '7.1.2'
-}
-
-group = 'sh.libre.scim'
-version = '1.0-SNAPSHOT'
-description = 'keycloak-scim'
-
-java.sourceCompatibility = JavaVersion.VERSION_11
-
-repositories {
-    mavenLocal()
-    mavenCentral()
-}
-
-dependencies {
-    compileOnly 'org.keycloak:keycloak-core:18.0.0'
-    compileOnly 'org.keycloak:keycloak-server-spi:18.0.0'
-    compileOnly 'org.keycloak:keycloak-server-spi-private:18.0.0'
-    compileOnly 'org.keycloak:keycloak-services:18.0.0'
-    compileOnly 'org.keycloak:keycloak-model-jpa:18.0.0'
-    implementation 'io.github.resilience4j:resilience4j-retry:1.7.1'
-
-    compileOnly 'org.wildfly.client:wildfly-client-config:1.0.1.Final'
-    compileOnly 'org.jboss.resteasy:resteasy-client:4.7.6.Final'
-    compileOnly 'org.jboss.resteasy:resteasy-client-api:4.7.6.Final'
-
-}
-
-shadowJar {
-   archiveClassifier.set('all-legacy')
-}
diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java
index 8faac5a..d5cbb0d 100644
--- a/src/main/java/sh/libre/scim/core/Adapter.java
+++ b/src/main/java/sh/libre/scim/core/Adapter.java
@@ -2,10 +2,9 @@ package sh.libre.scim.core;
 
 import java.util.stream.Stream;
 
-import javax.persistence.EntityManager;
-import javax.persistence.NoResultException;
-import javax.persistence.TypedQuery;
-import javax.ws.rs.NotFoundException;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.NoResultException;
+import jakarta.persistence.TypedQuery;
 
 import org.jboss.logging.Logger;
 import org.keycloak.connections.jpa.JpaConnectionProvider;
@@ -61,7 +60,7 @@ public abstract class Adapter<M extends RoleMapperModel, S extends ResourceNode>
     }
 
     public String getSCIMEndpoint() {
-        return type + "s";
+        return "/" + type + "s";
     }
 
     public ScimResource toMapping() {
@@ -95,12 +94,8 @@ public abstract class Adapter<M extends RoleMapperModel, S extends ResourceNode>
             if (this.externalId != null) {
                 return this.query("findByExternalId", externalId).getSingleResult();
             }
-        } catch (NotFoundException e) {
         } catch (NoResultException e) {
-        } catch (Exception e) {
-            LOGGER.error(e);
         }
-
         return null;
     }
 
@@ -124,7 +119,7 @@ public abstract class Adapter<M extends RoleMapperModel, S extends ResourceNode>
 
     public abstract Class<S> getResourceClass();
 
-    public abstract S toSCIM(Boolean addMeta);
+    public abstract S toSCIM();
 
     public abstract Boolean entityExists();
 
diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java
index 8680060..e7edf6d 100644
--- a/src/main/java/sh/libre/scim/core/GroupAdapter.java
+++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java
@@ -2,16 +2,15 @@ package sh.libre.scim.core;
 
 import java.net.URI;
 import java.net.URISyntaxException;
-import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-import javax.persistence.NoResultException;
+import jakarta.persistence.NoResultException;
 import de.captaingoldfish.scim.sdk.common.resources.Group;
 import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member;
 import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.jboss.logging.Logger;
 import org.keycloak.models.GroupModel;
 import org.keycloak.models.KeycloakSession;
@@ -59,44 +58,49 @@ public class GroupAdapter extends Adapter<GroupModel, Group> {
         if (groupMembers != null && groupMembers.size() > 0) {
             this.members = new HashSet<String>();
             for (var groupMember : groupMembers) {
-                var userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User")
-                        .getSingleResult();
-                this.members.add(userMapping.getId());
+                try {
+                    var userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User")
+                            .getSingleResult();
+                    this.members.add(userMapping.getId());
+                } catch (NoResultException e) {
+                    LOGGER.warnf("member %s not found for scim group %s", groupMember.getValue().get(), group.getId().get());
+                }
             }
         }
     }
 
     @Override
-    public Group toSCIM(Boolean addMeta) {
+    public Group toSCIM() {
         var group = new Group();
         group.setId(externalId);
         group.setExternalId(id);
         group.setDisplayName(displayName);
         if (members.size() > 0) {
-            var groupMembers = new ArrayList<Member>();
             for (var member : members) {
                 var groupMember = new Member();
                 try {
                     var userMapping = this.query("findById", member, "User").getSingleResult();
+                    LOGGER.debug(userMapping.getExternalId());
+                    LOGGER.debug(userMapping.getId());
                     groupMember.setValue(userMapping.getExternalId());
                     var ref = new URI(String.format("Users/%s", userMapping.getExternalId()));
                     groupMember.setRef(ref.toString());
-                    groupMembers.add(groupMember);
-                } catch (Exception e) {
-                    LOGGER.error(e);
+                    group.addMember(groupMember);
+                } catch (NoResultException e) {
+                    LOGGER.warnf("member %s not found for group %s", member, id);
+                } catch (URISyntaxException e) {
+                    LOGGER.warnf("bad ref uri");
                 }
             }
-            group.setMembers(groupMembers);
         }
-        if (addMeta) {
-            var meta = new Meta();
-            try {
-                var uri = new URI("Groups/" + externalId);
-                meta.setLocation(uri.toString());
-            } catch (URISyntaxException e) {
-            }
-            group.setMeta(meta);
+        var meta = new Meta();
+        try {
+            var uri = new URI("Groups/" + externalId);
+            meta.setLocation(uri.toString());
+        } catch (URISyntaxException e) {
+            LOGGER.warn(e);
         }
+        group.setMeta(meta);
         return group;
     }
 
@@ -114,7 +118,9 @@ public class GroupAdapter extends Adapter<GroupModel, Group> {
 
     @Override
     public Boolean tryToMap() {
-        var group = session.groups().getGroupsStream(realm).filter(x -> x.getName() == displayName).findFirst();
+        var group = session.groups().getGroupsStream(realm).filter(
+                x -> StringUtils.equals(x.getName(), externalId) || StringUtils.equals(x.getName(), displayName))
+                .findFirst();
         if (group.isPresent()) {
             setId(group.get().getId());
             return true;
diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java
index 29c5c17..e3b1b72 100644
--- a/src/main/java/sh/libre/scim/core/ScimClient.java
+++ b/src/main/java/sh/libre/scim/core/ScimClient.java
@@ -3,9 +3,9 @@ package sh.libre.scim.core;
 import java.util.HashMap;
 import java.util.Map;
 
-import javax.persistence.EntityManager;
-import javax.persistence.NoResultException;
-import javax.ws.rs.ProcessingException;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.NoResultException;
+import jakarta.ws.rs.ProcessingException;
 
 import de.captaingoldfish.scim.sdk.client.ScimClientConfig;
 import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder;
@@ -51,12 +51,11 @@ public class ScimClient {
         switch (model.get("auth-mode")) {
             case "BEARER":
                 defaultHeaders.put(HttpHeaders.AUTHORIZATION,
-                    BearerAuthentication(model.get("auth-pass")));
+                    BearerAuthentication());
                 break;
             case "BASIC_AUTH":
                 defaultHeaders.put(HttpHeaders.AUTHORIZATION,
-                    BasicAuthentication(model.get("auth-user"),
-                                        model.get("auth-pass")));
+                    BasicAuthentication());
                 break;
         }
 
@@ -73,10 +72,10 @@ public class ScimClient {
         registry = RetryRegistry.of(retryConfig);
     }
 
-    protected String BasicAuthentication(String username ,String password) {
+    protected String BasicAuthentication() {
         return  BasicAuth.builder()
-        .username(model.get(username))
-        .password(model.get(password))
+        .username(model.get("auth-user"))
+        .password(model.get("auth-pass"))
         .build()
         .getAuthorizationHeaderValue();
     }
@@ -92,17 +91,10 @@ public class ScimClient {
         .build();
     }
 
-    protected String BearerAuthentication(String token) {
-        return "Bearer " + token ;
+    protected String BearerAuthentication() {
+        return "Bearer " + model.get("auth-pass") ;
     }
 
-    protected String genScimUrl(String scimEndpoint,String resourcePath) {
-        return String.format("%s%s/%s", scimApplicationBaseUrl ,
-                            scimEndpoint,
-                            resourcePath);
-    }
-
-
     protected EntityManager getEM() {
         return session.getProvider(JpaConnectionProvider.class).getEntityManager();
     }
@@ -136,8 +128,8 @@ public class ScimClient {
         ServerResponse<S> response = retry.executeSupplier(() -> {
             try {
                 return scimRequestBuilder
-                .create(adapter.getResourceClass(), String.format("/" + adapter.getSCIMEndpoint()))
-                .setResource(adapter.toSCIM(false))
+                .create(adapter.getResourceClass(), adapter.getSCIMEndpoint())
+                .setResource(adapter.toSCIM())
                 .sendRequest();
             } catch ( ResponseException e) {
                 throw new RuntimeException(e);
@@ -166,9 +158,8 @@ public class ScimClient {
             ServerResponse<S> response = retry.executeSupplier(() -> {
                 try {
                     return scimRequestBuilder
-                    .update(genScimUrl(adapter.getSCIMEndpoint(), adapter.getExternalId()),
-                                       adapter.getResourceClass())
-                    .setResource(adapter.toSCIM(false))
+                    .update(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId())
+                    .setResource(adapter.toSCIM())
                     .sendRequest() ;
                 } catch ( ResponseException e) {
 
@@ -199,8 +190,7 @@ public class ScimClient {
 
             ServerResponse<S> response = retry.executeSupplier(() -> {
                 try {
-                    return scimRequestBuilder.delete(genScimUrl(adapter.getSCIMEndpoint(), adapter.getExternalId()),
-                                                                adapter.getResourceClass())
+                    return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId())
                                              .sendRequest();
                 } catch (ResponseException e) {
                     throw new RuntimeException(e);
@@ -247,7 +237,7 @@ public class ScimClient {
         LOGGER.info("Import");
         try {
             var adapter = getAdapter(aClass);
-            ServerResponse<ListResponse<S>> response  = scimRequestBuilder.list("url",adapter.getResourceClass()).get().sendRequest();
+            ServerResponse<ListResponse<S>> response  = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getSCIMEndpoint()).get().sendRequest();
             ListResponse<S> resourceTypeListResponse = response.getResource();
 
             for (var resource : resourceTypeListResponse.getListedResources()) {
@@ -287,9 +277,7 @@ public class ScimClient {
                             case "DELETE_REMOTE":
                                 LOGGER.info("Delete remote resource");
                                 scimRequestBuilder
-                                    .delete(genScimUrl(adapter.getSCIMEndpoint(),
-                                                       resource.getId().get()),
-                                                       adapter.getResourceClass())
+                                    .delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(),  resource.getId().get())
                                     .sendRequest();
                                 syncRes.increaseRemoved();
                                 break;
diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java
index f76102c..293106b 100644
--- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java
+++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java
@@ -1,6 +1,8 @@
 package sh.libre.scim.core;
 
 import java.util.function.Consumer;
+
+import org.apache.commons.lang3.StringUtils;
 import org.jboss.logging.Logger;
 import org.keycloak.component.ComponentModel;
 import org.keycloak.models.KeycloakSession;
@@ -21,7 +23,8 @@ public class ScimDispatcher {
     public void run(String scope, Consumer<ScimClient> f) {
         session.getContext().getRealm().getComponentsStream()
                 .filter((m) -> {
-                    return ScimStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true)
+                    return StringUtils.equals(ScimStorageProviderFactory.ID, m.getProviderId())
+                            && m.get("enabled", true)
                             && m.get("propagation-" + scope, false);
                 })
                 .forEach(m -> runOne(m, f));
diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java
index b2283e5..ff85170 100644
--- a/src/main/java/sh/libre/scim/core/UserAdapter.java
+++ b/src/main/java/sh/libre/scim/core/UserAdapter.java
@@ -3,8 +3,10 @@ package sh.libre.scim.core;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Stream;
 
 import de.captaingoldfish.scim.sdk.common.resources.User;
@@ -13,8 +15,7 @@ import de.captaingoldfish.scim.sdk.common.resources.complex.Name;
 import de.captaingoldfish.scim.sdk.common.resources.multicomplex.PersonRole;
 import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
 
-
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.jboss.logging.Logger;
 import org.keycloak.models.KeycloakSession;
 import org.keycloak.models.UserModel;
@@ -26,6 +27,8 @@ public class UserAdapter extends Adapter<UserModel, User> {
     private String email;
     private Boolean active;
     private String[] roles;
+    private String firstName;
+    private String lastName;
 
     public UserAdapter(KeycloakSession session, String componentId) {
         super(session, componentId, "User", Logger.getLogger(UserAdapter.class));
@@ -79,6 +82,22 @@ public class UserAdapter extends Adapter<UserModel, User> {
         this.roles = roles;
     }
 
+    public String getFirstName() {
+        return firstName;
+    }
+
+    public void setFirstName(String firstName) {
+        this.firstName = firstName;
+    }
+
+    public String getLastName() {
+        return lastName;
+    }
+
+    public void setLastName(String lastName) {
+        this.lastName = lastName;
+    }
+
     @Override
     public Class<User> getResourceClass() {
         return User.class;
@@ -96,19 +115,17 @@ public class UserAdapter extends Adapter<UserModel, User> {
         setDisplayName(displayName);
         setEmail(user.getEmail());
         setActive(user.isEnabled());
+        setFirstName(user.getFirstName());
+        setLastName(user.getLastName());
         var rolesSet = new HashSet<String>();
         user.getGroupsStream().flatMap(g -> g.getRoleMappingsStream())
-                .filter((r) -> r.getFirstAttribute("scim").equals("true")).map((r) -> r.getName())
+                .filter((r) -> StringUtils.equals(r.getFirstAttribute("scim"), "true"))
+                .map((r) -> r.getName())
+                .forEach(r -> rolesSet.add(r));
+        user.getRoleMappingsStream()
+                .filter((r) -> StringUtils.equals(r.getFirstAttribute("scim"), "true"))
+                .map((r) -> r.getName())
                 .forEach(r -> rolesSet.add(r));
-
-        user.getRoleMappingsStream().filter((r) -> {
-            var attr = r.getFirstAttribute("scim");
-            if (attr == null) {
-                return false;
-            }
-            return attr.equals("true");
-        }).map((r) -> r.getName()).forEach(r -> rolesSet.add(r));
-
         var roles = new String[rolesSet.size()];
         rolesSet.toArray(roles);
         setRoles(roles);
@@ -127,30 +144,31 @@ public class UserAdapter extends Adapter<UserModel, User> {
     }
 
     @Override
-    public User toSCIM(Boolean addMeta) {
+    public User toSCIM() {
         var user = new User();
         user.setExternalId(id);
         user.setUserName(username);
         user.setId(externalId);
         user.setDisplayName(displayName);
         Name name = new Name();
+        name.setFamilyName(lastName);
+        name.setGivenName(firstName);
         user.setName(name);
         var emails = new ArrayList<Email>();
         if (email != null) {
             emails.add(
-                Email.builder().value(getEmail()).build());
+                    Email.builder().value(getEmail()).build());
         }
         user.setEmails(emails);
         user.setActive(active);
-        if (addMeta) {
-            var meta = new Meta();
-            try {
-                var uri = new URI("Users/" + externalId);
-              meta.setLocation(uri.toString());
-            } catch (URISyntaxException e) {
-            }
-            user.setMeta(meta);
-        }
+        var meta = new Meta();
+        try {
+            var uri = new URI("Users/" + externalId);
+            meta.setLocation(uri.toString());
+        } catch (URISyntaxException e) {
+            LOGGER.warn(e);
+        }
+        user.setMeta(meta);
         List<PersonRole> roles = new ArrayList<PersonRole>();
         for (var r : this.roles) {
             var role = new PersonRole();
@@ -212,11 +230,13 @@ public class UserAdapter extends Adapter<UserModel, User> {
 
     @Override
     public Stream<UserModel> getResourceStream() {
-        return this.session.users().getUsersStream(this.session.getContext().getRealm());
+        Map<String, String> params = new HashMap<String, String>() {
+        };
+        return this.session.users().searchForUserStream(realm, params);
     }
 
     @Override
     public Boolean skipRefresh() {
-        return getUsername().equals("admin");
+        return StringUtils.equals(getUsername(), "admin");
     }
 }
diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java
index 3fbad12..24dab78 100644
--- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java
+++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java
@@ -1,8 +1,9 @@
 package sh.libre.scim.event;
 
 import java.util.HashMap;
-import java.util.regex.*;
+import java.util.regex.Pattern;
 
+import org.apache.commons.lang3.StringUtils;
 import org.jboss.logging.Logger;
 import org.keycloak.events.Event;
 import org.keycloak.events.EventListenerProvider;
@@ -99,7 +100,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
             var groupId = matcher.group(2);
             LOGGER.infof("%s %s from %s", event.getOperationType(), userId, groupId);
             var group = getGroup(groupId);
-            dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group));
+            group.setSingleAttribute("scim-dirty", "true");
             var user = getUser(userId);
             dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user));
         }
@@ -107,10 +108,10 @@ public class ScimEventListenerProvider implements EventListenerProvider {
             var type = matcher.group(1);
             var id = matcher.group(2);
             LOGGER.infof("%s %s %s roles", event.getOperationType(), type, id);
-            if (type.equals("users")) {
+            if (StringUtils.equals(type, "users")) {
                 var user = getUser(id);
                 dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user));
-            } else if (type.equals("groups")) {
+            } else if (StringUtils.equals(type, "groups")) {
                 var group = getGroup(id);
                 session.users().getGroupMembersStream(session.getContext().getRealm(), group).forEach(user -> {
                     dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user));
diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java
index a9f6958..62dbdf0 100644
--- a/src/main/java/sh/libre/scim/jpa/ScimResource.java
+++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java
@@ -1,12 +1,12 @@
 package sh.libre.scim.jpa;
 
-import javax.persistence.Column;
-import javax.persistence.Entity;
-import javax.persistence.Id;
-import javax.persistence.IdClass;
-import javax.persistence.NamedQuery;
-import javax.persistence.NamedQueries;
-import javax.persistence.Table;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.NamedQuery;
+import jakarta.persistence.NamedQueries;
+import jakarta.persistence.Table;
 
 @Entity
 @IdClass(ScimResourceId.class)
diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java
index 7775543..1fa0d21 100644
--- a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java
+++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java
@@ -68,6 +68,7 @@ public class ScimResourceId implements Serializable {
         if (!(other instanceof ScimResourceId))
             return false;
         var o = (ScimResourceId) other;
+        // TODO
         return (o.id == id &&
                 o.realmId == realmId &&
                 o.componentId == componentId &&
diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java
index 34ea984..265c6c6 100644
--- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java
+++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java
@@ -3,8 +3,9 @@ package sh.libre.scim.storage;
 import java.util.Date;
 import java.util.List;
 
-import javax.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MediaType;
 
+import org.apache.commons.lang3.StringUtils;
 import org.jboss.logging.Logger;
 import org.keycloak.component.ComponentModel;
 import org.keycloak.models.KeycloakSession;
@@ -17,6 +18,7 @@ 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;
@@ -34,6 +36,7 @@ public class ScimStorageProviderFactory
                 .property()
                 .name("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)")
@@ -70,12 +73,14 @@ public class ScimStorageProviderFactory
                 .name("propagation-user")
                 .type(ProviderConfigProperty.BOOLEAN_TYPE)
                 .label("Enable user propagation")
+                .helpText("Should operation on users be propagated to this provider ?")
                 .defaultValue("true")
                 .add()
                 .property()
                 .name("propagation-group")
                 .type(ProviderConfigProperty.BOOLEAN_TYPE)
                 .label("Enable group propagation")
+                .helpText("Should operation on groups be propagated to this provider ?")
                 .defaultValue("true")
                 .add()
                 .property()
@@ -127,10 +132,10 @@ public class ScimStorageProviderFactory
                 var realm = session.realms().getRealm(realmId);
                 session.getContext().setRealm(realm);
                 var dispatcher = new ScimDispatcher(session);
-                if (model.get("propagation-user").equals("true")) {
+                if (StringUtils.equals(model.get("propagation-user"), "true")) {
                     dispatcher.runOne(model, (client) -> client.sync(UserAdapter.class, result));
                 }
-                if (model.get("propagation-group").equals("true")) {
+                if (StringUtils.equals(model.get("propagation-group"), "true")) {
                     dispatcher.runOne(model, (client) -> client.sync(GroupAdapter.class, result));
                 }
             }
@@ -147,4 +152,27 @@ public class ScimStorageProviderFactory
         return this.sync(sessionFactory, realmId, model);
     }
 
+    @Override
+    public void postInit(KeycloakSessionFactory factory) {
+        var timer = factory.create().getProvider(TimerProvider.class);
+        timer.scheduleTask(taskSession -> {
+            for (var realm : taskSession.realms().getRealmsStream().toList()) {
+                KeycloakModelUtils.runJobInTransaction(factory, new KeycloakSessionTask() {
+                    @Override
+                    public void run(KeycloakSession session) {
+                        session.getContext().setRealm(realm);
+                        var dispatcher = new ScimDispatcher(session);
+                        for (var group : session.groups().getGroupsStream(realm)
+                                .filter(x -> StringUtils.equals(x.getFirstAttribute("scim-dirty"), "true")).toList()) {
+                            LOGGER.debug(group.getName() + " is dirty");
+                            dispatcher.run(ScimDispatcher.SCOPE_GROUP,
+                                    (client) -> client.replace(GroupAdapter.class, group));
+                            group.removeAttribute("scim-dirty");
+                        }
+                    }
+
+                });
+            }
+        }, 30000, "scim-background");
+    }
 }
-- 
GitLab