Skip to content
Snippets Groups Projects
Commit 57f7c6c7 authored by Alex Morel's avatar Alex Morel
Browse files

Merge branch 'sonar-refactoring' into 'main'

Sonar refactoring

See merge request !3
parents 19e23a44 e8177237
No related branches found
No related tags found
1 merge request!3Sonar refactoring
Pipeline #1988 passed with stage
in 33 seconds
Showing
with 580 additions and 917 deletions
package: package:
image: image:
name: gradle:jdk11 name: gradle:jdk17
script: script:
- gradle jar shadowjar - gradle jar shadowjar
- gradle -b legacy-build.gradle shadowjar
artifacts: artifacts:
paths: paths:
- build/libs/keycloak-scim-1.0-SNAPSHOT.jar - 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.jar
- build/libs/keycloak-scim-1.0-SNAPSHOT-all-legacy.jar - build/libs/keycloak-scim-1.0-SNAPSHOT-all-legacy.jar
only:
- main
# keycloak-scim-client # 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.
## Overview It allows to :
### Motivation * 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).
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. See [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643)
and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644)) for further details
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. ## Overview
### Technical choices ### Motivation
The SCIM protocol is standard, comprehensible and easy to implement. It's a perfect fit for our goal. 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 chose to build application extensions/plugins because it's easier to deploy and thus will benefit to a larger portion of the FOSS community. This will allow users to collaborate seamlessly across the platform without requiring every user to have connected once
to each application. This will also ease GDRP compliance because deleting a user in Keycloak will delete the user from
every app. The SCIM protocol is standard, comprehensible and easy to implement. It's a perfect fit for our goal.
We chose to build application extensions/plugins because it's easier to deploy and thus will benefit to a larger portion
of the FOSS community.
#### Keycloak specific #### Keycloak specific
This extension uses 3 concepts in KC : This extension uses 3 concepts in KeyCloack :
- 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.
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 ## Usage
### Installation (quick) ### Development mode
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``
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) ### 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/`. 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). 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. ...@@ -38,7 +62,7 @@ Other [installation options](/docs/installation.md) are available.
### Setup ### Setup
#### Add the event listerner #### Enable SCIM Event listeners
1. Go to `Admin Console > Events > Config`. 1. Go to `Admin Console > Events > Config`.
2. Add `scim` in `Event Listeners`. 2. Add `scim` in `Event Listeners`.
...@@ -46,37 +70,35 @@ Other [installation options](/docs/installation.md) are available. ...@@ -46,37 +70,35 @@ Other [installation options](/docs/installation.md) are available.
![Event listener page](/docs/img/event-listener-page.png) ![Event listener page](/docs/img/event-listener-page.png)
#### Create a federation provider #### Register SCIM Service Providers
1. Go to `Admin Console > User Federation`. 1. Go to `Admin Console > Realm Settings > Events`.
2. Click on `Add provider`. 2. Add `scim` to the list of event listers
3. Select `scim`. 3. Save
4. Configure the provider ([see](#configuration)).
5. Save.
![Federation provider page](/docs/img/federation-provider-page.png) ![Federation provider page](/docs/img/federation-provider-page.png)
### Configuration ### 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/) Add the endpoint - for a local set up you have to add the two containers in a docker network and use the container ip
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. 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. Endpoint content type is application/json.
Auth mode Bearer or None for local test setup. Auth mode Bearer or None for local test setup.
Copy the bearer token from your app details in rocketchat. 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: If you enable import during sync then you can choose between to following import actions:
- Create Local - adds users to keycloak - Create Local - adds users to keycloak
- Nothing - Nothing
- Delete Remote - deletes users from the remote application - Delete Remote - deletes users from the remote application
### Sync ### 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 Full Sync
- Periodic Changed User Sync - Periodic Changed User Sync
**[License AGPL](/LICENSE)** **[License AGPL](/LICENSE)**
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
plugins { plugins {
id 'java' 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' group = 'sh.libre.scim'
version = '1.0-SNAPSHOT' version = '1.0-SNAPSHOT'
description = 'keycloak-scim' description = 'keycloak-scim'
java.sourceCompatibility = JavaVersion.VERSION_11 java.sourceCompatibility = JavaVersion.VERSION_17
repositories { repositories {
mavenLocal() mavenLocal()
...@@ -15,26 +17,12 @@ repositories { ...@@ -15,26 +17,12 @@ repositories {
} }
dependencies { dependencies {
compileOnly 'org.keycloak:keycloak-core:18.0.0' compileOnly 'org.keycloak:keycloak-core:26.0.1'
compileOnly 'org.keycloak:keycloak-server-spi:18.0.0' compileOnly 'org.keycloak:keycloak-server-spi:26.0.1'
compileOnly 'org.keycloak:keycloak-server-spi-private:18.0.0' compileOnly 'org.keycloak:keycloak-server-spi-private:26.0.1'
compileOnly 'org.keycloak:keycloak-services:18.0.0' compileOnly 'org.keycloak:keycloak-services:26.0.1'
compileOnly 'org.keycloak:keycloak-model-jpa:18.0.0' compileOnly 'org.keycloak:keycloak-model-jpa:26.0.1'
implementation 'io.github.resilience4j:resilience4j-retry:1.7.1' implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'
implementation('com.unboundid.product.scim2:scim2-sdk-client:2.3.7') { implementation 'de.captaingoldfish:scim-sdk-common:1.26.0'
transitive false implementation 'de.captaingoldfish:scim-sdk-client:1.26.0'
}
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
}
} }
...@@ -11,7 +11,7 @@ services: ...@@ -11,7 +11,7 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
keycloak: keycloak:
image: quay.io/keycloak/keycloak:18.0.0 image: quay.io/keycloak/keycloak:26.0.1
build: . build: .
command: start-dev command: start-dev
volumes: volumes:
...@@ -23,6 +23,7 @@ services: ...@@ -23,6 +23,7 @@ services:
KC_DB_PASSWORD: keycloak KC_DB_PASSWORD: keycloak
KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin KEYCLOAK_ADMIN_PASSWORD: admin
KC_LOG_LEVEL: INFO,sh.libre.scim:debug,de.captaingoldfish.scim:debug
ports: ports:
- 127.0.0.1:8080:8080 - 127.0.0.1:8080:8080
depends_on: depends_on:
......
docs/img/event-listener-page.png

105 KiB | W: | H:

docs/img/event-listener-page.png

81.2 KiB | W: | H:

docs/img/event-listener-page.png
docs/img/event-listener-page.png
docs/img/event-listener-page.png
docs/img/event-listener-page.png
  • 2-up
  • Swipe
  • Onion skin
docs/img/federation-provider-page.png

151 KiB | W: | H:

docs/img/federation-provider-page.png

104 KiB | W: | H:

docs/img/federation-provider-page.png
docs/img/federation-provider-page.png
docs/img/federation-provider-page.png
docs/img/federation-provider-page.png
  • 2-up
  • Swipe
  • Onion skin
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')
}
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<M extends RoleMapperModel, S extends com.unboundid.scim2.common.ScimResource> {
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<ScimResource> query(String query, String id) {
return query(query, id, type);
}
public TypedQuery<ScimResource> 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<S> getResourceClass();
public abstract S toSCIM(Boolean addMeta);
public abstract Boolean entityExists();
public abstract Boolean tryToMap();
public abstract void createEntity() throws Exception;
public abstract Stream<M> getResourceStream();
public abstract Boolean skipRefresh();
}
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);
}
}
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);
}
}
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<GroupModel, GroupResource> {
private String displayName;
private Set<String> members = new HashSet<String>();
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<GroupResource> 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<String>();
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<Member>();
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<GroupModel> getResourceStream() {
return this.session.groups().getGroupsStream(this.session.getContext().getRealm());
}
@Override
public Boolean skipRefresh() {
return false;
}
}
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 <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> A getAdapter(
Class<A> aClass) {
try {
return aClass.getDeclaredConstructor(KeycloakSession.class, String.class)
.newInstance(session, this.model.getId());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void create(Class<A> 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 <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void replace(Class<A> 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 <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void delete(Class<A> 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 <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void refreshResources(
Class<A> 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 <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void importResources(
Class<A> 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 <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void sync(Class<A> 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();
}
}
package sh.libre.scim.core; package sh.libre.scim.core;
import java.util.function.Consumer;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession; 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 class ScimDispatcher {
public static final String SCOPE_USER = "user";
public static final String SCOPE_GROUP = "group";
final private KeycloakSession session; private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class);
final private 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<UserScimService> userScimServices = new ArrayList<>();
private final List<GroupScimService> groupScimServices = new ArrayList<>();
public ScimDispatcher(KeycloakSession session) { public ScimDispatcher(KeycloakSession session) {
this.session = 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<ScimClient> 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() session.getContext().getRealm().getComponentsStream()
.filter((m) -> { .filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId())
return ScimStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true) && m.get("enabled", true))
&& m.get("propagation-" + scope, false); .forEach(scimEndpointConfigurationRaw -> {
}) try {
.forEach(m -> runOne(m, f)); ScrimEndPointConfiguration scrimEndPointConfiguration = new ScrimEndPointConfiguration(scimEndpointConfigurationRaw);
}
// Step 3 : create scim clients for each endpoint
public void runOne(ComponentModel m, Consumer<ScimClient> f) { if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType()); GroupScimService groupScimService = new GroupScimService(session, scrimEndPointConfiguration, skipOrStopStrategy);
var client = new ScimClient(m, session); groupScimServices.add(groupScimService);
try { }
f.accept(client); if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
} catch (Exception e) { UserScimService userScimService = new UserScimService(session, scrimEndPointConfiguration, skipOrStopStrategy);
LOGGER.error(e); userScimServices.add(userScimService);
} finally { }
client.close(); } catch (IllegalArgumentException e) {
if (skipOrStopStrategy.allowInvalidEndpointConfiguration()) {
LOGGER.warn("[SCIM] Invalid Endpoint configuration " + scimEndpointConfigurationRaw.getId(), e);
} else {
throw e;
}
}
});
}
public void dispatchUserModificationToAll(SCIMPropagationConsumer<UserScimService> operationToDispatch) {
initializeClientsIfNeeded();
Set<UserScimService> servicesCorrectlyPropagated = new LinkedHashSet<>();
userScimServices.forEach(userScimService -> {
try {
operationToDispatch.acceptThrows(userScimService);
servicesCorrectlyPropagated.add(userScimService);
} catch (ScimPropagationException e) {
exceptionHandler.handleException(userScimService.getConfiguration(), e);
}
});
// TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints
LOGGER.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size());
}
public void dispatchGroupModificationToAll(SCIMPropagationConsumer<GroupScimService> operationToDispatch) {
initializeClientsIfNeeded();
Set<GroupScimService> servicesCorrectlyPropagated = new LinkedHashSet<>();
groupScimServices.forEach(groupScimService -> {
try {
operationToDispatch.acceptThrows(groupScimService);
servicesCorrectlyPropagated.add(groupScimService);
} catch (ScimPropagationException e) {
exceptionHandler.handleException(groupScimService.getConfiguration(), e);
}
});
// TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints
LOGGER.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size());
}
public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer<UserScimService> operationToDispatch) {
initializeClientsIfNeeded();
// Scim client should already have been created
Optional<UserScimService> matchingClient = userScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
if (matchingClient.isPresent()) {
try {
operationToDispatch.acceptThrows(matchingClient.get());
LOGGER.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName());
} catch (ScimPropagationException e) {
exceptionHandler.handleException(matchingClient.get().getConfiguration(), e);
}
} else {
LOGGER.error("[SCIM] Could not find a Scim Client matching User endpoint configuration" + scimServerConfiguration.getId());
} }
} }
public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer<GroupScimService> operationToDispatch) {
initializeClientsIfNeeded();
// Scim client should already have been created
Optional<GroupScimService> matchingClient = groupScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
if (matchingClient.isPresent()) {
try {
operationToDispatch.acceptThrows(matchingClient.get());
LOGGER.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName());
} catch (ScimPropagationException e) {
exceptionHandler.handleException(matchingClient.get().getConfiguration(), e);
}
} else {
LOGGER.error("[SCIM] Could not find a Scim Client matching Group endpoint configuration" + scimServerConfiguration.getId());
}
}
public void close() {
for (GroupScimService c : groupScimServices) {
c.close();
}
for (UserScimService c : userScimServices) {
c.close();
}
groupScimServices.clear();
userScimServices.clear();
}
private void initializeClientsIfNeeded() {
if (!clientsInitialized) {
clientsInitialized = true;
refreshActiveScimEndpoints();
}
}
/**
* A Consumer that throws ScimPropagationException.
*
* @param <T> An {@link AbstractScimService to call}
*/
@FunctionalInterface
public interface SCIMPropagationConsumer<T> {
void acceptThrows(T elem) throws ScimPropagationException;
}
} }
package sh.libre.scim.storage; package sh.libre.scim.core;
import java.util.Date;
import java.util.List;
import javax.ws.rs.core.MediaType;
import com.unboundid.scim2.client.ScimService;
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.jboss.logging.Logger;
import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.KeycloakSessionTask; import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder; import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageProviderFactory; import org.keycloak.storage.UserStorageProviderFactory;
import org.keycloak.storage.UserStorageProviderModel; import org.keycloak.storage.UserStorageProviderModel;
import org.keycloak.storage.user.ImportSynchronization; import org.keycloak.storage.user.ImportSynchronization;
import org.keycloak.storage.user.SynchronizationResult; 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<ScimEndpointConfigurationStorageProviderFactory.ScimEndpointConfigurationStorageProvider>, ImportSynchronization {
public static final String ID = "scim";
private static final Logger LOGGER = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class);
@Override
public String getId() {
return ID;
}
@Override
public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId,
UserStorageProviderModel model) {
// Manually Launch a synchronization between keycloack and the SCIM endpoint described in the given model
LOGGER.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getName());
SynchronizationResult result = new SynchronizationResult();
KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> {
RealmModel realm = session.realms().getRealm(realmId);
session.getContext().setRealm(realm);
ScimDispatcher dispatcher = new ScimDispatcher(session);
if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER))) {
dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result));
}
if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP))) {
dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result));
}
dispatcher.close();
});
return result;
}
@Override
public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId,
UserStorageProviderModel model) {
return this.sync(sessionFactory, realmId, model);
}
import sh.libre.scim.core.GroupAdapter; @Override
import sh.libre.scim.core.ScimDispatcher; public void postInit(KeycloakSessionFactory factory) {
import sh.libre.scim.core.UserAdapter; ScimBackgroundGroupMembershipUpdater scimBackgroundGroupMembershipUpdater = new ScimBackgroundGroupMembershipUpdater(factory);
scimBackgroundGroupMembershipUpdater.startBackgroundUpdates();
}
public class ScimStorageProviderFactory @Override
implements UserStorageProviderFactory<ScimStorageProvider>, ImportSynchronization { public List<ProviderConfigProperty> getConfigProperties() {
final private Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class); // These Config Properties will be use to generate configuration page in Admin Console
public final static String ID = "scim"; return ProviderConfigurationBuilder.create()
protected static final List<ProviderConfigProperty> configMetadata;
static {
configMetadata = ProviderConfigurationBuilder.create()
.property() .property()
.name("endpoint") .name(ScrimEndPointConfiguration.CONF_KEY_ENDPOINT)
.type(ProviderConfigProperty.STRING_TYPE) .type(ProviderConfigProperty.STRING_TYPE)
.required(true)
.label("SCIM 2.0 endpoint") .label("SCIM 2.0 endpoint")
.helpText("External SCIM 2.0 base " + .helpText("External SCIM 2.0 base " +
"URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)") "URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)")
.add() .add()
.property() .property()
.name("content-type") .name(ScrimEndPointConfiguration.CONF_KEY_CONTENT_TYPE)
.type(ProviderConfigProperty.LIST_TYPE) .type(ProviderConfigProperty.LIST_TYPE)
.label("Endpoint content type") .label("Endpoint content type")
.helpText("Only used when endpoint doesn't support application/scim+json") .helpText("Only used when endpoint doesn't support application/scim+json")
.options(MediaType.APPLICATION_JSON.toString(), ScimService.MEDIA_TYPE_SCIM_TYPE.toString()) .options(MediaType.APPLICATION_JSON, HttpHeader.SCIM_CONTENT_TYPE)
.defaultValue(ScimService.MEDIA_TYPE_SCIM_TYPE.toString()) .defaultValue(HttpHeader.SCIM_CONTENT_TYPE)
.add() .add()
.property() .property()
.name("auth-mode") .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_MODE)
.type(ProviderConfigProperty.LIST_TYPE) .type(ProviderConfigProperty.LIST_TYPE)
.label("Auth mode") .label("Auth mode")
.helpText("Select the authorization mode") .helpText("Select the authorization mode")
...@@ -55,96 +97,69 @@ public class ScimStorageProviderFactory ...@@ -55,96 +97,69 @@ public class ScimStorageProviderFactory
.defaultValue("NONE") .defaultValue("NONE")
.add() .add()
.property() .property()
.name("auth-user") .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_USER)
.type(ProviderConfigProperty.STRING_TYPE) .type(ProviderConfigProperty.STRING_TYPE)
.label("Auth username") .label("Auth username")
.helpText("Required for basic authentification.") .helpText("Required for basic authentication.")
.add() .add()
.property() .property()
.name("auth-pass") .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_PASSWORD)
.type(ProviderConfigProperty.PASSWORD) .type(ProviderConfigProperty.PASSWORD)
.label("Auth password/token") .label("Auth password/token")
.helpText("Password or token required for basic or bearer authentification.") .helpText("Password or token required for basic or bearer authentication.")
.add() .add()
.property() .property()
.name("propagation-user") .name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER)
.type(ProviderConfigProperty.BOOLEAN_TYPE) .type(ProviderConfigProperty.BOOLEAN_TYPE)
.label("Enable user propagation") .label("Enable user propagation")
.defaultValue("true") .helpText("Should operation on users be propagated to this provider?")
.defaultValue(BooleanUtils.TRUE)
.add() .add()
.property() .property()
.name("propagation-group") .name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP)
.type(ProviderConfigProperty.BOOLEAN_TYPE) .type(ProviderConfigProperty.BOOLEAN_TYPE)
.label("Enable group propagation") .label("Enable group propagation")
.defaultValue("true") .helpText("Should operation on groups be propagated to this provider?")
.defaultValue(BooleanUtils.TRUE)
.add() .add()
.property() .property()
.name("sync-import") .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT)
.type(ProviderConfigProperty.BOOLEAN_TYPE) .type(ProviderConfigProperty.BOOLEAN_TYPE)
.label("Enable import during sync") .label("Enable import during sync")
.add() .add()
.property() .property()
.name("sync-import-action") .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT_ACTION)
.type(ProviderConfigProperty.LIST_TYPE) .type(ProviderConfigProperty.LIST_TYPE)
.label("Import action") .label("Import action")
.helpText("What to do when the user don\'t exists in Keycloak.") .helpText("What to do when the user doesn't exists in Keycloak.")
.options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE") .options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE")
.defaultValue("CREATE_LOCAL") .defaultValue("CREATE_LOCAL")
.add() .add()
.property() .property()
.name("sync-refresh") .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_REFRESH)
.type(ProviderConfigProperty.BOOLEAN_TYPE) .type(ProviderConfigProperty.BOOLEAN_TYPE)
.label("Enable refresh during sync") .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() .add()
.build(); .build();
} }
@Override
public ScimStorageProvider create(KeycloakSession session, ComponentModel model) {
LOGGER.info("create");
return new ScimStorageProvider();
}
@Override
public String getId() {
return ID;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configMetadata;
}
@Override @Override
public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, public ScimEndpointConfigurationStorageProvider create(KeycloakSession session, ComponentModel model) {
UserStorageProviderModel model) { return new ScimEndpointConfigurationStorageProvider();
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, * Empty implementation : we used this {@link ScimEndpointConfigurationStorageProviderFactory} to generate Admin Console page.
UserStorageProviderModel model) { */
return this.sync(sessionFactory, realmId, model); public static final class ScimEndpointConfigurationStorageProvider implements UserStorageProvider {
@Override
public void close() {
// Nothing to close here
}
} }
} }
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
}
}
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<UserModel, UserResource> {
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<UserResource> 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<String>();
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<Email>();
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<Role> roles = new ArrayList<Role>();
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<UserModel> getResourceStream() {
return this.session.users().getUsersStream(this.session.getContext().getRealm());
}
@Override
public Boolean skipRefresh() {
return getUsername().equals("admin");
}
}
package sh.libre.scim.core.exceptions;
public class InconsistentScimMappingException extends ScimPropagationException {
public InconsistentScimMappingException(String message) {
super(message);
}
}
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<ServerResponse> response;
public InvalidResponseFromScimEndpointException(ServerResponse response, String message) {
super(message);
this.response = Optional.of(response);
}
public InvalidResponseFromScimEndpointException(String message, Exception e) {
super(message, e);
this.response = Optional.empty();
}
/**
* Empty response can occur if a major exception was thrown while retrying the request.
*/
public Optional<ServerResponse> getResponse() {
return response;
}
}
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<Integer> acceptableStatus = Lists.newArrayList(200, 204, 404);
return !acceptableStatus.contains(r.getHttpStatus());
}).orElse(
// Never got an answer, server was either misconfigured or unreachable
// No rollback in that case.
false
);
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment