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