diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b7932a6331..99347f14d3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,13 +1,10 @@ package: image: - name: gradle:jdk11 + name: gradle:jdk17 script: - gradle jar shadowjar - - gradle -b legacy-build.gradle shadowjar artifacts: paths: - build/libs/keycloak-scim-1.0-SNAPSHOT.jar - build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar - build/libs/keycloak-scim-1.0-SNAPSHOT-all-legacy.jar - only: - - main diff --git a/README.md b/README.md index 4e6889b03d..785758b007 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,59 @@ # keycloak-scim-client -This extension add [SCIM2](http://www.simplecloud.info) client capabilities to Keycloak. (See [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644)). +This extension add [SCIM2](http://www.simplecloud.info) client capabilities to Keycloak. + +It allows to : + +* Declare SCIM endpoints (through the identity federation UI). Any tool implementing SCIM protocol can be wired to the + Keycloack instance through this declaration. +* Propagate users and groups from Keycloack to SCIM endpoints : when a user/group gets created or modified in Keycloack, + the modification is fowarded 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 Keycloack synchronization mechanism). + +See [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643) +and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644)) for further details ## Overview ### 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. +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. +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. -### Technical choices - -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. +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 KC : -- Event Listener : it's used to listens for changes and transform them in SCIM calls. -- Federation Provider : it's used to set up all the SCIM service providers without creating our own UI. -- JPA Entity Provider : it's used to save the mapping between the local IDs and the service providers IDs. +This extension uses 3 concepts in KeyCloack : -Because the event listener is the source of the SCIM flow, and it is not cancelable, we can't have strictly consistent behavior in case of SCIM service provider failure. +- Event Listener : used to listen for changes within Keycloack (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. ## Usage -### Installation (quick) +### Development mode -1. Download the [latest version](https://lab.libreho.st/libre.sh/scim/keycloak-scim/-/jobs/artifacts/main/raw/build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar?job=package) +From the repository root : + +* Launch the docker-compose image (composed of a postgre and keycloack instance runing on localhost:8080) : + ``docker compose up -d`` +* Execute ``gradle jar shadowJar && docker compose restart keycloak`` to build extension and update the Keycloack + instance +* You can access extension logs through ``docker compose logs -f`` + +### Installation + +1. Download + the [latest version](https://lab.libreho.st/libre.sh/scim/keycloak-scim/-/jobs/artifacts/main/raw/build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar?job=package) 2. Put it in `/opt/keycloak/providers/`. It's also possible to build your own custom image if you run Keycloak in a [container](/docs/container.md). @@ -38,7 +62,7 @@ Other [installation options](/docs/installation.md) are available. ### Setup -#### Add the event listerner +#### Enable SCIM Event listeners 1. Go to `Admin Console > Events > Config`. 2. Add `scim` in `Event Listeners`. @@ -46,37 +70,35 @@ Other [installation options](/docs/installation.md) are available. ![Event listener page](/docs/img/event-listener-page.png) -#### Create a federation provider +#### Register SCIM Service Providers -1. Go to `Admin Console > User Federation`. -2. Click on `Add provider`. -3. Select `scim`. -4. Configure the provider ([see](#configuration)). -5. Save. +1. Go to `Admin Console > Realm Settings > Events`. +2. Add `scim` to the list of event listers +3. Save ![Federation provider page](/docs/img/federation-provider-page.png) ### Configuration -Add the endpoint - for a local set up you have to add the two containers in a docker network and use the container ip see [here](https://docs.docker.com/engine/reference/commandline/network/) -If you use the [rocketchat app](https://lab.libreho.st/libre.sh/scim/rocketchat-scim) you get the endpoint from your rocket Chat Scim Adapter App Details. +Add the endpoint - for a local set up you have to add the two containers in a docker network and use the container ip +see [here](https://docs.docker.com/engine/reference/commandline/network/) +If you use the [rocketchat app](https://lab.libreho.st/libre.sh/scim/rocketchat-scim) you get the endpoint from your +rocket Chat Scim Adapter App Details. Endpoint content type is application/json. Auth mode Bearer or None for local test setup. Copy the bearer token from your app details in rocketchat. 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 necesarry. You can either do: +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 - **[License AGPL](/LICENSE)** diff --git a/auto.sh b/auto.sh new file mode 100755 index 0000000000..884ba67840 --- /dev/null +++ b/auto.sh @@ -0,0 +1,4 @@ +gradle jar shadowjar +scp build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar root@192.168.130.252:/var/www/html/keycloak-scim-1.0-SNAPSHOT-all.jar +scp build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar root@192.168.130.252:/var/www/html/keycloak-scim-aws-1.0-SNAPSHOT-all.jar +k delete pod keycloak-keycloakx-0 -n keycloak diff --git a/build.gradle b/build.gradle index def33a3531..7897ff6a60 100644 --- a/build.gradle +++ b/build.gradle @@ -1,13 +1,15 @@ plugins { id 'java' - id 'com.github.johnrengelman.shadow' version '7.1.2' + id 'com.github.johnrengelman.shadow' version '8.1.1' + id "org.sonarqube" version "5.1.0.4882" + id "com.github.ben-manes.versions" version "0.51.0" } group = 'sh.libre.scim' version = '1.0-SNAPSHOT' description = 'keycloak-scim' -java.sourceCompatibility = JavaVersion.VERSION_11 +java.sourceCompatibility = JavaVersion.VERSION_17 repositories { mavenLocal() @@ -15,26 +17,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('com.unboundid.product.scim2:scim2-sdk-client:2.3.7') { - transitive false - } - implementation('com.unboundid.product.scim2:scim2-sdk-common:2.3.7') { - transitive false - } - 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 - } - implementation('org.jboss.resteasy:resteasy-client-api:4.7.6.Final') { - transitive false - } - + compileOnly 'org.keycloak:keycloak-core:26.0.1' + compileOnly 'org.keycloak:keycloak-server-spi:26.0.1' + compileOnly 'org.keycloak:keycloak-server-spi-private:26.0.1' + compileOnly 'org.keycloak:keycloak-services:26.0.1' + compileOnly 'org.keycloak:keycloak-model-jpa:26.0.1' + implementation 'io.github.resilience4j:resilience4j-retry:2.2.0' + implementation 'de.captaingoldfish:scim-sdk-common:1.26.0' + implementation 'de.captaingoldfish:scim-sdk-client:1.26.0' } diff --git a/docker-compose.yml b/docker-compose.yml index d18aa1ef3e..43e4f3a770 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:26.0.1 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/docs/img/event-listener-page.png b/docs/img/event-listener-page.png index 066b25d774..5c45734c68 100644 Binary files a/docs/img/event-listener-page.png and b/docs/img/event-listener-page.png differ diff --git a/docs/img/federation-provider-page.png b/docs/img/federation-provider-page.png index e89858d432..390128f581 100644 Binary files a/docs/img/federation-provider-page.png and b/docs/img/federation-provider-page.png differ diff --git a/legacy-build.gradle b/legacy-build.gradle deleted file mode 100644 index c9318f4a09..0000000000 --- a/legacy-build.gradle +++ /dev/null @@ -1,38 +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' - implementation('com.unboundid.product.scim2:scim2-sdk-client:2.3.7') { - transitive false - } - implementation('com.unboundid.product.scim2:scim2-sdk-common:2.3.7') { - transitive false - } - 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 deleted file mode 100644 index f7581a1bec..0000000000 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ /dev/null @@ -1,136 +0,0 @@ -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 org.jboss.logging.Logger; -import org.keycloak.connections.jpa.JpaConnectionProvider; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.RoleMapperModel; -import sh.libre.scim.jpa.ScimResource; - -public abstract class Adapter { - - protected final Logger LOGGER; - protected final String realmId; - protected final RealmModel realm; - protected final String type; - protected final String componentId; - protected final EntityManager em; - protected final KeycloakSession session; - - protected String id; - protected String externalId; - protected Boolean skip = false; - - public Adapter(KeycloakSession session, String componentId, String type, Logger logger) { - this.session = session; - this.realm = session.getContext().getRealm(); - this.realmId = session.getContext().getRealm().getId(); - this.componentId = componentId; - this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); - this.type = type; - this.LOGGER = logger; - } - - public String getId() { - return id; - } - - public void setId(String id) { - if (this.id == null) { - this.id = id; - } - } - - public String getExternalId() { - return externalId; - } - - public void setExternalId(String externalId) { - if (this.externalId == null) { - this.externalId = externalId; - } - } - - public String getSCIMEndpoint() { - return type + "s"; - } - - public ScimResource toMapping() { - var entity = new ScimResource(); - entity.setType(type); - entity.setId(id); - entity.setExternalId(externalId); - entity.setComponentId(componentId); - entity.setRealmId(realmId); - return entity; - } - - public TypedQuery query(String query, String id) { - return query(query, id, type); - } - - public TypedQuery query(String query, String id, String type) { - return this.em - .createNamedQuery(query, ScimResource.class) - .setParameter("type", type) - .setParameter("realmId", realmId) - .setParameter("componentId", componentId) - .setParameter("id", id); - } - - public ScimResource getMapping() { - try { - if (this.id != null) { - return this.query("findById", id).getSingleResult(); - } - if (this.externalId != null) { - return this.query("findByExternalId", externalId).getSingleResult(); - } - } catch (NotFoundException e) { - } catch (NoResultException e) { - } catch (Exception e) { - LOGGER.error(e); - } - - return null; - } - - public void saveMapping() { - this.em.persist(toMapping()); - } - - public void deleteMapping() { - var mapping = this.em.merge(toMapping()); - this.em.remove(mapping); - } - - public void apply(ScimResource mapping) { - setId(mapping.getId()); - setExternalId(mapping.getExternalId()); - } - - public abstract void apply(M model); - - public abstract void apply(S resource); - - public abstract Class getResourceClass(); - - public abstract S toSCIM(Boolean addMeta); - - public abstract Boolean entityExists(); - - public abstract Boolean tryToMap(); - - public abstract void createEntity() throws Exception; - - public abstract Stream getResourceStream(); - - public abstract Boolean skipRefresh(); -} diff --git a/src/main/java/sh/libre/scim/core/BasicAuthentication.java b/src/main/java/sh/libre/scim/core/BasicAuthentication.java deleted file mode 100644 index 227681d2d2..0000000000 --- a/src/main/java/sh/libre/scim/core/BasicAuthentication.java +++ /dev/null @@ -1,23 +0,0 @@ -package sh.libre.scim.core; - -import java.io.IOException; -import java.util.Base64; - -import javax.ws.rs.client.ClientRequestContext; -import javax.ws.rs.client.ClientRequestFilter; - -public class BasicAuthentication implements ClientRequestFilter { - private final String user; - private final String password; - - BasicAuthentication(String user, String password) { - this.user = user; - this.password = password; - } - - @Override - public void filter(ClientRequestContext requestContext) throws IOException { - var token = Base64.getEncoder().encodeToString((user + ":" + password).getBytes()); - requestContext.getHeaders().add("Authorization", "Basic " + token); - } -} diff --git a/src/main/java/sh/libre/scim/core/BearerAuthentication.java b/src/main/java/sh/libre/scim/core/BearerAuthentication.java deleted file mode 100644 index 2b4133d885..0000000000 --- a/src/main/java/sh/libre/scim/core/BearerAuthentication.java +++ /dev/null @@ -1,20 +0,0 @@ -package sh.libre.scim.core; - -import java.io.IOException; - -import javax.ws.rs.client.ClientRequestContext; -import javax.ws.rs.client.ClientRequestFilter; - -public class BearerAuthentication implements ClientRequestFilter { - private final String token; - - BearerAuthentication(String token) { - this.token = token; - } - - @Override - public void filter(ClientRequestContext requestContext) throws IOException { - requestContext.getHeaders().add("Authorization", "Bearer " + this.token); - - } -} diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java deleted file mode 100644 index 33039c5904..0000000000 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ /dev/null @@ -1,155 +0,0 @@ -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 com.unboundid.scim2.common.types.GroupResource; -import com.unboundid.scim2.common.types.Member; -import com.unboundid.scim2.common.types.Meta; - -import org.apache.commons.lang.StringUtils; -import org.jboss.logging.Logger; -import org.keycloak.models.GroupModel; -import org.keycloak.models.KeycloakSession; - -public class GroupAdapter extends Adapter { - - private String displayName; - private Set members = new HashSet(); - - public GroupAdapter(KeycloakSession session, String componentId) { - super(session, componentId, "Group", Logger.getLogger(GroupAdapter.class)); - } - - public String getDisplayName() { - return displayName; - } - - public void setDisplayName(String displayName) { - if (this.displayName == null) { - this.displayName = displayName; - } - } - - @Override - public Class getResourceClass() { - return GroupResource.class; - } - - @Override - public void apply(GroupModel group) { - setId(group.getId()); - setDisplayName(group.getName()); - this.members = session.users() - .getGroupMembersStream(session.getContext().getRealm(), group) - .map(x -> x.getId()) - .collect(Collectors.toSet()); - this.skip = StringUtils.equals(group.getFirstAttribute("scim-skip"), "true"); - } - - @Override - public void apply(GroupResource group) { - setExternalId(group.getId()); - setDisplayName(group.getDisplayName()); - var groupMembers = group.getMembers(); - if (groupMembers != null && groupMembers.size() > 0) { - this.members = new HashSet(); - for (var groupMember : groupMembers) { - var userMapping = this.query("findByExternalId", groupMember.getValue(), "User") - .getSingleResult(); - this.members.add(userMapping.getId()); - } - } - } - - @Override - public GroupResource toSCIM(Boolean addMeta) { - var group = new GroupResource(); - group.setId(externalId); - group.setExternalId(id); - group.setDisplayName(displayName); - if (members.size() > 0) { - var groupMembers = new ArrayList(); - for (var member : members) { - var groupMember = new Member(); - try { - var userMapping = this.query("findById", member, "User").getSingleResult(); - groupMember.setValue(userMapping.getExternalId()); - var ref = new URI(String.format("Users/%s", userMapping.getExternalId())); - groupMember.setRef(ref); - groupMembers.add(groupMember); - } catch (Exception e) { - LOGGER.error(e); - } - } - group.setMembers(groupMembers); - } - if (addMeta) { - var meta = new Meta(); - try { - var uri = new URI("Groups/" + externalId); - meta.setLocation(uri); - } catch (URISyntaxException e) { - } - group.setMeta(meta); - } - return group; - } - - @Override - public Boolean entityExists() { - if (this.id == null) { - return false; - } - var group = session.groups().getGroupById(realm, id); - if (group != null) { - return true; - } - return false; - } - - @Override - public Boolean tryToMap() { - var group = session.groups().getGroupsStream(realm).filter(x -> x.getName() == displayName).findFirst(); - if (group.isPresent()) { - setId(group.get().getId()); - return true; - } - return false; - } - - @Override - public void createEntity() { - var group = session.groups().createGroup(realm, displayName); - this.id = group.getId(); - for (String mId : members) { - try { - var user = session.users().getUserById(realm, mId); - if (user == null) { - throw new NoResultException(); - } - user.joinGroup(group); - } catch (Exception e) { - LOGGER.warn(e); - } - } - } - - @Override - public Stream getResourceStream() { - return this.session.groups().getGroupsStream(this.session.getContext().getRealm()); - } - - @Override - public Boolean skipRefresh() { - return false; - } - -} diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java deleted file mode 100644 index 67e15acfa6..0000000000 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ /dev/null @@ -1,246 +0,0 @@ -package sh.libre.scim.core; - -import javax.persistence.EntityManager; -import javax.persistence.NoResultException; -import javax.ws.rs.ProcessingException; -import javax.ws.rs.client.Client; - -import com.unboundid.scim2.client.ScimService; -import com.unboundid.scim2.common.ScimResource; -import com.unboundid.scim2.common.exceptions.ScimException; - -import org.jboss.logging.Logger; -import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; -import org.keycloak.component.ComponentModel; -import org.keycloak.connections.jpa.JpaConnectionProvider; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RoleMapperModel; -import org.keycloak.storage.user.SynchronizationResult; - -import io.github.resilience4j.core.IntervalFunction; -import io.github.resilience4j.retry.RetryConfig; -import io.github.resilience4j.retry.RetryRegistry; - -public class ScimClient { - final protected Logger LOGGER = Logger.getLogger(ScimClient.class); - final protected Client client = ResteasyClientBuilder.newClient(); - final protected ScimService scimService; - final protected RetryRegistry registry; - final protected KeycloakSession session; - final protected String contentType; - final protected ComponentModel model; - - public ScimClient(ComponentModel model, KeycloakSession session) { - this.model = model; - this.contentType = model.get("content-type"); - - this.session = session; - var target = client.target(model.get("endpoint")); - switch (model.get("auth-mode")) { - case "BEARER": - target = target.register(new BearerAuthentication(model.get("auth-pass"))); - break; - case "BASIC_AUTH": - target = target.register(new BasicAuthentication( - model.get("auth-user"), - model.get("auth-pass"))); - } - - scimService = new ScimService(target); - - RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(10) - .intervalFunction(IntervalFunction.ofExponentialBackoff()) - .retryExceptions(ProcessingException.class) - .build(); - registry = RetryRegistry.of(retryConfig); - } - - protected EntityManager getEM() { - return session.getProvider(JpaConnectionProvider.class).getEntityManager(); - } - - protected String getRealmId() { - return session.getContext().getRealm().getId(); - } - - protected > A getAdapter( - Class aClass) { - try { - return aClass.getDeclaredConstructor(KeycloakSession.class, String.class) - .newInstance(session, this.model.getId()); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - public > void create(Class aClass, - M kcModel) { - var adapter = getAdapter(aClass); - adapter.apply(kcModel); - if (adapter.skip) - return; - // If mapping exist then it was created by import so skip. - if (adapter.query("findById", adapter.getId()).getResultList().size() != 0) { - return; - } - var retry = registry.retry("create-" + adapter.getId()); - var resource = retry.executeSupplier(() -> { - try { - return scimService.createRequest(adapter.getSCIMEndpoint(), - adapter.toSCIM(false)) - .contentType(contentType).invoke(); - } catch (ScimException e) { - throw new RuntimeException(e); - } - }); - adapter.apply(resource); - adapter.saveMapping(); - - }; - - public > void replace(Class aClass, - M kcModel) { - var adapter = getAdapter(aClass); - try { - adapter.apply(kcModel); - if (adapter.skip) - return; - var resource = adapter.query("findById", adapter.getId()).getSingleResult(); - adapter.apply(resource); - var retry = registry.retry("replace-" + adapter.getId()); - retry.executeSupplier(() -> { - try { - return scimService.replaceRequest(adapter.toSCIM(true)).contentType(contentType).invoke(); - } catch (ScimException e) { - throw new RuntimeException(e); - } - }); - } catch (NoResultException e) { - LOGGER.warnf("failed to replace resource %s, scim mapping not found", adapter.getId()); - } catch (Exception e) { - LOGGER.error(e); - } - } - - public > void delete(Class aClass, - String id) { - var adapter = getAdapter(aClass); - adapter.setId(id); - try { - var resource = adapter.query("findById", adapter.getId()).getSingleResult(); - adapter.apply(resource); - var retry = registry.retry("delete-" + id); - retry.executeSupplier(() -> { - try { - scimService.deleteRequest(adapter.getSCIMEndpoint(), resource.getExternalId()) - .contentType(contentType).invoke(); - } catch (ScimException e) { - throw new RuntimeException(e); - } - return ""; - }); - getEM().remove(resource); - } catch (NoResultException e) { - LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id); - } - } - - public > void refreshResources( - Class aClass, - SynchronizationResult syncRes) { - LOGGER.info("Refresh resources"); - getAdapter(aClass).getResourceStream().forEach(resource -> { - var adapter = getAdapter(aClass); - adapter.apply(resource); - LOGGER.infof("Reconciling local resource %s", adapter.getId()); - if (!adapter.skipRefresh()) { - var mapping = adapter.getMapping(); - if (mapping == null) { - LOGGER.info("Creating it"); - this.create(aClass, resource); - } else { - LOGGER.info("Replacing it"); - this.replace(aClass, resource); - } - syncRes.increaseUpdated(); - } - }); - - } - - public > void importResources( - Class aClass, SynchronizationResult syncRes) { - LOGGER.info("Import"); - try { - var adapter = getAdapter(aClass); - var resources = scimService.searchRequest(adapter.getSCIMEndpoint()).contentType(contentType) - .invoke(adapter.getResourceClass()); - for (var resource : resources) { - try { - LOGGER.infof("Reconciling remote resource %s", resource.getId()); - adapter = getAdapter(aClass); - adapter.apply(resource); - - var mapping = adapter.getMapping(); - if (mapping != null) { - adapter.apply(mapping); - if (adapter.entityExists()) { - LOGGER.info("Valid mapping found, skipping"); - continue; - } else { - LOGGER.info("Delete a dangling mapping"); - adapter.deleteMapping(); - } - } - - var mapped = adapter.tryToMap(); - if (mapped) { - LOGGER.info("Matched"); - adapter.saveMapping(); - } else { - switch (this.model.get("sync-import-action")) { - case "CREATE_LOCAL": - LOGGER.info("Create local resource"); - try { - adapter.createEntity(); - adapter.saveMapping(); - syncRes.increaseAdded(); - } catch (Exception e) { - LOGGER.error(e); - } - break; - case "DELETE_REMOTE": - LOGGER.info("Delete remote resource"); - scimService.deleteRequest(adapter.getSCIMEndpoint(), resource.getId()) - .contentType(contentType) - .invoke(); - syncRes.increaseRemoved(); - break; - } - } - } catch (Exception e) { - LOGGER.error(e); - e.printStackTrace(); - syncRes.increaseFailed(); - } - } - } catch (ScimException e) { - throw new RuntimeException(e); - } - } - - public > void sync(Class aClass, - SynchronizationResult syncRes) { - if (this.model.get("sync-import", false)) { - this.importResources(aClass, syncRes); - } - if (this.model.get("sync-refresh", false)) { - this.refreshResources(aClass, syncRes); - } - } - - public void close() { - client.close(); - } -} diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index f76102cafa..d3d675108b 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -1,41 +1,171 @@ package sh.libre.scim.core; -import java.util.function.Consumer; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; +import sh.libre.scim.core.exceptions.ScimExceptionHandler; +import sh.libre.scim.core.exceptions.ScimPropagationException; +import sh.libre.scim.core.exceptions.SkipOrStopApproach; +import sh.libre.scim.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.service.AbstractScimService; +import sh.libre.scim.core.service.GroupScimService; +import sh.libre.scim.core.service.UserScimService; -import sh.libre.scim.storage.ScimStorageProviderFactory; +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 { - public static final String SCOPE_USER = "user"; - public static final String SCOPE_GROUP = "group"; - final private KeycloakSession session; - final private Logger LOGGER = Logger.getLogger(ScimDispatcher.class); + private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); + + private final KeycloakSession session; + private final ScimExceptionHandler exceptionHandler; + private final SkipOrStopStrategy skipOrStopStrategy; + private boolean clientsInitialized = false; + private final List userScimServices = new ArrayList<>(); + private final List groupScimServices = new ArrayList<>(); + 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; } - public void run(String scope, Consumer f) { + /** + * 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) -> { - return ScimStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true) - && m.get("propagation-" + scope, false); - }) - .forEach(m -> runOne(m, f)); + .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 runOne(ComponentModel m, Consumer f) { - LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType()); - var client = new ScimClient(m, session); - try { - f.accept(client); - } catch (Exception e) { - LOGGER.error(e); - } finally { - client.close(); + public void dispatchUserModificationToAll(SCIMPropagationConsumer operationToDispatch) { + initializeClientsIfNeeded(); + Set servicesCorrectlyPropagated = new LinkedHashSet<>(); + userScimServices.forEach(userScimService -> { + try { + operationToDispatch.acceptThrows(userScimService); + servicesCorrectlyPropagated.add(userScimService); + } catch (ScimPropagationException e) { + exceptionHandler.handleException(userScimService.getConfiguration(), e); + } + }); + // TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints + LOGGER.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); + } + + public void dispatchGroupModificationToAll(SCIMPropagationConsumer operationToDispatch) { + initializeClientsIfNeeded(); + Set servicesCorrectlyPropagated = new LinkedHashSet<>(); + groupScimServices.forEach(groupScimService -> { + try { + operationToDispatch.acceptThrows(groupScimService); + servicesCorrectlyPropagated.add(groupScimService); + } catch (ScimPropagationException e) { + exceptionHandler.handleException(groupScimService.getConfiguration(), e); + } + }); + // TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints + LOGGER.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); + } + + public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer operationToDispatch) { + initializeClientsIfNeeded(); + // Scim client should already have been created + Optional matchingClient = userScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + if (matchingClient.isPresent()) { + try { + operationToDispatch.acceptThrows(matchingClient.get()); + LOGGER.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName()); + } catch (ScimPropagationException e) { + exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); + } + } else { + LOGGER.error("[SCIM] Could not find a Scim Client matching User endpoint configuration" + scimServerConfiguration.getId()); } } + + + public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer operationToDispatch) { + initializeClientsIfNeeded(); + // Scim client should already have been created + Optional matchingClient = groupScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + if (matchingClient.isPresent()) { + try { + operationToDispatch.acceptThrows(matchingClient.get()); + LOGGER.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName()); + } catch (ScimPropagationException e) { + exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); + } + } else { + LOGGER.error("[SCIM] Could not find a Scim Client matching Group endpoint configuration" + scimServerConfiguration.getId()); + } + } + + public void close() { + for (GroupScimService c : groupScimServices) { + c.close(); + } + for (UserScimService c : userScimServices) { + c.close(); + } + groupScimServices.clear(); + userScimServices.clear(); + } + + private void initializeClientsIfNeeded() { + if (!clientsInitialized) { + clientsInitialized = true; + refreshActiveScimEndpoints(); + } + } + + /** + * A Consumer that throws ScimPropagationException. + * + * @param An {@link AbstractScimService to call} + */ + @FunctionalInterface + public interface SCIMPropagationConsumer { + + void acceptThrows(T elem) throws ScimPropagationException; + + } } diff --git a/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java new file mode 100644 index 0000000000..b73df47725 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java @@ -0,0 +1,165 @@ +package sh.libre.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 sh.libre.scim.event.ScimBackgroundGroupMembershipUpdater; + +import java.util.Date; +import java.util.List; + +/** + * Allows to register and configure Scim endpoints through Admin console, using the provided config properties. + */ +public class ScimEndpointConfigurationStorageProviderFactory + implements UserStorageProviderFactory, ImportSynchronization { + public static final String ID = "scim"; + private static final Logger LOGGER = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class); + + @Override + public String getId() { + return ID; + } + + + @Override + public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, + UserStorageProviderModel model) { + // Manually Launch a synchronization between keycloack and the SCIM endpoint described in the given model + LOGGER.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getName()); + SynchronizationResult result = new SynchronizationResult(); + KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + ScimDispatcher dispatcher = new ScimDispatcher(session); + if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER))) { + dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); + } + if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP))) { + dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); + } + dispatcher.close(); + }); + return result; + } + + @Override + public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, + UserStorageProviderModel model) { + return this.sync(sessionFactory, realmId, model); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + ScimBackgroundGroupMembershipUpdater scimBackgroundGroupMembershipUpdater = new ScimBackgroundGroupMembershipUpdater(factory); + scimBackgroundGroupMembershipUpdater.startBackgroundUpdates(); + } + + @Override + public List getConfigProperties() { + // These Config Properties will be use to generate configuration page in Admin Console + return ProviderConfigurationBuilder.create() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_ENDPOINT) + .type(ProviderConfigProperty.STRING_TYPE) + .required(true) + .label("SCIM 2.0 endpoint") + .helpText("External SCIM 2.0 base " + + "URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_CONTENT_TYPE) + .type(ProviderConfigProperty.LIST_TYPE) + .label("Endpoint content type") + .helpText("Only used when endpoint doesn't support application/scim+json") + .options(MediaType.APPLICATION_JSON, HttpHeader.SCIM_CONTENT_TYPE) + .defaultValue(HttpHeader.SCIM_CONTENT_TYPE) + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_MODE) + .type(ProviderConfigProperty.LIST_TYPE) + .label("Auth mode") + .helpText("Select the authorization mode") + .options("NONE", "BASIC_AUTH", "BEARER") + .defaultValue("NONE") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_USER) + .type(ProviderConfigProperty.STRING_TYPE) + .label("Auth username") + .helpText("Required for basic authentication.") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_PASSWORD) + .type(ProviderConfigProperty.PASSWORD) + .label("Auth password/token") + .helpText("Password or token required for basic or bearer authentication.") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable user propagation") + .helpText("Should operation on users be propagated to this provider?") + .defaultValue(BooleanUtils.TRUE) + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable group propagation") + .helpText("Should operation on groups be propagated to this provider?") + .defaultValue(BooleanUtils.TRUE) + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable import during sync") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT_ACTION) + .type(ProviderConfigProperty.LIST_TYPE) + .label("Import action") + .helpText("What to do when the user doesn't exists in Keycloak.") + .options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE") + .defaultValue("CREATE_LOCAL") + .add() + .property() + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_REFRESH) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable refresh during sync") + .name(ScrimEndPointConfiguration.CONF_KEY_LOG_ALL_SCIM_REQUESTS) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Log SCIM requests and responses") + .helpText("If true, all sent SCIM requests and responses will be logged") + .add() + .build(); + } + + + @Override + public ScimEndpointConfigurationStorageProvider create(KeycloakSession session, ComponentModel model) { + return new ScimEndpointConfigurationStorageProvider(); + } + + /** + * Empty implementation : we used this {@link ScimEndpointConfigurationStorageProviderFactory} to generate Admin Console page. + */ + public static final class ScimEndpointConfigurationStorageProvider implements UserStorageProvider { + @Override + public void close() { + // Nothing to close here + } + } +} diff --git a/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java new file mode 100644 index 0000000000..6359b57153 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java @@ -0,0 +1,101 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.client.http.BasicAuth; +import org.keycloak.component.ComponentModel; + +public class ScrimEndPointConfiguration { + // Configuration keys : also used in Admin Console page + public static final String CONF_KEY_AUTH_MODE = "auth-mode"; + public static final String CONF_KEY_AUTH_PASSWORD = "auth-pass"; + public static final String CONF_KEY_AUTH_USER = "auth-user"; + public static final String CONF_KEY_CONTENT_TYPE = "content-type"; + public static final String CONF_KEY_ENDPOINT = "endpoint"; + public static final String CONF_KEY_SYNC_IMPORT_ACTION = "sync-import-action"; + public static final String CONF_KEY_SYNC_IMPORT = "sync-import"; + public static final String CONF_KEY_SYNC_REFRESH = "sync-refresh"; + public static final String CONF_KEY_PROPAGATION_USER = "propagation-user"; + public static final String CONF_KEY_PROPAGATION_GROUP = "propagation-group"; + public static final String CONF_KEY_LOG_ALL_SCIM_REQUESTS = "log-all-scim-requests"; + + private final String endPoint; + private final String id; + private final String name; + private final String contentType; + private final String authorizationHeaderValue; + private final ImportAction importAction; + private final boolean pullFromScimSynchronisationActivated; + private final boolean pushToScimSynchronisationActivated; + private final boolean logAllScimRequests; + + public ScrimEndPointConfiguration(ComponentModel scimProviderConfiguration) { + try { + AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE)); + + authorizationHeaderValue = switch (authMode) { + case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD); + case BASIC_AUTH -> { + BasicAuth basicAuth = BasicAuth.builder() + .username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER)) + .password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD)) + .build(); + yield basicAuth.getAuthorizationHeaderValue(); + } + case NONE -> ""; + }; + contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE, ""); + endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT, ""); + id = scimProviderConfiguration.getId(); + name = scimProviderConfiguration.getName(); + importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); + pullFromScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); + pushToScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); + logAllScimRequests = scimProviderConfiguration.get(CONF_KEY_LOG_ALL_SCIM_REQUESTS, false); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("authMode '" + scimProviderConfiguration.get(CONF_KEY_AUTH_MODE) + "' is not supported"); + } + } + + public boolean isPushToScimSynchronisationActivated() { + return pushToScimSynchronisationActivated; + } + + public boolean isPullFromScimSynchronisationActivated() { + return pullFromScimSynchronisationActivated; + } + + public String getContentType() { + return contentType; + } + + public String getAuthorizationHeaderValue() { + return authorizationHeaderValue; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public ImportAction getImportAction() { + return importAction; + } + + public String getEndPoint() { + return endPoint; + } + + public boolean isLogAllScimRequests() { + return logAllScimRequests; + } + + public enum AuthMode { + BEARER, BASIC_AUTH, NONE + } + + public enum ImportAction { + CREATE_LOCAL, DELETE_REMOTE, NOTHING + } +} diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java deleted file mode 100644 index c17ef52e79..0000000000 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ /dev/null @@ -1,218 +0,0 @@ -package sh.libre.scim.core; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.stream.Stream; - -import com.unboundid.scim2.common.types.Email; -import com.unboundid.scim2.common.types.Meta; -import com.unboundid.scim2.common.types.Role; -import com.unboundid.scim2.common.types.UserResource; - -import org.apache.commons.lang.StringUtils; -import org.jboss.logging.Logger; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserModel; - -public class UserAdapter extends Adapter { - - private String username; - private String displayName; - private String email; - private Boolean active; - private String[] roles; - - public UserAdapter(KeycloakSession session, String componentId) { - super(session, componentId, "User", Logger.getLogger(UserAdapter.class)); - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - if (this.username == null) { - this.username = username; - } - } - - public String getDisplayName() { - return displayName; - } - - public void setDisplayName(String displayName) { - if (this.displayName == null) { - this.displayName = displayName; - } - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - if (this.email == null) { - this.email = email; - } - } - - public Boolean getActive() { - return active; - } - - public void setActive(Boolean active) { - if (this.active == null) { - this.active = active; - } - } - - public String[] getRoles() { - return roles; - } - - public void setRoles(String[] roles) { - this.roles = roles; - } - - @Override - public Class getResourceClass() { - return UserResource.class; - } - - @Override - public void apply(UserModel user) { - setId(user.getId()); - setUsername(user.getUsername()); - var displayName = String.format("%s %s", StringUtils.defaultString(user.getFirstName()), - StringUtils.defaultString(user.getLastName())).trim(); - if (StringUtils.isEmpty(displayName)) { - displayName = user.getUsername(); - } - setDisplayName(displayName); - setEmail(user.getEmail()); - setActive(user.isEnabled()); - var rolesSet = new HashSet(); - user.getGroupsStream().flatMap(g -> g.getRoleMappingsStream()) - .filter((r) -> r.getFirstAttribute("scim").equals("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); - this.skip = StringUtils.equals(user.getFirstAttribute("scim-skip"), "true"); - } - - @Override - public void apply(UserResource user) { - setExternalId(user.getId()); - setUsername(user.getUserName()); - setDisplayName(user.getDisplayName()); - setActive(user.getActive()); - if (user.getEmails().size() > 0) { - setEmail(user.getEmails().get(0).getValue()); - } - } - - @Override - public UserResource toSCIM(Boolean addMeta) { - var user = new UserResource(); - user.setExternalId(id); - user.setUserName(username); - user.setId(externalId); - user.setDisplayName(displayName); - var emails = new ArrayList(); - if (email != null) { - emails.add( - new Email().setPrimary(true).setValue(email)); - } - user.setEmails(emails); - user.setActive(active); - if (addMeta) { - var meta = new Meta(); - try { - var uri = new URI("Users/" + externalId); - meta.setLocation(uri); - } catch (URISyntaxException e) { - } - user.setMeta(meta); - } - List roles = new ArrayList(); - for (var r : this.roles) { - var role = new Role(); - role.setValue(r); - roles.add(role); - } - user.setRoles(roles); - return user; - } - - @Override - public void createEntity() throws Exception { - if (StringUtils.isEmpty(username)) { - throw new Exception("can't create user with empty username"); - } - var user = session.users().addUser(realm, username); - user.setEmail(email); - user.setEnabled(active); - this.id = user.getId(); - } - - @Override - public Boolean entityExists() { - if (this.id == null) { - return false; - } - var user = session.users().getUserById(realm, id); - if (user != null) { - return true; - } - return false; - } - - @Override - public Boolean tryToMap() { - UserModel sameUsernameUser = null; - UserModel sameEmailUser = null; - if (username != null) { - sameUsernameUser = session.users().getUserByUsername(realm, username); - } - if (email != null) { - sameEmailUser = session.users().getUserByEmail(realm, email); - } - if ((sameUsernameUser != null && sameEmailUser != null) - && (sameUsernameUser.getId() != sameEmailUser.getId())) { - LOGGER.warnf("found 2 possible users for remote user %s %s", username, email); - return false; - } - if (sameUsernameUser != null) { - this.id = sameUsernameUser.getId(); - return true; - } - if (sameEmailUser != null) { - this.id = sameEmailUser.getId(); - return true; - } - return false; - } - - @Override - public Stream getResourceStream() { - return this.session.users().getUsersStream(this.session.getContext().getRealm()); - } - - @Override - public Boolean skipRefresh() { - return getUsername().equals("admin"); - } -} diff --git a/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java b/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java new file mode 100644 index 0000000000..44f7eb46e6 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.exceptions; + +public class InconsistentScimMappingException extends ScimPropagationException { + public InconsistentScimMappingException(String message) { + super(message); + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java b/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java new file mode 100644 index 0000000000..079443622b --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java @@ -0,0 +1,29 @@ +package sh.libre.scim.core.exceptions; + +import de.captaingoldfish.scim.sdk.client.response.ServerResponse; + +import java.util.Optional; + +public class InvalidResponseFromScimEndpointException extends ScimPropagationException { + + private final transient Optional response; + + public InvalidResponseFromScimEndpointException(ServerResponse response, String message) { + super(message); + this.response = Optional.of(response); + } + + public InvalidResponseFromScimEndpointException(String message, Exception e) { + super(message, e); + this.response = Optional.empty(); + } + + + /** + * Empty response can occur if a major exception was thrown while retrying the request. + */ + public Optional getResponse() { + return response; + } + +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java new file mode 100644 index 0000000000..d1fb108930 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java @@ -0,0 +1,55 @@ +package sh.libre.scim.core.exceptions; + +import com.google.common.collect.Lists; +import sh.libre.scim.core.ScrimEndPointConfiguration; + +import java.util.ArrayList; + + +public enum RollbackApproach implements RollbackStrategy { + ALWAYS_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + return true; + } + }, + NEVER_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + return false; + } + }, + CRITICAL_ONLY_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + if (e instanceof InconsistentScimMappingException) { + // Occurs when mapping between a SCIM resource and a keycloak user failed (missing, ambiguous..) + // Log can be sufficient here, no rollback required + return false; + } + if (e instanceof UnexpectedScimDataException) { + // Occurs when a SCIM endpoint sends invalid date (e.g. group with empty name, user without ids...) + // No rollback required : we cannot recover. This needs to be fixed in the SCIM endpoint data + return false; + } + if (e instanceof InvalidResponseFromScimEndpointException invalidResponseFromScimEndpointException) { + return shouldRollbackBecauseOfResponse(invalidResponseFromScimEndpointException); + } + // Should not occur + throw new IllegalStateException("Unkown ScimPropagationException", e); + } + + private boolean shouldRollbackBecauseOfResponse(InvalidResponseFromScimEndpointException e) { + // If we have a response + return e.getResponse().map(r -> { + // We consider that 404 are acceptable, otherwise rollback + ArrayList acceptableStatus = Lists.newArrayList(200, 204, 404); + return !acceptableStatus.contains(r.getHttpStatus()); + }).orElse( + // Never got an answer, server was either misconfigured or unreachable + // No rollback in that case. + false + ); + } + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java new file mode 100644 index 0000000000..90d859305c --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java @@ -0,0 +1,22 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +/** + * In charge of deciding, when facing a SCIM-related issue during an operation (e.g User creation), + * whether we should : + * - Log the issue and let the operation succeed in Keycloack database (potentially unsynchronising + * Keycloack with the SCIM servers) + * - Rollback the whole operation + */ +public interface RollbackStrategy { + + /** + * Indicates whether we should rollback the whole transaction because of the given exception. + * + * @param configuration The SCIM Endpoint configuration for which the exception occured + * @param e the exception that we have to handle + * @return true if transaction should be rolled back, false if we should log and continue operation + */ + boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e); +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java new file mode 100644 index 0000000000..78973d2ad6 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java @@ -0,0 +1,43 @@ +package sh.libre.scim.core.exceptions; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import sh.libre.scim.core.ScrimEndPointConfiguration; + +/** + * In charge of dealing with SCIM exceptions by ignoring, logging or rollback transaction according to : + * - The context in which it occurs (sync, user creation...) + * - The related SCIM endpoint and its configuration + * - The thrown exception itself + */ +public class ScimExceptionHandler { + private static final Logger LOGGER = Logger.getLogger(ScimExceptionHandler.class); + + private final KeycloakSession session; + private final RollbackStrategy rollbackStrategy; + + public ScimExceptionHandler(KeycloakSession session) { + this(session, RollbackApproach.CRITICAL_ONLY_ROLLBACK); + } + + public ScimExceptionHandler(KeycloakSession session, RollbackStrategy rollbackStrategy) { + this.session = session; + this.rollbackStrategy = rollbackStrategy; + } + + /** + * Handles the given exception by loggin and/or rollback transaction. + * + * @param scimProviderConfiguration the configuration of the endpoint for which the propagation exception occured + * @param e the occuring exception + */ + public void handleException(ScrimEndPointConfiguration scimProviderConfiguration, ScimPropagationException e) { + String errorMessage = "[SCIM] Error while propagating to SCIM endpoint " + scimProviderConfiguration.getName(); + if (rollbackStrategy.shouldRollback(scimProviderConfiguration, e)) { + session.getTransactionManager().rollback(); + LOGGER.error("TRANSACTION ROLLBACK - " + errorMessage, e); + } else { + LOGGER.warn(errorMessage, e); + } + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java b/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java new file mode 100644 index 0000000000..bee5ee18fd --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java @@ -0,0 +1,12 @@ +package sh.libre.scim.core.exceptions; + +public abstract class ScimPropagationException extends Exception { + + protected ScimPropagationException(String message) { + super(message); + } + + protected ScimPropagationException(String message, Exception e) { + super(message, e); + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java new file mode 100644 index 0000000000..e0669d59db --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java @@ -0,0 +1,59 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + + +public enum SkipOrStopApproach implements SkipOrStopStrategy { + ALWAYS_SKIP_AND_CONTINUE { + @Override + public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowInvalidEndpointConfiguration() { + return false; + } + + @Override + public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) { + return false; + } + }, + ALWAYS_STOP { + @Override + public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowInvalidEndpointConfiguration() { + return true; + } + + @Override + public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) { + return true; + } + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java new file mode 100644 index 0000000000..8ad46c7ff9 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java @@ -0,0 +1,66 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +/** + * In charge of deciding, when facing a SCIM-related issue, whether we should : + * - log a warning, skip the problematic element and continue the rest of the operation + * - stop immediately the whole operation (typically, a synchronisation between SCIM and Keycloack) + */ +public interface SkipOrStopStrategy { + /** + * Indicates if, during a synchronisation from Keycloack to a SCIM endpoint, we should : + * - cancel the whole synchronisation if an element CRUD fail, or + * - keep on with synchronisation, allowing a partial synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial synchronisation is allowed, + * false if we should stop the whole synchronisation at first issue + */ + boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration); + + /** + * Indicates if, during a synchronisation from a SCIM endpoint to Keycloack, we should : + * - cancel the whole synchronisation if an element CRUD fail, or + * - keep on with synchronisation, allowing a partial synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial synchronisation is allowed, + * false if we should interrupt the whole synchronisation at first issue + */ + boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration); + + + /** + * Indicates if, when we propagate a group creation or update to a SCIM endpoint and some + * of its members are not mapped to SCIM, we should allow partial group update or interrupt completely. + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial group update is allowed, + * false if we should interrupt the group update in case of any unmapped member + */ + boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration); + + /** + * Indicates if, when facing an invalid SCIM endpoint configuration (resulting in a unreachable SCIM server), + * we should stop or ignore this configuration. + * + * @return true the invalid endpoint should be ignored, + * * false if we should interrupt the rest of the synchronisation + */ + boolean allowInvalidEndpointConfiguration(); + + /** + * Indicates if, when trying to pull User or Groups from a SCIM endpoint, + * we encounter a invalid data (e.g. group with empty name), we should : + * - Skip the invalid element pull and continue + * - Cancel the whole synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if we should skip the invalid data synchronisation and pursue, + * false if we should interrupt immediately the whole synchronisation + */ + boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration); + + +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java b/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java new file mode 100644 index 0000000000..918127ef0b --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.exceptions; + +public class UnexpectedScimDataException extends ScimPropagationException { + public UnexpectedScimDataException(String message) { + super(message); + } +} diff --git a/src/main/java/sh/libre/scim/core/service/AbstractScimService.java b/src/main/java/sh/libre/scim/core/service/AbstractScimService.java new file mode 100644 index 0000000000..b22b6a9a8d --- /dev/null +++ b/src/main/java/sh/libre/scim/core/service/AbstractScimService.java @@ -0,0 +1,281 @@ +package sh.libre.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 sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.core.exceptions.InconsistentScimMappingException; +import sh.libre.scim.core.exceptions.InvalidResponseFromScimEndpointException; +import sh.libre.scim.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.exceptions.UnexpectedScimDataException; +import sh.libre.scim.jpa.ScimResourceDao; +import sh.libre.scim.jpa.ScimResourceMapping; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A service in charge of synchronisation (CRUD) between + * a Keykloak Role (UserModel, GroupModel) and a SCIM Resource (User,Group). + * + * @param The Keycloack Model (e.g. UserModel, GroupModel) + * @param The SCIM Resource (e.g. User, Group) + */ +public abstract class AbstractScimService implements AutoCloseable { + + private static final Logger LOGGER = Logger.getLogger(AbstractScimService.class); + + private final KeycloakSession keycloakSession; + protected final SkipOrStopStrategy skipOrStopStrategy; + private final ScrimEndPointConfiguration scimProviderConfiguration; + private final ScimResourceType type; + private final ScimClient scimClient; + + protected AbstractScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType type, SkipOrStopStrategy skipOrStopStrategy) { + this.keycloakSession = keycloakSession; + this.scimProviderConfiguration = scimProviderConfiguration; + this.type = type; + this.scimClient = ScimClient.open(scimProviderConfiguration, type); + this.skipOrStopStrategy = skipOrStopStrategy; + } + + public void create(K roleMapperModel) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + if (isMarkedToIgnore(roleMapperModel)) { + // Silently return: resource is explicitly marked as to ignore + return; + } + // If mapping, then we are trying to recreate a user that was already created by import + KeycloakId id = getId(roleMapperModel); + if (findMappingById(id).isPresent()) { + throw new InconsistentScimMappingException("Trying to create user with id " + id + ": id already exists in Keycloak database"); + } + S scimForCreation = scimRequestBodyForCreate(roleMapperModel); + EntityOnRemoteScimId externalId = scimClient.create(id, scimForCreation); + createMapping(id, externalId); + } + + public void update(K roleMapperModel) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + if (isMarkedToIgnore(roleMapperModel)) { + // Silently return: resource is explicitly marked as to ignore + return; + } + KeycloakId keycloakId = getId(roleMapperModel); + EntityOnRemoteScimId entityOnRemoteScimId = findMappingById(keycloakId) + .map(ScimResourceMapping::getExternalIdAsEntityOnRemoteScimId) + .orElseThrow(() -> new InconsistentScimMappingException("Failed to find SCIM mapping for " + keycloakId)); + S scimForReplace = scimRequestBodyForUpdate(roleMapperModel, entityOnRemoteScimId); + scimClient.update(entityOnRemoteScimId, scimForReplace); + } + + protected abstract S scimRequestBodyForUpdate(K roleMapperModel, EntityOnRemoteScimId externalId) throws InconsistentScimMappingException; + + public void delete(KeycloakId id) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + ScimResourceMapping resource = findMappingById(id) + .orElseThrow(() -> new InconsistentScimMappingException("Failed to delete resource %s, scim mapping not found: ".formatted(id))); + EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId(); + scimClient.delete(externalId); + getScimResourceDao().delete(resource); + } + + public void pushAllResourcesToScim(SynchronizationResult syncRes) throws InvalidResponseFromScimEndpointException, InconsistentScimMappingException { + LOGGER.info("[SCIM] Push resources to endpoint " + this.getConfiguration().getEndPoint()); + try (Stream resourcesStream = getResourceStream()) { + Set resources = resourcesStream.collect(Collectors.toUnmodifiableSet()); + for (K resource : resources) { + KeycloakId id = getId(resource); + pushSingleResourceToScim(syncRes, resource, id); + } + } + } + + public void pullAllResourcesFromScim(SynchronizationResult syncRes) throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + LOGGER.info("[SCIM] Pull resources from endpoint " + this.getConfiguration().getEndPoint()); + for (S resource : scimClient.listResources()) { + pullSingleResourceFromScim(syncRes, resource); + } + } + + private void pushSingleResourceToScim(SynchronizationResult syncRes, K resource, KeycloakId id) throws InvalidResponseFromScimEndpointException, InconsistentScimMappingException { + try { + LOGGER.infof("[SCIM] Reconciling local resource %s", id); + if (shouldIgnoreForScimSynchronization(resource)) { + LOGGER.infof("[SCIM] Skip local resource %s", id); + return; + } + if (findMappingById(id).isPresent()) { + LOGGER.info("[SCIM] Replacing it"); + update(resource); + } else { + LOGGER.info("[SCIM] Creating it"); + create(resource); + } + syncRes.increaseUpdated(); + } catch (InvalidResponseFromScimEndpointException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPushingToScim(this.getConfiguration())) { + LOGGER.warn("Error while syncing " + id + " to endpoint " + getConfiguration().getEndPoint(), e); + } else { + throw e; + } + } catch (InconsistentScimMappingException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPushingToScim(this.getConfiguration())) { + LOGGER.warn("Inconsistent data for element " + id + " and endpoint " + getConfiguration().getEndPoint(), e); + } else { + throw e; + } + } + } + + + private void pullSingleResourceFromScim(SynchronizationResult syncRes, S resource) throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + try { + LOGGER.infof("[SCIM] Reconciling remote resource %s", resource); + EntityOnRemoteScimId externalId = resource.getId() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new UnexpectedScimDataException("Remote SCIM resource doesn't have an id, cannot import it in Keycloak")); + if (validMappingAlreadyExists(externalId)) return; + + // Here no keycloak user/group matching the SCIM external id exists + // Try to match existing keycloak resource by properties (username, email, name) + Optional mapped = matchKeycloakMappingByScimProperties(resource); + if (mapped.isPresent()) { + // If found a mapped, update + LOGGER.info("[SCIM] Matched SCIM resource " + externalId + " from properties with keycloak entity " + mapped.get()); + createMapping(mapped.get(), externalId); + syncRes.increaseUpdated(); + } else { + // If not, create it locally or deleting it remotely (according to the configured Import Action) + createLocalOrDeleteRemote(syncRes, resource, externalId); + } + } catch (UnexpectedScimDataException e) { + if (skipOrStopStrategy.skipInvalidDataFromScimEndpoint(getConfiguration())) { + LOGGER.warn("[SCIM] Skipping element synchronisation because of invalid Scim Data for element " + resource.getId() + " : " + e.getMessage(), e); + } else { + throw e; + } + } catch (InconsistentScimMappingException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPullingFromScim(getConfiguration())) { + LOGGER.warn("[SCIM] Skipping element synchronisation because of inconsistent mapping for element " + resource.getId() + " : " + e.getMessage(), e); + } else { + throw e; + } + } catch (InvalidResponseFromScimEndpointException e) { + // Can only occur in case of a DELETE_REMOTE conflict action + if (skipOrStopStrategy.allowPartialSynchronizationWhenPullingFromScim(getConfiguration())) { + LOGGER.warn("[SCIM] Could not delete SCIM resource " + resource.getId() + " during synchronisation", e); + } else { + throw e; + } + } + + } + + private boolean validMappingAlreadyExists(EntityOnRemoteScimId externalId) { + Optional optionalMapping = getScimResourceDao().findByExternalId(externalId, type); + // If an existing mapping exists, delete potential dangling references + if (optionalMapping.isPresent()) { + ScimResourceMapping mapping = optionalMapping.get(); + if (entityExists(mapping.getIdAsKeycloakId())) { + LOGGER.info("[SCIM] Valid mapping found, skipping"); + return true; + } else { + LOGGER.info("[SCIM] Delete a dangling mapping"); + getScimResourceDao().delete(mapping); + } + } + return false; + } + + private void createLocalOrDeleteRemote(SynchronizationResult syncRes, S resource, EntityOnRemoteScimId externalId) throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + switch (scimProviderConfiguration.getImportAction()) { + case CREATE_LOCAL -> { + LOGGER.info("[SCIM] Create local resource for SCIM resource " + externalId); + KeycloakId id = createEntity(resource); + createMapping(id, externalId); + syncRes.increaseAdded(); + } + case DELETE_REMOTE -> { + LOGGER.info("[SCIM] Delete remote resource " + externalId); + scimClient.delete(externalId); + } + case NOTHING -> LOGGER.info("[SCIM] Import action set to NOTHING"); + } + } + + + protected abstract S scimRequestBodyForCreate(K roleMapperModel) throws InconsistentScimMappingException; + + protected abstract KeycloakId getId(K roleMapperModel); + + protected abstract boolean isMarkedToIgnore(K roleMapperModel); + + private void createMapping(KeycloakId keycloakId, EntityOnRemoteScimId externalId) { + getScimResourceDao().create(keycloakId, externalId, type); + } + + protected ScimResourceDao getScimResourceDao() { + return ScimResourceDao.newInstance(getKeycloakSession(), scimProviderConfiguration.getId()); + } + + private Optional findMappingById(KeycloakId keycloakId) { + return getScimResourceDao().findById(keycloakId, type); + } + + private KeycloakSession getKeycloakSession() { + return keycloakSession; + } + + + protected abstract boolean shouldIgnoreForScimSynchronization(K resource); + + protected abstract Stream getResourceStream(); + + protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException, InconsistentScimMappingException; + + protected abstract Optional matchKeycloakMappingByScimProperties(S resource) throws InconsistentScimMappingException; + + protected abstract boolean entityExists(KeycloakId keycloakId); + + public void sync(SynchronizationResult syncRes) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException, UnexpectedScimDataException { + if (this.scimProviderConfiguration.isPullFromScimSynchronisationActivated()) { + this.pullAllResourcesFromScim(syncRes); + } + if (this.scimProviderConfiguration.isPushToScimSynchronisationActivated()) { + this.pushAllResourcesToScim(syncRes); + } + } + + protected Meta newMetaLocation(EntityOnRemoteScimId externalId) { + Meta meta = new Meta(); + URI uri = getUri(type, externalId); + meta.setLocation(uri.toString()); + return meta; + } + + protected URI getUri(ScimResourceType type, EntityOnRemoteScimId externalId) { + try { + return new URI("%s/%s".formatted(type.getEndpoint(), externalId.asString())); + } catch (URISyntaxException e) { + throw new IllegalStateException("should never occur: can not format URI for type %s and id %s".formatted(type, externalId), e); + } + } + + protected KeycloakDao getKeycloakDao() { + return new KeycloakDao(getKeycloakSession()); + } + + @Override + public void close() { + scimClient.close(); + } + + public ScrimEndPointConfiguration getConfiguration() { + return scimProviderConfiguration; + } +} diff --git a/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java b/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java new file mode 100644 index 0000000000..df96a12323 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java @@ -0,0 +1,6 @@ +package sh.libre.scim.core.service; + +public record EntityOnRemoteScimId( + String asString +) { +} diff --git a/src/main/java/sh/libre/scim/core/service/GroupScimService.java b/src/main/java/sh/libre/scim/core/service/GroupScimService.java new file mode 100644 index 0000000000..bd09f3e9b2 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/service/GroupScimService.java @@ -0,0 +1,131 @@ +package sh.libre.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 sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.core.exceptions.InconsistentScimMappingException; +import sh.libre.scim.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.exceptions.UnexpectedScimDataException; +import sh.libre.scim.jpa.ScimResourceMapping; + +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; + +public class GroupScimService extends AbstractScimService { + private static final Logger LOGGER = Logger.getLogger(GroupScimService.class); + + public GroupScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration, SkipOrStopStrategy skipOrStopStrategy) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP, skipOrStopStrategy); + } + + @Override + protected Stream getResourceStream() { + return getKeycloakDao().getGroupsStream(); + } + + @Override + protected boolean entityExists(KeycloakId keycloakId) { + return getKeycloakDao().groupExists(keycloakId); + } + + @Override + protected Optional matchKeycloakMappingByScimProperties(Group resource) { + Set names = new TreeSet<>(); + resource.getId().ifPresent(names::add); + resource.getDisplayName().ifPresent(names::add); + try (Stream groupsStream = getKeycloakDao().getGroupsStream()) { + Optional group = groupsStream + .filter(groupModel -> names.contains(groupModel.getName())) + .findFirst(); + return group + .map(GroupModel::getId) + .map(KeycloakId::new); + } + } + + @Override + protected KeycloakId createEntity(Group resource) throws UnexpectedScimDataException, InconsistentScimMappingException { + String displayName = resource.getDisplayName() + .filter(StringUtils::isNotBlank) + .orElseThrow(() -> new UnexpectedScimDataException("Remote Scim group has empty name, can't create. Resource id = %s".formatted(resource.getId()))); + GroupModel group = getKeycloakDao().createGroup(displayName); + List groupMembers = resource.getMembers(); + if (CollectionUtils.isNotEmpty(groupMembers)) { + for (Member groupMember : groupMembers) { + EntityOnRemoteScimId externalId = groupMember.getValue() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new UnexpectedScimDataException("can't create group member for group '%s' without id: ".formatted(displayName) + resource)); + KeycloakId userId = getScimResourceDao().findUserByExternalId(externalId) + .map(ScimResourceMapping::getIdAsKeycloakId) + .orElseThrow(() -> new InconsistentScimMappingException("can't find mapping for group member %s".formatted(externalId))); + UserModel userModel = getKeycloakDao().getUserById(userId); + userModel.joinGroup(group); + } + } + return new KeycloakId(group.getId()); + } + + @Override + protected boolean isMarkedToIgnore(GroupModel groupModel) { + return BooleanUtils.TRUE.equals(groupModel.getFirstAttribute("scim-skip")); + } + + @Override + protected KeycloakId getId(GroupModel groupModel) { + return new KeycloakId(groupModel.getId()); + } + + @Override + protected Group scimRequestBodyForCreate(GroupModel groupModel) throws InconsistentScimMappingException { + Set members = getKeycloakDao().getGroupMembers(groupModel); + Group group = new Group(); + group.setExternalId(groupModel.getId()); + group.setDisplayName(groupModel.getName()); + for (KeycloakId member : members) { + Member groupMember = new Member(); + Optional optionalGroupMemberMapping = getScimResourceDao().findUserById(member); + if (optionalGroupMemberMapping.isPresent()) { + ScimResourceMapping groupMemberMapping = optionalGroupMemberMapping.get(); + EntityOnRemoteScimId externalIdAsEntityOnRemoteScimId = groupMemberMapping.getExternalIdAsEntityOnRemoteScimId(); + groupMember.setValue(externalIdAsEntityOnRemoteScimId.asString()); + URI ref = getUri(ScimResourceType.USER, externalIdAsEntityOnRemoteScimId); + groupMember.setRef(ref.toString()); + group.addMember(groupMember); + } else { + String message = "Unmapped member " + member + " for group " + groupModel.getId(); + if (skipOrStopStrategy.allowMissingMembersWhenPushingGroupToScim(this.getConfiguration())) { + LOGGER.warn(message); + } else { + throw new InconsistentScimMappingException(message); + } + } + } + return group; + } + + @Override + protected Group scimRequestBodyForUpdate(GroupModel groupModel, EntityOnRemoteScimId externalId) throws InconsistentScimMappingException { + Group group = scimRequestBodyForCreate(groupModel); + group.setId(externalId.asString()); + Meta meta = newMetaLocation(externalId); + group.setMeta(meta); + return group; + } + + @Override + protected boolean shouldIgnoreForScimSynchronization(GroupModel resource) { + return false; + } +} diff --git a/src/main/java/sh/libre/scim/core/service/KeycloakDao.java b/src/main/java/sh/libre/scim/core/service/KeycloakDao.java new file mode 100644 index 0000000000..f4c406c351 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/service/KeycloakDao.java @@ -0,0 +1,81 @@ +package sh.libre.scim.core.service; + +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class KeycloakDao { + + private final KeycloakSession keycloakSession; + + public KeycloakDao(KeycloakSession keycloakSession) { + this.keycloakSession = keycloakSession; + } + + private KeycloakSession getKeycloakSession() { + return keycloakSession; + } + + private RealmModel getRealm() { + return getKeycloakSession().getContext().getRealm(); + } + + public boolean groupExists(KeycloakId groupId) { + GroupModel group = getKeycloakSession().groups().getGroupById(getRealm(), groupId.asString()); + return group != null; + } + + public boolean userExists(KeycloakId userId) { + UserModel user = getUserById(userId); + return user != null; + } + + public UserModel getUserById(KeycloakId userId) { + return getKeycloakSession().users().getUserById(getRealm(), userId.asString()); + } + + public GroupModel getGroupById(KeycloakId groupId) { + return getKeycloakSession().groups().getGroupById(getRealm(), groupId.asString()); + } + + + public Stream getGroupsStream() { + return getKeycloakSession().groups().getGroupsStream(getRealm()); + } + + public GroupModel createGroup(String displayName) { + return getKeycloakSession().groups().createGroup(getRealm(), displayName); + } + + public Set getGroupMembers(GroupModel groupModel) { + return getKeycloakSession().users() + .getGroupMembersStream(getRealm(), groupModel) + .map(UserModel::getId) + .map(KeycloakId::new) + .collect(Collectors.toSet()); + } + + public Stream getUsersStream() { + return getKeycloakSession().users().searchForUserStream(getRealm(), Collections.emptyMap()); + } + + public UserModel getUserByUsername(String username) { + return getKeycloakSession().users().getUserByUsername(getRealm(), username); + } + + public UserModel getUserByEmail(String email) { + return getKeycloakSession().users().getUserByEmail(getRealm(), email); + } + + public UserModel addUser(String username) { + return getKeycloakSession().users().addUser(getRealm(), username); + } + + +} diff --git a/src/main/java/sh/libre/scim/core/service/KeycloakId.java b/src/main/java/sh/libre/scim/core/service/KeycloakId.java new file mode 100644 index 0000000000..04bad470b3 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/service/KeycloakId.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.service; + +public record KeycloakId( + String asString +) { + +} diff --git a/src/main/java/sh/libre/scim/core/service/ScimClient.java b/src/main/java/sh/libre/scim/core/service/ScimClient.java new file mode 100644 index 0000000000..de3b4d7c0f --- /dev/null +++ b/src/main/java/sh/libre/scim/core/service/ScimClient.java @@ -0,0 +1,155 @@ +package sh.libre.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 sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.core.exceptions.InvalidResponseFromScimEndpointException; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class ScimClient implements AutoCloseable { + private static final Logger LOGGER = Logger.getLogger(ScimClient.class); + + private final RetryRegistry retryRegistry; + + private final ScimRequestBuilder scimRequestBuilder; + + private final ScimResourceType scimResourceType; + private final boolean logAllRequests; + + private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType, boolean detailedLogs) { + this.scimRequestBuilder = scimRequestBuilder; + this.scimResourceType = scimResourceType; + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(10) + .intervalFunction(IntervalFunction.ofExponentialBackoff()) + .retryExceptions(ProcessingException.class) + .build(); + retryRegistry = RetryRegistry.of(retryConfig); + this.logAllRequests = detailedLogs; + } + + public static ScimClient open(ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { + String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); + Map httpHeaders = new HashMap<>(); + httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue()); + httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType()); + ScimClientConfig scimClientConfig = ScimClientConfig.builder() + .httpHeaders(httpHeaders) + .connectTimeout(5) + .requestTimeout(5) + .socketTimeout(5) + .build(); + ScimRequestBuilder scimRequestBuilder = + new ScimRequestBuilder( + scimApplicationBaseUrl, + scimClientConfig + ); + return new ScimClient<>(scimRequestBuilder, scimResourceType, scimProviderConfiguration.isLogAllScimRequests()); + } + + public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws InvalidResponseFromScimEndpointException { + Optional scimForCreationId = scimForCreation.getId(); + if (scimForCreationId.isPresent()) { + throw new IllegalArgumentException( + "User to create should never have an existing id: %s %s".formatted(id, scimForCreationId.get()) + ); + } + try { + Retry retry = retryRegistry.retry("create-%s".formatted(id.asString())); + if (logAllRequests) { + LOGGER.info("[SCIM] Sending CREATE " + scimForCreation.toPrettyString() + "\n to " + getScimEndpoint()); + } + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .create(getResourceClass(), getScimEndpoint()) + .setResource(scimForCreation) + .sendRequest() + ); + checkResponseIsSuccess(response); + S resource = response.getResource(); + return resource.getId() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new InvalidResponseFromScimEndpointException(response, "Created SCIM resource does not have id")); + + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying create " + e.getMessage(), e); + } + } + + private void checkResponseIsSuccess(ServerResponse response) throws InvalidResponseFromScimEndpointException { + if (logAllRequests) { + LOGGER.info("[SCIM] Server response " + response.getHttpStatus() + "\n" + response.getResponseBody()); + } + if (!response.isSuccess()) { + throw new InvalidResponseFromScimEndpointException(response, "Server answered with status " + response.getResponseBody() + ": " + response.getResponseBody()); + } + } + + private String getScimEndpoint() { + return scimResourceType.getEndpoint(); + } + + private Class getResourceClass() { + return scimResourceType.getResourceClass(); + } + + public void update(EntityOnRemoteScimId externalId, S scimForReplace) throws InvalidResponseFromScimEndpointException { + Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); + try { + if (logAllRequests) { + LOGGER.info("[SCIM] Sending UPDATE " + scimForReplace.toPrettyString() + "\n to " + getScimEndpoint()); + } + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .update(getResourceClass(), getScimEndpoint(), externalId.asString()) + .setResource(scimForReplace) + .sendRequest() + ); + checkResponseIsSuccess(response); + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying update " + e.getMessage(), e); + } + } + + public void delete(EntityOnRemoteScimId externalId) throws InvalidResponseFromScimEndpointException { + Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString())); + if (logAllRequests) { + LOGGER.info("[SCIM] Sending DELETE to " + getScimEndpoint()); + } + try { + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .delete(getResourceClass(), getScimEndpoint(), externalId.asString()) + .sendRequest() + ); + checkResponseIsSuccess(response); + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying delete " + e.getMessage(), e); + } + } + + @Override + public void close() { + scimRequestBuilder.close(); + } + + public List listResources() { + ServerResponse> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get().sendRequest(); + ListResponse resourceTypeListResponse = response.getResource(); + return resourceTypeListResponse.getListedResources(); + } +} diff --git a/src/main/java/sh/libre/scim/core/service/ScimResourceType.java b/src/main/java/sh/libre/scim/core/service/ScimResourceType.java new file mode 100644 index 0000000000..b90845b6cf --- /dev/null +++ b/src/main/java/sh/libre/scim/core/service/ScimResourceType.java @@ -0,0 +1,29 @@ +package sh.libre.scim.core.service; + +import de.captaingoldfish.scim.sdk.common.resources.Group; +import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; +import de.captaingoldfish.scim.sdk.common.resources.User; + +public enum ScimResourceType { + + USER("/Users", User.class), + + GROUP("/Groups", Group.class); + + private final String endpoint; + + private final Class resourceClass; + + ScimResourceType(String endpoint, Class resourceClass) { + this.endpoint = endpoint; + this.resourceClass = resourceClass; + } + + public String getEndpoint() { + return endpoint; + } + + public Class getResourceClass() { + return (Class) resourceClass; + } +} diff --git a/src/main/java/sh/libre/scim/core/service/UserScimService.java b/src/main/java/sh/libre/scim/core/service/UserScimService.java new file mode 100644 index 0000000000..c0262f3d4a --- /dev/null +++ b/src/main/java/sh/libre/scim/core/service/UserScimService.java @@ -0,0 +1,145 @@ +package sh.libre.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 sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.core.exceptions.InconsistentScimMappingException; +import sh.libre.scim.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.exceptions.UnexpectedScimDataException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +public class UserScimService extends AbstractScimService { + private static final Logger LOGGER = Logger.getLogger(UserScimService.class); + + public UserScimService( + KeycloakSession keycloakSession, + ScrimEndPointConfiguration scimProviderConfiguration, + SkipOrStopStrategy skipOrStopStrategy) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER, skipOrStopStrategy); + } + + @Override + protected Stream getResourceStream() { + return getKeycloakDao().getUsersStream(); + } + + @Override + protected boolean entityExists(KeycloakId keycloakId) { + return getKeycloakDao().userExists(keycloakId); + } + + @Override + protected Optional matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimMappingException { + Optional matchedByUsername = resource.getUserName() + .map(getKeycloakDao()::getUserByUsername) + .map(this::getId); + Optional matchedByEmail = resource.getEmails().stream() + .findFirst() + .flatMap(MultiComplexNode::getValue) + .map(getKeycloakDao()::getUserByEmail) + .map(this::getId); + if (matchedByUsername.isPresent() + && matchedByEmail.isPresent() + && !matchedByUsername.equals(matchedByEmail)) { + String inconstencyErrorMessage = "Found 2 possible users for remote user " + matchedByUsername.get() + " - " + matchedByEmail.get(); + LOGGER.warn(inconstencyErrorMessage); + throw new InconsistentScimMappingException(inconstencyErrorMessage); + } + if (matchedByUsername.isPresent()) { + return matchedByUsername; + } + return matchedByEmail; + } + + @Override + protected KeycloakId createEntity(User resource) throws UnexpectedScimDataException { + String username = resource.getUserName() + .filter(StringUtils::isNotBlank) + .orElseThrow(() -> new UnexpectedScimDataException("Remote Scim user has empty username, can't create. Resource id = %s".formatted(resource.getId()))); + UserModel user = getKeycloakDao().addUser(username); + resource.getEmails().stream() + .findFirst() + .flatMap(MultiComplexNode::getValue) + .ifPresent(user::setEmail); + boolean userEnabled = resource.isActive().orElse(false); + user.setEnabled(userEnabled); + return new KeycloakId(user.getId()); + } + + @Override + protected boolean isMarkedToIgnore(UserModel userModel) { + return BooleanUtils.TRUE.equals(userModel.getFirstAttribute("scim-skip")); + } + + @Override + protected KeycloakId getId(UserModel userModel) { + return new KeycloakId(userModel.getId()); + } + + @Override + protected User scimRequestBodyForCreate(UserModel roleMapperModel) { + String firstAndLastName = String.format("%s %s", + StringUtils.defaultString(roleMapperModel.getFirstName()), + StringUtils.defaultString(roleMapperModel.getLastName())).trim(); + String displayName = Objects.toString(firstAndLastName, roleMapperModel.getUsername()); + Stream groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream); + Stream roleModels = roleMapperModel.getRoleMappingsStream(); + Stream allRoleModels = Stream.concat(groupRoleModels, roleModels); + List roles = allRoleModels + .filter(r -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) + .map(RoleModel::getName) + .map(roleName -> { + PersonRole personRole = new PersonRole(); + personRole.setValue(roleName); + return personRole; + }) + .toList(); + User user = new User(); + user.setRoles(roles); + user.setExternalId(roleMapperModel.getId()); + user.setUserName(roleMapperModel.getUsername()); + user.setDisplayName(displayName); + Name name = new Name(); + name.setFamilyName(roleMapperModel.getLastName()); + name.setGivenName(roleMapperModel.getFirstName()); + user.setName(name); + List emails = new ArrayList<>(); + if (roleMapperModel.getEmail() != null) { + emails.add( + Email.builder().value(roleMapperModel.getEmail()).build()); + } + user.setEmails(emails); + user.setActive(roleMapperModel.isEnabled()); + return user; + } + + @Override + protected User scimRequestBodyForUpdate(UserModel userModel, EntityOnRemoteScimId externalId) { + User user = scimRequestBodyForCreate(userModel); + user.setId(externalId.asString()); + Meta meta = newMetaLocation(externalId); + user.setMeta(meta); + return user; + } + + @Override + protected boolean shouldIgnoreForScimSynchronization(UserModel userModel) { + return "admin".equals(userModel.getUsername()); + } +} diff --git a/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java b/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java new file mode 100644 index 0000000000..4c49f74f67 --- /dev/null +++ b/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java @@ -0,0 +1,74 @@ +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/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 3fbad121ca..2c177b0e47 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -1,9 +1,7 @@ package sh.libre.scim.event; -import java.util.HashMap; -import java.util.regex.*; - import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; import org.keycloak.events.EventType; @@ -13,117 +11,237 @@ 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.GroupAdapter; import sh.libre.scim.core.ScimDispatcher; -import sh.libre.scim.core.UserAdapter; +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 { - final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class); - ScimDispatcher dispatcher; - KeycloakSession session; - HashMap patterns = new HashMap(); + + 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; - dispatcher = new ScimDispatcher(session); - patterns.put(ResourceType.USER, Pattern.compile("users/(.+)")); - patterns.put(ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?")); - patterns.put(ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)")); - patterns.put(ResourceType.REALM_ROLE_MAPPING, Pattern.compile("^(.+)/(.+)/role-mappings")); - } - - @Override - public void close() { + this.keycloakDao = new KeycloakDao(session); + this.dispatcher = new ScimDispatcher(session); } @Override public void onEvent(Event event) { - if (event.getType() == EventType.REGISTER) { - var user = getUser(event.getUserId()); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); - } - if (event.getType() == EventType.UPDATE_EMAIL || event.getType() == EventType.UPDATE_PROFILE) { - var user = getUser(event.getUserId()); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); - } - if (event.getType() == EventType.DELETE_ACCOUNT) { - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, event.getUserId())); + // 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) { - var pattern = patterns.get(event.getResourceType()); + // Step 1: check if event is relevant for propagation through SCIM + Pattern pattern = listenedEventPathPatterns.get(event.getResourceType()); if (pattern == null) return; - var matcher = pattern.matcher(event.getResourcePath()); + Matcher matcher = pattern.matcher(event.getResourcePath()); if (!matcher.find()) return; - if (event.getResourceType() == ResourceType.USER) { - var userId = matcher.group(1); - LOGGER.infof("%s %s", userId, event.getOperationType()); - if (event.getOperationType() == OperationType.CREATE) { - var user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); - user.getGroupsStream().forEach(group -> { - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); - }); + + + // 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); } - if (event.getOperationType() == OperationType.UPDATE) { - var user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + case GROUP -> { + KeycloakId groupId = new KeycloakId(matcher.group(1)); + handleGroupEvent(event, groupId); } - if (event.getOperationType() == OperationType.DELETE) { - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, userId)); + case GROUP_MEMBERSHIP -> { + KeycloakId userId = new KeycloakId(matcher.group(1)); + KeycloakId groupId = new KeycloakId(matcher.group(2)); + handleGroupMemberShipEvent(event, userId, groupId); } - } - if (event.getResourceType() == ResourceType.GROUP) { - var groupId = matcher.group(1); - LOGGER.infof("group %s %s", groupId, event.getOperationType()); - if (event.getOperationType() == OperationType.CREATE) { - var group = getGroup(groupId); - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.create(GroupAdapter.class, group)); + 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); } - if (event.getOperationType() == OperationType.UPDATE) { - var group = getGroup(groupId); - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); + case COMPONENT -> { + String id = matcher.group(1); + handleScimEndpointConfigurationEvent(event, id); + } - if (event.getOperationType() == OperationType.DELETE) { - dispatcher.run(ScimDispatcher.SCOPE_GROUP, - (client) -> client.delete(GroupAdapter.class, groupId)); - } - } - if (event.getResourceType() == ResourceType.GROUP_MEMBERSHIP) { - var userId = matcher.group(1); - 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)); - var user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); - } - if (event.getResourceType() == ResourceType.REALM_ROLE_MAPPING) { - var type = matcher.group(1); - var id = matcher.group(2); - LOGGER.infof("%s %s %s roles", event.getOperationType(), type, id); - if (type.equals("users")) { - var user = getUser(id); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); - } else if (type.equals("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)); - }); + default -> { + // No other resource modification has to be propagated to Scim endpoints } } } - private UserModel getUser(String id) { - return session.users().getUserById(session.getContext().getRealm(), id); + + 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 + } + } } - private GroupModel getGroup(String id) { - return session.groups().getGroupById(session.getContext().getRealm(), id); + /** + * 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/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java index debe59b8fb..c7b437a287 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java @@ -13,20 +13,24 @@ public class ScimEventListenerProviderFactory implements EventListenerProviderFa 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 } - @Override - public String getId() { - return "scim"; - } } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java deleted file mode 100644 index a9f69581cd..0000000000 --- a/src/main/java/sh/libre/scim/jpa/ScimResource.java +++ /dev/null @@ -1,78 +0,0 @@ -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; - -@Entity -@IdClass(ScimResourceId.class) -@Table(name = "SCIM_RESOURCE") -@NamedQueries({ - @NamedQuery(name = "findById", query = "from ScimResource where realmId = :realmId and componentId = :componentId and type = :type and id = :id"), - @NamedQuery(name = "findByExternalId", query = "from ScimResource where realmId = :realmId and componentId = :componentId and type = :type and externalId = :id") }) -public class ScimResource { - @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; - } - -} diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java new file mode 100644 index 0000000000..4deec373b9 --- /dev/null +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -0,0 +1,96 @@ +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/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java index 7775543a54..d0abddf2b1 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -1,5 +1,7 @@ package sh.libre.scim.jpa; +import org.apache.commons.lang3.StringUtils; + import java.io.Serializable; import java.util.Objects; @@ -65,14 +67,13 @@ public class ScimResourceId implements Serializable { public boolean equals(Object other) { if (this == other) return true; - if (!(other instanceof ScimResourceId)) + if (!(other instanceof ScimResourceId o)) return false; - var o = (ScimResourceId) other; - return (o.id == id && - o.realmId == realmId && - o.componentId == componentId && - o.type == type && - o.externalId == externalId); + 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 diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java b/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java new file mode 100644 index 0000000000..ade6848ccd --- /dev/null +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java @@ -0,0 +1,89 @@ +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/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java index 5da5880085..6ef55a060e 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java @@ -1,16 +1,15 @@ package sh.libre.scim.jpa; -import java.util.List; - 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(ScimResource.class); + return Collections.singletonList(ScimResourceMapping.class); } @Override @@ -20,6 +19,7 @@ public class ScimResourceProvider implements JpaEntityProvider { @Override public void close() { + // Nothing to close } @Override diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java index 682ccbee04..7f3cc323dd 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java @@ -7,10 +7,8 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; public class ScimResourceProviderFactory implements JpaEntityProviderFactory { - final static String ID ="scim-resource"; - @Override - public void close() { - } + + static final String ID = "scim-resource"; @Override public JpaEntityProvider create(KeycloakSession session) { @@ -24,9 +22,18 @@ public class ScimResourceProviderFactory implements JpaEntityProviderFactory { @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/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java b/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java deleted file mode 100644 index 19496069fe..0000000000 --- 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 deleted file mode 100644 index d961b70032..0000000000 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ /dev/null @@ -1,150 +0,0 @@ -package sh.libre.scim.storage; - -import java.util.Date; -import java.util.List; - -import javax.ws.rs.core.MediaType; - -import com.unboundid.scim2.client.ScimService; - -import org.jboss.logging.Logger; -import org.keycloak.component.ComponentModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.KeycloakSessionTask; -import org.keycloak.models.utils.KeycloakModelUtils; -import org.keycloak.provider.ProviderConfigProperty; -import org.keycloak.provider.ProviderConfigurationBuilder; -import org.keycloak.storage.UserStorageProviderFactory; -import org.keycloak.storage.UserStorageProviderModel; -import org.keycloak.storage.user.ImportSynchronization; -import org.keycloak.storage.user.SynchronizationResult; - -import sh.libre.scim.core.GroupAdapter; -import sh.libre.scim.core.ScimDispatcher; -import sh.libre.scim.core.UserAdapter; - -public class ScimStorageProviderFactory - implements UserStorageProviderFactory, ImportSynchronization { - final private Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class); - public final static String ID = "scim"; - protected static final List configMetadata; - static { - configMetadata = ProviderConfigurationBuilder.create() - .property() - .name("endpoint") - .type(ProviderConfigProperty.STRING_TYPE) - .label("SCIM 2.0 endpoint") - .helpText("External SCIM 2.0 base " + - "URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)") - .add() - .property() - .name("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.toString(), ScimService.MEDIA_TYPE_SCIM_TYPE.toString()) - .defaultValue(ScimService.MEDIA_TYPE_SCIM_TYPE.toString()) - .add() - .property() - .name("auth-mode") - .type(ProviderConfigProperty.LIST_TYPE) - .label("Auth mode") - .helpText("Select the authorization mode") - .options("NONE", "BASIC_AUTH", "BEARER") - .defaultValue("NONE") - .add() - .property() - .name("auth-user") - .type(ProviderConfigProperty.STRING_TYPE) - .label("Auth username") - .helpText("Required for basic authentification.") - .add() - .property() - .name("auth-pass") - .type(ProviderConfigProperty.PASSWORD) - .label("Auth password/token") - .helpText("Password or token required for basic or bearer authentification.") - .add() - .property() - .name("propagation-user") - .type(ProviderConfigProperty.BOOLEAN_TYPE) - .label("Enable user propagation") - .defaultValue("true") - .add() - .property() - .name("propagation-group") - .type(ProviderConfigProperty.BOOLEAN_TYPE) - .label("Enable group propagation") - .defaultValue("true") - .add() - .property() - .name("sync-import") - .type(ProviderConfigProperty.BOOLEAN_TYPE) - .label("Enable import during sync") - .add() - .property() - .name("sync-import-action") - .type(ProviderConfigProperty.LIST_TYPE) - .label("Import action") - .helpText("What to do when the user don\'t exists in Keycloak.") - .options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE") - .defaultValue("CREATE_LOCAL") - .add() - .property() - .name("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 getConfigProperties() { - return configMetadata; - } - - @Override - public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, - UserStorageProviderModel model) { - LOGGER.info("sync"); - var result = new SynchronizationResult(); - KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { - - @Override - public void run(KeycloakSession session) { - var realm = session.realms().getRealm(realmId); - session.getContext().setRealm(realm); - var dispatcher = new ScimDispatcher(session); - if (model.get("propagation-user").equals("true")) { - dispatcher.runOne(model, (client) -> client.sync(UserAdapter.class, result)); - } - if (model.get("propagation-group").equals("true")) { - dispatcher.runOne(model, (client) -> client.sync(GroupAdapter.class, result)); - } - } - - }); - - return result; - - } - - @Override - public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, - UserStorageProviderModel model) { - return this.sync(sessionFactory, realmId, model); - } - -} diff --git a/src/main/resources/META-INF/scim-resource-changelog.xml b/src/main/resources/META-INF/scim-resource-changelog.xml index 45be732f89..d3e2687a57 100644 --- a/src/main/resources/META-INF/scim-resource-changelog.xml +++ b/src/main/resources/META-INF/scim-resource-changelog.xml @@ -1,28 +1,35 @@ - + - + - + - + - + - + - + - - - + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory index 255bddacd6..308796c862 100644 --- a/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory +++ b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -1 +1 @@ -sh.libre.scim.storage.ScimStorageProviderFactory +sh.libre.scim.core.ScimEndpointConfigurationStorageProviderFactory