Merge branch 'sonar-refactoring' into 'main'
Sonar refactoring See merge request libre.sh/scim/keycloak-scim!3
This commit is contained in:
commit
57f7c6c74e
47 changed files with 2132 additions and 1266 deletions
|
@ -1,13 +1,10 @@
|
||||||
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
|
|
||||||
|
|
80
README.md
80
README.md
|
@ -1,35 +1,59 @@
|
||||||
# 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.
|
||||||
|
|
||||||
|
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
|
## Overview
|
||||||
|
|
||||||
### Motivation
|
### 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
|
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.
|
||||||
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
|
||||||
|
|
||||||
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/`.
|
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.
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
![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)**
|
||||||
|
|
4
auto.sh
Executable file
4
auto.sh
Executable file
|
@ -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
|
36
build.gradle
36
build.gradle
|
@ -1,13 +1,15 @@
|
||||||
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 {
|
||||||
}
|
}
|
||||||
|
|
||||||
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:
|
||||||
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:
|
||||||
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:
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 81 KiB |
Binary file not shown.
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 104 KiB |
|
@ -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')
|
|
||||||
}
|
|
|
@ -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<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();
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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 <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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,41 +1,171 @@
|
||||||
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
|
||||||
|
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<ScimClient> f) {
|
public void dispatchUserModificationToAll(SCIMPropagationConsumer<UserScimService> operationToDispatch) {
|
||||||
LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType());
|
initializeClientsIfNeeded();
|
||||||
var client = new ScimClient(m, session);
|
Set<UserScimService> servicesCorrectlyPropagated = new LinkedHashSet<>();
|
||||||
try {
|
userScimServices.forEach(userScimService -> {
|
||||||
f.accept(client);
|
try {
|
||||||
} catch (Exception e) {
|
operationToDispatch.acceptThrows(userScimService);
|
||||||
LOGGER.error(e);
|
servicesCorrectlyPropagated.add(userScimService);
|
||||||
} finally {
|
} catch (ScimPropagationException e) {
|
||||||
client.close();
|
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;
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postInit(KeycloakSessionFactory factory) {
|
||||||
|
ScimBackgroundGroupMembershipUpdater scimBackgroundGroupMembershipUpdater = new ScimBackgroundGroupMembershipUpdater(factory);
|
||||||
|
scimBackgroundGroupMembershipUpdater.startBackgroundUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProviderConfigProperty> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
101
src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java
Normal file
101
src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<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");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
package sh.libre.scim.core.exceptions;
|
||||||
|
|
||||||
|
public class InconsistentScimMappingException extends ScimPropagationException {
|
||||||
|
public InconsistentScimMappingException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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<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,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);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package sh.libre.scim.core.exceptions;
|
||||||
|
|
||||||
|
public class UnexpectedScimDataException extends ScimPropagationException {
|
||||||
|
public UnexpectedScimDataException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 <K> The Keycloack Model (e.g. UserModel, GroupModel)
|
||||||
|
* @param <S> The SCIM Resource (e.g. User, Group)
|
||||||
|
*/
|
||||||
|
public abstract class AbstractScimService<K extends RoleMapperModel, S extends ResourceNode> 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<S> 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<K> resourcesStream = getResourceStream()) {
|
||||||
|
Set<K> 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<KeycloakId> 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<ScimResourceMapping> 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<ScimResourceMapping> findMappingById(KeycloakId keycloakId) {
|
||||||
|
return getScimResourceDao().findById(keycloakId, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeycloakSession getKeycloakSession() {
|
||||||
|
return keycloakSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected abstract boolean shouldIgnoreForScimSynchronization(K resource);
|
||||||
|
|
||||||
|
protected abstract Stream<K> getResourceStream();
|
||||||
|
|
||||||
|
protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException, InconsistentScimMappingException;
|
||||||
|
|
||||||
|
protected abstract Optional<KeycloakId> 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package sh.libre.scim.core.service;
|
||||||
|
|
||||||
|
public record EntityOnRemoteScimId(
|
||||||
|
String asString
|
||||||
|
) {
|
||||||
|
}
|
131
src/main/java/sh/libre/scim/core/service/GroupScimService.java
Normal file
131
src/main/java/sh/libre/scim/core/service/GroupScimService.java
Normal file
|
@ -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<GroupModel, Group> {
|
||||||
|
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<GroupModel> getResourceStream() {
|
||||||
|
return getKeycloakDao().getGroupsStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean entityExists(KeycloakId keycloakId) {
|
||||||
|
return getKeycloakDao().groupExists(keycloakId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Optional<KeycloakId> matchKeycloakMappingByScimProperties(Group resource) {
|
||||||
|
Set<String> names = new TreeSet<>();
|
||||||
|
resource.getId().ifPresent(names::add);
|
||||||
|
resource.getDisplayName().ifPresent(names::add);
|
||||||
|
try (Stream<GroupModel> groupsStream = getKeycloakDao().getGroupsStream()) {
|
||||||
|
Optional<GroupModel> 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<Member> 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<KeycloakId> 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<ScimResourceMapping> 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;
|
||||||
|
}
|
||||||
|
}
|
81
src/main/java/sh/libre/scim/core/service/KeycloakDao.java
Normal file
81
src/main/java/sh/libre/scim/core/service/KeycloakDao.java
Normal file
|
@ -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<GroupModel> getGroupsStream() {
|
||||||
|
return getKeycloakSession().groups().getGroupsStream(getRealm());
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupModel createGroup(String displayName) {
|
||||||
|
return getKeycloakSession().groups().createGroup(getRealm(), displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<KeycloakId> getGroupMembers(GroupModel groupModel) {
|
||||||
|
return getKeycloakSession().users()
|
||||||
|
.getGroupMembersStream(getRealm(), groupModel)
|
||||||
|
.map(UserModel::getId)
|
||||||
|
.map(KeycloakId::new)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stream<UserModel> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
7
src/main/java/sh/libre/scim/core/service/KeycloakId.java
Normal file
7
src/main/java/sh/libre/scim/core/service/KeycloakId.java
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package sh.libre.scim.core.service;
|
||||||
|
|
||||||
|
public record KeycloakId(
|
||||||
|
String asString
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
155
src/main/java/sh/libre/scim/core/service/ScimClient.java
Normal file
155
src/main/java/sh/libre/scim/core/service/ScimClient.java
Normal file
|
@ -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<S extends ResourceNode> 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 <T extends ResourceNode> ScimClient<T> open(ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) {
|
||||||
|
String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint();
|
||||||
|
Map<String, String> 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<String> 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<S> 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<S> 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<S> 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<S> 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<S> 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<S> listResources() {
|
||||||
|
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get().sendRequest();
|
||||||
|
ListResponse<S> resourceTypeListResponse = response.getResource();
|
||||||
|
return resourceTypeListResponse.getListedResources();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<? extends ResourceNode> resourceClass;
|
||||||
|
|
||||||
|
ScimResourceType(String endpoint, Class<? extends ResourceNode> resourceClass) {
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.resourceClass = resourceClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEndpoint() {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T extends ResourceNode> Class<T> getResourceClass() {
|
||||||
|
return (Class<T>) resourceClass;
|
||||||
|
}
|
||||||
|
}
|
145
src/main/java/sh/libre/scim/core/service/UserScimService.java
Normal file
145
src/main/java/sh/libre/scim/core/service/UserScimService.java
Normal file
|
@ -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<UserModel, User> {
|
||||||
|
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<UserModel> getResourceStream() {
|
||||||
|
return getKeycloakDao().getUsersStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean entityExists(KeycloakId keycloakId) {
|
||||||
|
return getKeycloakDao().userExists(keycloakId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Optional<KeycloakId> matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimMappingException {
|
||||||
|
Optional<KeycloakId> matchedByUsername = resource.getUserName()
|
||||||
|
.map(getKeycloakDao()::getUserByUsername)
|
||||||
|
.map(this::getId);
|
||||||
|
Optional<KeycloakId> 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<RoleModel> groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream);
|
||||||
|
Stream<RoleModel> roleModels = roleMapperModel.getRoleMappingsStream();
|
||||||
|
Stream<RoleModel> allRoleModels = Stream.concat(groupRoleModels, roleModels);
|
||||||
|
List<PersonRole> 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<Email> 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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,7 @@
|
||||||
package sh.libre.scim.event;
|
package sh.libre.scim.event;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.regex.*;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.component.ComponentModel;
|
||||||
import org.keycloak.events.Event;
|
import org.keycloak.events.Event;
|
||||||
import org.keycloak.events.EventListenerProvider;
|
import org.keycloak.events.EventListenerProvider;
|
||||||
import org.keycloak.events.EventType;
|
import org.keycloak.events.EventType;
|
||||||
|
@ -13,117 +11,237 @@ import org.keycloak.events.admin.ResourceType;
|
||||||
import org.keycloak.models.GroupModel;
|
import org.keycloak.models.GroupModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
|
|
||||||
import sh.libre.scim.core.GroupAdapter;
|
|
||||||
import sh.libre.scim.core.ScimDispatcher;
|
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 {
|
public class ScimEventListenerProvider implements EventListenerProvider {
|
||||||
final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class);
|
|
||||||
ScimDispatcher dispatcher;
|
private static final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class);
|
||||||
KeycloakSession session;
|
|
||||||
HashMap<ResourceType, Pattern> patterns = new HashMap<ResourceType, Pattern>();
|
private final ScimDispatcher dispatcher;
|
||||||
|
|
||||||
|
private final KeycloakSession session;
|
||||||
|
|
||||||
|
private final KeycloakDao keycloakDao;
|
||||||
|
|
||||||
|
private final Map<ResourceType, Pattern> 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) {
|
public ScimEventListenerProvider(KeycloakSession session) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
dispatcher = new ScimDispatcher(session);
|
this.keycloakDao = new KeycloakDao(session);
|
||||||
patterns.put(ResourceType.USER, Pattern.compile("users/(.+)"));
|
this.dispatcher = new ScimDispatcher(session);
|
||||||
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() {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEvent(Event event) {
|
public void onEvent(Event event) {
|
||||||
if (event.getType() == EventType.REGISTER) {
|
// React to User-related event : creation, deletion, update
|
||||||
var user = getUser(event.getUserId());
|
EventType eventType = event.getType();
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user));
|
KeycloakId eventUserId = new KeycloakId(event.getUserId());
|
||||||
}
|
switch (eventType) {
|
||||||
if (event.getType() == EventType.UPDATE_EMAIL || event.getType() == EventType.UPDATE_PROFILE) {
|
case REGISTER -> {
|
||||||
var user = getUser(event.getUserId());
|
LOGGER.infof("[SCIM] Propagate User Registration - %s", eventUserId);
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user));
|
UserModel user = getUser(eventUserId);
|
||||||
}
|
dispatcher.dispatchUserModificationToAll(client -> client.create(user));
|
||||||
if (event.getType() == EventType.DELETE_ACCOUNT) {
|
}
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, event.getUserId()));
|
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
|
@Override
|
||||||
public void onEvent(AdminEvent event, boolean includeRepresentation) {
|
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)
|
if (pattern == null)
|
||||||
return;
|
return;
|
||||||
var matcher = pattern.matcher(event.getResourcePath());
|
Matcher matcher = pattern.matcher(event.getResourcePath());
|
||||||
if (!matcher.find())
|
if (!matcher.find())
|
||||||
return;
|
return;
|
||||||
if (event.getResourceType() == ResourceType.USER) {
|
|
||||||
var userId = matcher.group(1);
|
|
||||||
LOGGER.infof("%s %s", userId, event.getOperationType());
|
// Step 2: propagate event (if needed) according to its resource type
|
||||||
if (event.getOperationType() == OperationType.CREATE) {
|
switch (event.getResourceType()) {
|
||||||
var user = getUser(userId);
|
case USER -> {
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user));
|
KeycloakId userId = new KeycloakId(matcher.group(1));
|
||||||
user.getGroupsStream().forEach(group -> {
|
handleUserEvent(event, userId);
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (event.getOperationType() == OperationType.UPDATE) {
|
case GROUP -> {
|
||||||
var user = getUser(userId);
|
KeycloakId groupId = new KeycloakId(matcher.group(1));
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user));
|
handleGroupEvent(event, groupId);
|
||||||
}
|
}
|
||||||
if (event.getOperationType() == OperationType.DELETE) {
|
case GROUP_MEMBERSHIP -> {
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, userId));
|
KeycloakId userId = new KeycloakId(matcher.group(1));
|
||||||
|
KeycloakId groupId = new KeycloakId(matcher.group(2));
|
||||||
|
handleGroupMemberShipEvent(event, userId, groupId);
|
||||||
}
|
}
|
||||||
}
|
case REALM_ROLE_MAPPING -> {
|
||||||
if (event.getResourceType() == ResourceType.GROUP) {
|
String rawResourceType = matcher.group(1);
|
||||||
var groupId = matcher.group(1);
|
ScimResourceType type = switch (rawResourceType) {
|
||||||
LOGGER.infof("group %s %s", groupId, event.getOperationType());
|
case "users" -> ScimResourceType.USER;
|
||||||
if (event.getOperationType() == OperationType.CREATE) {
|
case "groups" -> ScimResourceType.GROUP;
|
||||||
var group = getGroup(groupId);
|
default -> throw new IllegalArgumentException("Unsupported resource type: " + rawResourceType);
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.create(GroupAdapter.class, group));
|
};
|
||||||
|
KeycloakId id = new KeycloakId(matcher.group(2));
|
||||||
|
handleRoleMappingEvent(event, type, id);
|
||||||
}
|
}
|
||||||
if (event.getOperationType() == OperationType.UPDATE) {
|
case COMPONENT -> {
|
||||||
var group = getGroup(groupId);
|
String id = matcher.group(1);
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group));
|
handleScimEndpointConfigurationEvent(event, id);
|
||||||
|
|
||||||
}
|
}
|
||||||
if (event.getOperationType() == OperationType.DELETE) {
|
default -> {
|
||||||
dispatcher.run(ScimDispatcher.SCOPE_GROUP,
|
// No other resource modification has to be propagated to Scim endpoints
|
||||||
(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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<ComponentModel> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,20 +13,24 @@ public class ScimEventListenerProviderFactory implements EventListenerProviderFa
|
||||||
return new ScimEventListenerProvider(session);
|
return new ScimEventListenerProvider(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getId() {
|
||||||
|
return "scim";
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void init(Scope config) {
|
public void init(Scope config) {
|
||||||
|
// Nothing to initialize
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void postInit(KeycloakSessionFactory factory) {
|
public void postInit(KeycloakSessionFactory factory) {
|
||||||
|
// Nothing to initialize
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
|
// Nothing to close
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getId() {
|
|
||||||
return "scim";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
96
src/main/java/sh/libre/scim/jpa/ScimResourceDao.java
Normal file
96
src/main/java/sh/libre/scim/jpa/ScimResourceDao.java
Normal file
|
@ -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<ScimResourceMapping> 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<ScimResourceMapping> findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) {
|
||||||
|
try {
|
||||||
|
return Optional.of(
|
||||||
|
getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult()
|
||||||
|
);
|
||||||
|
} catch (NoResultException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ScimResourceMapping> findById(KeycloakId keycloakId, ScimResourceType type) {
|
||||||
|
try {
|
||||||
|
return Optional.of(
|
||||||
|
getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult()
|
||||||
|
);
|
||||||
|
} catch (NoResultException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ScimResourceMapping> findUserById(KeycloakId id) {
|
||||||
|
return findById(id, ScimResourceType.USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ScimResourceMapping> findUserByExternalId(EntityOnRemoteScimId externalId) {
|
||||||
|
return findByExternalId(externalId, ScimResourceType.USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(ScimResourceMapping resource) {
|
||||||
|
entityManager.remove(resource);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
package sh.libre.scim.jpa;
|
package sh.libre.scim.jpa;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@ -65,14 +67,13 @@ public class ScimResourceId implements Serializable {
|
||||||
public boolean equals(Object other) {
|
public boolean equals(Object other) {
|
||||||
if (this == other)
|
if (this == other)
|
||||||
return true;
|
return true;
|
||||||
if (!(other instanceof ScimResourceId))
|
if (!(other instanceof ScimResourceId o))
|
||||||
return false;
|
return false;
|
||||||
var o = (ScimResourceId) other;
|
return (StringUtils.equals(o.id, id) &&
|
||||||
return (o.id == id &&
|
StringUtils.equals(o.realmId, realmId) &&
|
||||||
o.realmId == realmId &&
|
StringUtils.equals(o.componentId, componentId) &&
|
||||||
o.componentId == componentId &&
|
StringUtils.equals(o.type, type) &&
|
||||||
o.type == type &&
|
StringUtils.equals(o.externalId, externalId));
|
||||||
o.externalId == externalId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
89
src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java
Normal file
89
src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,16 +1,15 @@
|
||||||
package sh.libre.scim.jpa;
|
package sh.libre.scim.jpa;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
|
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class ScimResourceProvider implements JpaEntityProvider {
|
public class ScimResourceProvider implements JpaEntityProvider {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Class<?>> getEntities() {
|
public List<Class<?>> getEntities() {
|
||||||
return Collections.singletonList(ScimResource.class);
|
return Collections.singletonList(ScimResourceMapping.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -20,6 +19,7 @@ public class ScimResourceProvider implements JpaEntityProvider {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
|
// Nothing to close
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -7,10 +7,8 @@ import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.KeycloakSessionFactory;
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
|
|
||||||
public class ScimResourceProviderFactory implements JpaEntityProviderFactory {
|
public class ScimResourceProviderFactory implements JpaEntityProviderFactory {
|
||||||
final static String ID ="scim-resource";
|
|
||||||
@Override
|
static final String ID = "scim-resource";
|
||||||
public void close() {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JpaEntityProvider create(KeycloakSession session) {
|
public JpaEntityProvider create(KeycloakSession session) {
|
||||||
|
@ -24,9 +22,18 @@ public class ScimResourceProviderFactory implements JpaEntityProviderFactory {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void init(Scope scope) {
|
public void init(Scope scope) {
|
||||||
|
// Nothing to initialise
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void postInit(KeycloakSessionFactory sessionFactory) {
|
public void postInit(KeycloakSessionFactory sessionFactory) {
|
||||||
|
// Nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
// Nothing to close
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
package sh.libre.scim.storage;
|
|
||||||
|
|
||||||
import org.keycloak.storage.UserStorageProvider;
|
|
||||||
|
|
||||||
public class ScimStorageProvider implements UserStorageProvider {
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<ScimStorageProvider>, ImportSynchronization {
|
|
||||||
final private Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class);
|
|
||||||
public final static String ID = "scim";
|
|
||||||
protected static final List<ProviderConfigProperty> 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<ProviderConfigProperty> 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,28 +1,35 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
|
||||||
<changeSet author="contact@indiehosters.net" id="scim-resource-1.0">
|
<changeSet author="contact@indiehosters.net" id="scim-resource-1.0">
|
||||||
|
|
||||||
<createTable tableName="SCIM_RESOURCE">
|
<createTable tableName="SCIM_RESOURCE_MAPPING">
|
||||||
<column name="ID" type="VARCHAR(36)">
|
<column name="ID" type="VARCHAR(36)">
|
||||||
<constraints nullable="false" />
|
<constraints nullable="false"/>
|
||||||
</column>
|
</column>
|
||||||
<column name="REALM_ID" type="VARCHAR(36)">
|
<column name="REALM_ID" type="VARCHAR(36)">
|
||||||
<constraints nullable="false" />
|
<constraints nullable="false"/>
|
||||||
</column>
|
</column>
|
||||||
<column name="TYPE" type="VARCHAR(36)">
|
<column name="TYPE" type="VARCHAR(36)">
|
||||||
<constraints nullable="false" />
|
<constraints nullable="false"/>
|
||||||
</column>
|
</column>
|
||||||
<column name="COMPONENT_ID" type="VARCHAR(36)">
|
<column name="COMPONENT_ID" type="VARCHAR(36)">
|
||||||
<constraints nullable="false" />
|
<constraints nullable="false"/>
|
||||||
</column>
|
</column>
|
||||||
<column name="EXTERNAL_ID" type="VARCHAR(36)">
|
<column name="EXTERNAL_ID" type="VARCHAR(36)">
|
||||||
<constraints nullable="false" />
|
<constraints nullable="false"/>
|
||||||
</column>
|
</column>
|
||||||
</createTable>
|
</createTable>
|
||||||
|
|
||||||
<addPrimaryKey constraintName="PK_SCIM_RESOURCE" tableName="SCIM_RESOURCE" columnNames="ID,REALM_ID,TYPE,COMPONENT_ID,EXTERNAL_ID" />
|
<addPrimaryKey constraintName="PK_SCIM_RESOURCE_MAPPING" tableName="SCIM_RESOURCE_MAPPING"
|
||||||
<addForeignKeyConstraint baseTableName="SCIM_RESOURCE" baseColumnNames="REALM_ID" constraintName="FK_SCIM_RESOURCE_REALM" referencedTableName="REALM" referencedColumnNames="ID" onDelete="CASCADE" onUpdate="CASCADE" />
|
columnNames="ID,REALM_ID,TYPE,COMPONENT_ID,EXTERNAL_ID"/>
|
||||||
<addForeignKeyConstraint baseTableName="SCIM_RESOURCE" baseColumnNames="COMPONENT_ID" constraintName="FK_SCIM_RESOURCE_COMPONENT" referencedTableName="COMPONENT" referencedColumnNames="ID" onDelete="CASCADE" onUpdate="CASCADE" />
|
<addForeignKeyConstraint baseTableName="SCIM_RESOURCE_MAPPING" baseColumnNames="REALM_ID"
|
||||||
|
constraintName="FK_SCIM_RESOURCE_MAPPING_REALM" referencedTableName="REALM"
|
||||||
|
referencedColumnNames="ID" onDelete="CASCADE" onUpdate="CASCADE"/>
|
||||||
|
<addForeignKeyConstraint baseTableName="SCIM_RESOURCE_MAPPING" baseColumnNames="COMPONENT_ID"
|
||||||
|
constraintName="FK_SCIM_RESOURCE_MAPPING_COMPONENT" referencedTableName="COMPONENT"
|
||||||
|
referencedColumnNames="ID" onDelete="CASCADE" onUpdate="CASCADE"/>
|
||||||
</changeSet>
|
</changeSet>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
|
@ -1 +1 @@
|
||||||
sh.libre.scim.storage.ScimStorageProviderFactory
|
sh.libre.scim.core.ScimEndpointConfigurationStorageProviderFactory
|
||||||
|
|
Loading…
Reference in a new issue