Compare commits
No commits in common. "50acb71c15f297df6f2c43a2004552aa8875e5eb" and "65f4ea9c8ca8add6271ecd81fb5d58244a54f1c6" have entirely different histories.
50acb71c15
...
65f4ea9c8c
65 changed files with 71 additions and 2711 deletions
4
.github/actions/node-cache/action.yml
vendored
4
.github/actions/node-cache/action.yml
vendored
|
@ -8,8 +8,8 @@ runs:
|
||||||
id: tooling-versions
|
id: tooling-versions
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "node=$(./mvnw help:evaluate -Dexpression=node.version -q -DforceStdout | cut -c 2-)" >> $GITHUB_OUTPUT
|
echo "node=$(mvn help:evaluate -Dexpression=node.version -q -DforceStdout | cut -c 2-)" >> $GITHUB_OUTPUT
|
||||||
echo "pnpm=$(./mvnw help:evaluate -Dexpression=pnpm.version -q -DforceStdout)" >> $GITHUB_OUTPUT
|
echo "pnpm=$(mvn help:evaluate -Dexpression=pnpm.version -q -DforceStdout)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Downloading Node.js often fails due to network issues, therefore we cache the artifacts downloaded by the frontend plugin.
|
# Downloading Node.js often fails due to network issues, therefore we cache the artifacts downloaded by the frontend plugin.
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
|
|
|
@ -112,8 +112,6 @@ public class Profile {
|
||||||
|
|
||||||
OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL),
|
OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL),
|
||||||
|
|
||||||
SCIM("Synchronise users and groups with registered SCIM endpoints", Type.EXPERIMENTAL),
|
|
||||||
|
|
||||||
OPENTELEMETRY("OpenTelemetry Tracing", Type.PREVIEW),
|
OPENTELEMETRY("OpenTelemetry Tracing", Type.PREVIEW),
|
||||||
|
|
||||||
DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL),
|
DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL),
|
||||||
|
|
|
@ -47,10 +47,6 @@ Most CPU time goes into creating new TLS connections, as each client runs only a
|
||||||
This ensures a fast startup of the node, and sufficient capacity to handle failover tasks.
|
This ensures a fast startup of the node, and sufficient capacity to handle failover tasks.
|
||||||
Performance of {project_name} dropped significantly when its Pods were throttled in our tests.
|
Performance of {project_name} dropped significantly when its Pods were throttled in our tests.
|
||||||
|
|
||||||
* When performing requests with more than 2500 different clients concurrently, not all client information will fit into {project_name}'s caches when those are using the standard cache sizes of 10000 entries each.
|
|
||||||
Due to this, the database may become a bottleneck as client data is reloaded frequently from the database.
|
|
||||||
To reduce the database usage, increase the `users` cache size by two times the number of concurrently used clients, and the `realms` cache size by four times the number of concurrently used clients.
|
|
||||||
|
|
||||||
{project_name}, which by default stores user sessions in the database, requires the following resources for optimal performance on an Aurora PostgreSQL multi-AZ database:
|
{project_name}, which by default stores user sessions in the database, requires the following resources for optimal performance on an Aurora PostgreSQL multi-AZ database:
|
||||||
|
|
||||||
For every 100 login/logout/refresh requests per second:
|
For every 100 login/logout/refresh requests per second:
|
||||||
|
|
|
@ -36,7 +36,6 @@
|
||||||
<module>kerberos</module>
|
<module>kerberos</module>
|
||||||
<module>ldap</module>
|
<module>ldap</module>
|
||||||
<module>sssd</module>
|
<module>sssd</module>
|
||||||
<module>scim</module>
|
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|
|
@ -1,92 +0,0 @@
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<artifactId>keycloak-parent</artifactId>
|
|
||||||
<groupId>org.keycloak</groupId>
|
|
||||||
<version>999.0.0-SNAPSHOT</version>
|
|
||||||
<relativePath>../../pom.xml</relativePath>
|
|
||||||
</parent>
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<artifactId>keycloak-scim-federation</artifactId>
|
|
||||||
<name>Keycloak Federation from SCIM endpoints</name>
|
|
||||||
<description>
|
|
||||||
This extension add SCIM2 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
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<scim-sdk-version>1.26.0</scim-sdk-version>
|
|
||||||
<r4j-version>2.2.0</r4j-version>
|
|
||||||
</properties>
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>junit</groupId>
|
|
||||||
<artifactId>junit</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>net.java.dev.jna</groupId>
|
|
||||||
<artifactId>jna</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.keycloak</groupId>
|
|
||||||
<artifactId>keycloak-core</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.keycloak</groupId>
|
|
||||||
<artifactId>keycloak-server-spi</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.keycloak</groupId>
|
|
||||||
<artifactId>keycloak-server-spi-private</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.keycloak</groupId>
|
|
||||||
<artifactId>keycloak-services</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.keycloak</groupId>
|
|
||||||
<artifactId>keycloak-model-jpa</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.jboss.logging</groupId>
|
|
||||||
<artifactId>jboss-logging</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.github.resilience4j</groupId>
|
|
||||||
<artifactId>resilience4j-retry</artifactId>
|
|
||||||
<version>${r4j-version}</version>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>de.captaingoldfish</groupId>
|
|
||||||
<artifactId>scim-sdk-common</artifactId>
|
|
||||||
<version>${scim-sdk-version}</version>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>de.captaingoldfish</groupId>
|
|
||||||
<artifactId>scim-sdk-client</artifactId>
|
|
||||||
<version>${scim-sdk-version}</version>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
</project>
|
|
|
@ -1,171 +0,0 @@
|
||||||
package sh.libre.scim.core;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
import org.keycloak.component.ComponentModel;
|
|
||||||
import org.keycloak.models.KeycloakSession;
|
|
||||||
import sh.libre.scim.core.exceptions.ScimExceptionHandler;
|
|
||||||
import sh.libre.scim.core.exceptions.ScimPropagationException;
|
|
||||||
import sh.libre.scim.core.exceptions.SkipOrStopApproach;
|
|
||||||
import sh.libre.scim.core.exceptions.SkipOrStopStrategy;
|
|
||||||
import sh.libre.scim.core.service.AbstractScimService;
|
|
||||||
import sh.libre.scim.core.service.GroupScimService;
|
|
||||||
import sh.libre.scim.core.service.UserScimService;
|
|
||||||
|
|
||||||
import 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 {
|
|
||||||
|
|
||||||
private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class);
|
|
||||||
|
|
||||||
private final KeycloakSession session;
|
|
||||||
private final ScimExceptionHandler exceptionHandler;
|
|
||||||
private final SkipOrStopStrategy skipOrStopStrategy;
|
|
||||||
private boolean clientsInitialized = false;
|
|
||||||
private final List<UserScimService> userScimServices = new ArrayList<>();
|
|
||||||
private final List<GroupScimService> groupScimServices = new ArrayList<>();
|
|
||||||
|
|
||||||
|
|
||||||
public ScimDispatcher(KeycloakSession session) {
|
|
||||||
this.session = session;
|
|
||||||
this.exceptionHandler = new ScimExceptionHandler(session);
|
|
||||||
// By default, use a permissive Skip or Stop strategy
|
|
||||||
this.skipOrStopStrategy = SkipOrStopApproach.ALWAYS_SKIP_AND_CONTINUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lists all active ScimStorageProviderFactory and create new ScimClients for each of them
|
|
||||||
*/
|
|
||||||
public void refreshActiveScimEndpoints() {
|
|
||||||
// Step 1: close existing clients (as configuration may have changed)
|
|
||||||
groupScimServices.forEach(GroupScimService::close);
|
|
||||||
groupScimServices.clear();
|
|
||||||
userScimServices.forEach(UserScimService::close);
|
|
||||||
userScimServices.clear();
|
|
||||||
|
|
||||||
// Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory)
|
|
||||||
session.getContext().getRealm().getComponentsStream()
|
|
||||||
.filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId())
|
|
||||||
&& m.get("enabled", true))
|
|
||||||
.forEach(scimEndpointConfigurationRaw -> {
|
|
||||||
try {
|
|
||||||
ScrimEndPointConfiguration scrimEndPointConfiguration = new ScrimEndPointConfiguration(scimEndpointConfigurationRaw);
|
|
||||||
|
|
||||||
// Step 3 : create scim clients for each endpoint
|
|
||||||
if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
|
|
||||||
GroupScimService groupScimService = new GroupScimService(session, scrimEndPointConfiguration, skipOrStopStrategy);
|
|
||||||
groupScimServices.add(groupScimService);
|
|
||||||
}
|
|
||||||
if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
|
|
||||||
UserScimService userScimService = new UserScimService(session, scrimEndPointConfiguration, skipOrStopStrategy);
|
|
||||||
userScimServices.add(userScimService);
|
|
||||||
}
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
if (skipOrStopStrategy.allowInvalidEndpointConfiguration()) {
|
|
||||||
LOGGER.warn("[SCIM] Invalid Endpoint configuration " + scimEndpointConfigurationRaw.getId(), e);
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void dispatchUserModificationToAll(SCIMPropagationConsumer<UserScimService> operationToDispatch) {
|
|
||||||
initializeClientsIfNeeded();
|
|
||||||
Set<UserScimService> servicesCorrectlyPropagated = new LinkedHashSet<>();
|
|
||||||
userScimServices.forEach(userScimService -> {
|
|
||||||
try {
|
|
||||||
operationToDispatch.acceptThrows(userScimService);
|
|
||||||
servicesCorrectlyPropagated.add(userScimService);
|
|
||||||
} catch (ScimPropagationException e) {
|
|
||||||
exceptionHandler.handleException(userScimService.getConfiguration(), e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints
|
|
||||||
LOGGER.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void dispatchGroupModificationToAll(SCIMPropagationConsumer<GroupScimService> operationToDispatch) {
|
|
||||||
initializeClientsIfNeeded();
|
|
||||||
Set<GroupScimService> servicesCorrectlyPropagated = new LinkedHashSet<>();
|
|
||||||
groupScimServices.forEach(groupScimService -> {
|
|
||||||
try {
|
|
||||||
operationToDispatch.acceptThrows(groupScimService);
|
|
||||||
servicesCorrectlyPropagated.add(groupScimService);
|
|
||||||
} catch (ScimPropagationException e) {
|
|
||||||
exceptionHandler.handleException(groupScimService.getConfiguration(), e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints
|
|
||||||
LOGGER.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer<UserScimService> operationToDispatch) {
|
|
||||||
initializeClientsIfNeeded();
|
|
||||||
// Scim client should already have been created
|
|
||||||
Optional<UserScimService> matchingClient = userScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
|
|
||||||
if (matchingClient.isPresent()) {
|
|
||||||
try {
|
|
||||||
operationToDispatch.acceptThrows(matchingClient.get());
|
|
||||||
LOGGER.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName());
|
|
||||||
} catch (ScimPropagationException e) {
|
|
||||||
exceptionHandler.handleException(matchingClient.get().getConfiguration(), e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGGER.error("[SCIM] Could not find a Scim Client matching User endpoint configuration" + scimServerConfiguration.getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer<GroupScimService> operationToDispatch) {
|
|
||||||
initializeClientsIfNeeded();
|
|
||||||
// Scim client should already have been created
|
|
||||||
Optional<GroupScimService> matchingClient = groupScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
|
|
||||||
if (matchingClient.isPresent()) {
|
|
||||||
try {
|
|
||||||
operationToDispatch.acceptThrows(matchingClient.get());
|
|
||||||
LOGGER.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName());
|
|
||||||
} catch (ScimPropagationException e) {
|
|
||||||
exceptionHandler.handleException(matchingClient.get().getConfiguration(), e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGGER.error("[SCIM] Could not find a Scim Client matching Group endpoint configuration" + scimServerConfiguration.getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void close() {
|
|
||||||
for (GroupScimService c : groupScimServices) {
|
|
||||||
c.close();
|
|
||||||
}
|
|
||||||
for (UserScimService c : userScimServices) {
|
|
||||||
c.close();
|
|
||||||
}
|
|
||||||
groupScimServices.clear();
|
|
||||||
userScimServices.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initializeClientsIfNeeded() {
|
|
||||||
if (!clientsInitialized) {
|
|
||||||
clientsInitialized = true;
|
|
||||||
refreshActiveScimEndpoints();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Consumer that throws ScimPropagationException.
|
|
||||||
*
|
|
||||||
* @param <T> An {@link AbstractScimService to call}
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface SCIMPropagationConsumer<T> {
|
|
||||||
|
|
||||||
void acceptThrows(T elem) throws ScimPropagationException;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,165 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
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,7 +0,0 @@
|
||||||
package sh.libre.scim.core.exceptions;
|
|
||||||
|
|
||||||
public class InconsistentScimMappingException extends ScimPropagationException {
|
|
||||||
public InconsistentScimMappingException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
package sh.libre.scim.core.exceptions;
|
|
||||||
|
|
||||||
public class UnexpectedScimDataException extends ScimPropagationException {
|
|
||||||
public UnexpectedScimDataException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,281 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
package sh.libre.scim.core.service;
|
|
||||||
|
|
||||||
public record EntityOnRemoteScimId(
|
|
||||||
String asString
|
|
||||||
) {
|
|
||||||
}
|
|
|
@ -1,131 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,81 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
package sh.libre.scim.core.service;
|
|
||||||
|
|
||||||
public record KeycloakId(
|
|
||||||
String asString
|
|
||||||
) {
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,155 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,145 +0,0 @@
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,74 +0,0 @@
|
||||||
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,247 +0,0 @@
|
||||||
package sh.libre.scim.event;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
import org.keycloak.component.ComponentModel;
|
|
||||||
import org.keycloak.events.Event;
|
|
||||||
import org.keycloak.events.EventListenerProvider;
|
|
||||||
import org.keycloak.events.EventType;
|
|
||||||
import org.keycloak.events.admin.AdminEvent;
|
|
||||||
import org.keycloak.events.admin.OperationType;
|
|
||||||
import org.keycloak.events.admin.ResourceType;
|
|
||||||
import org.keycloak.models.GroupModel;
|
|
||||||
import org.keycloak.models.KeycloakSession;
|
|
||||||
import org.keycloak.models.UserModel;
|
|
||||||
import sh.libre.scim.core.ScimDispatcher;
|
|
||||||
import sh.libre.scim.core.ScimEndpointConfigurationStorageProviderFactory;
|
|
||||||
import sh.libre.scim.core.service.KeycloakDao;
|
|
||||||
import sh.libre.scim.core.service.KeycloakId;
|
|
||||||
import sh.libre.scim.core.service.ScimResourceType;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An Event listener reacting to Keycloak models modification
|
|
||||||
* (e.g. User creation, Group deletion, membership modifications, endpoint configuration change...)
|
|
||||||
* by propagating it to all registered Scim endpoints.
|
|
||||||
*/
|
|
||||||
public class ScimEventListenerProvider implements EventListenerProvider {
|
|
||||||
|
|
||||||
private static final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class);
|
|
||||||
|
|
||||||
private final ScimDispatcher dispatcher;
|
|
||||||
|
|
||||||
private final KeycloakSession session;
|
|
||||||
|
|
||||||
private final KeycloakDao keycloakDao;
|
|
||||||
|
|
||||||
private final Map<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) {
|
|
||||||
this.session = session;
|
|
||||||
this.keycloakDao = new KeycloakDao(session);
|
|
||||||
this.dispatcher = new ScimDispatcher(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onEvent(Event event) {
|
|
||||||
// React to User-related event : creation, deletion, update
|
|
||||||
EventType eventType = event.getType();
|
|
||||||
KeycloakId eventUserId = new KeycloakId(event.getUserId());
|
|
||||||
switch (eventType) {
|
|
||||||
case REGISTER -> {
|
|
||||||
LOGGER.infof("[SCIM] Propagate User Registration - %s", eventUserId);
|
|
||||||
UserModel user = getUser(eventUserId);
|
|
||||||
dispatcher.dispatchUserModificationToAll(client -> client.create(user));
|
|
||||||
}
|
|
||||||
case UPDATE_EMAIL, UPDATE_PROFILE -> {
|
|
||||||
LOGGER.infof("[SCIM] Propagate User %s - %s", eventType, eventUserId);
|
|
||||||
UserModel user = getUser(eventUserId);
|
|
||||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
|
||||||
}
|
|
||||||
case DELETE_ACCOUNT -> {
|
|
||||||
LOGGER.infof("[SCIM] Propagate User deletion - %s", eventUserId);
|
|
||||||
dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId));
|
|
||||||
}
|
|
||||||
default -> {
|
|
||||||
// No other event has to be propagated to Scim endpoints
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onEvent(AdminEvent event, boolean includeRepresentation) {
|
|
||||||
// Step 1: check if event is relevant for propagation through SCIM
|
|
||||||
Pattern pattern = listenedEventPathPatterns.get(event.getResourceType());
|
|
||||||
if (pattern == null)
|
|
||||||
return;
|
|
||||||
Matcher matcher = pattern.matcher(event.getResourcePath());
|
|
||||||
if (!matcher.find())
|
|
||||||
return;
|
|
||||||
|
|
||||||
|
|
||||||
// Step 2: propagate event (if needed) according to its resource type
|
|
||||||
switch (event.getResourceType()) {
|
|
||||||
case USER -> {
|
|
||||||
KeycloakId userId = new KeycloakId(matcher.group(1));
|
|
||||||
handleUserEvent(event, userId);
|
|
||||||
}
|
|
||||||
case GROUP -> {
|
|
||||||
KeycloakId groupId = new KeycloakId(matcher.group(1));
|
|
||||||
handleGroupEvent(event, groupId);
|
|
||||||
}
|
|
||||||
case GROUP_MEMBERSHIP -> {
|
|
||||||
KeycloakId userId = new KeycloakId(matcher.group(1));
|
|
||||||
KeycloakId groupId = new KeycloakId(matcher.group(2));
|
|
||||||
handleGroupMemberShipEvent(event, userId, groupId);
|
|
||||||
}
|
|
||||||
case REALM_ROLE_MAPPING -> {
|
|
||||||
String rawResourceType = matcher.group(1);
|
|
||||||
ScimResourceType type = switch (rawResourceType) {
|
|
||||||
case "users" -> ScimResourceType.USER;
|
|
||||||
case "groups" -> ScimResourceType.GROUP;
|
|
||||||
default -> throw new IllegalArgumentException("Unsupported resource type: " + rawResourceType);
|
|
||||||
};
|
|
||||||
KeycloakId id = new KeycloakId(matcher.group(2));
|
|
||||||
handleRoleMappingEvent(event, type, id);
|
|
||||||
}
|
|
||||||
case COMPONENT -> {
|
|
||||||
String id = matcher.group(1);
|
|
||||||
handleScimEndpointConfigurationEvent(event, id);
|
|
||||||
|
|
||||||
}
|
|
||||||
default -> {
|
|
||||||
// No other resource modification has to be propagated to Scim endpoints
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void handleUserEvent(AdminEvent userEvent, KeycloakId userId) {
|
|
||||||
LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId);
|
|
||||||
switch (userEvent.getOperationType()) {
|
|
||||||
case CREATE -> {
|
|
||||||
UserModel user = getUser(userId);
|
|
||||||
dispatcher.dispatchUserModificationToAll(client -> client.create(user));
|
|
||||||
user.getGroupsStream().forEach(group ->
|
|
||||||
dispatcher.dispatchGroupModificationToAll(client -> client.update(group)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
case UPDATE -> {
|
|
||||||
UserModel user = getUser(userId);
|
|
||||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
|
||||||
}
|
|
||||||
case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId));
|
|
||||||
default -> {
|
|
||||||
// ACTION userEvent are not relevant, nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Propagating the given group-related event to Scim endpoints.
|
|
||||||
*
|
|
||||||
* @param event the event to propagate
|
|
||||||
* @param groupId event target's id
|
|
||||||
*/
|
|
||||||
private void handleGroupEvent(AdminEvent event, KeycloakId groupId) {
|
|
||||||
LOGGER.infof("[SCIM] Propagate Group %s - %s", event.getOperationType(), groupId);
|
|
||||||
switch (event.getOperationType()) {
|
|
||||||
case CREATE -> {
|
|
||||||
GroupModel group = getGroup(groupId);
|
|
||||||
dispatcher.dispatchGroupModificationToAll(client -> client.create(group));
|
|
||||||
}
|
|
||||||
case UPDATE -> {
|
|
||||||
GroupModel group = getGroup(groupId);
|
|
||||||
dispatcher.dispatchGroupModificationToAll(client -> client.update(group));
|
|
||||||
}
|
|
||||||
case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId));
|
|
||||||
default -> {
|
|
||||||
// ACTION event are not relevant, nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleGroupMemberShipEvent(AdminEvent groupMemberShipEvent, KeycloakId userId, KeycloakId groupId) {
|
|
||||||
LOGGER.infof("[SCIM] Propagate GroupMemberShip %s - User %s Group %s", groupMemberShipEvent.getOperationType(), userId, groupId);
|
|
||||||
// Step 1: update USER immediately
|
|
||||||
GroupModel group = getGroup(groupId);
|
|
||||||
UserModel user = getUser(userId);
|
|
||||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
|
||||||
|
|
||||||
// Step 2: delayed GROUP update :
|
|
||||||
// if several users are added to the group simultaneously in different Keycloack sessions
|
|
||||||
// update the group in the context of the current session may not reflect those other changes
|
|
||||||
// We trigger a delayed update by setting an attribute on the group (that will be handled by ScimBackgroundGroupMembershipUpdaters)
|
|
||||||
group.setSingleAttribute(ScimBackgroundGroupMembershipUpdater.GROUP_DIRTY_SINCE_ATTRIBUTE_NAME, "" + System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleRoleMappingEvent(AdminEvent roleMappingEvent, ScimResourceType type, KeycloakId id) {
|
|
||||||
LOGGER.infof("[SCIM] Propagate RoleMapping %s - %s %s", roleMappingEvent.getOperationType(), type, id);
|
|
||||||
switch (type) {
|
|
||||||
case USER -> {
|
|
||||||
UserModel user = getUser(id);
|
|
||||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
|
||||||
}
|
|
||||||
case GROUP -> {
|
|
||||||
GroupModel group = getGroup(id);
|
|
||||||
session.users()
|
|
||||||
.getGroupMembersStream(session.getContext().getRealm(), group)
|
|
||||||
.forEach(user ->
|
|
||||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
default -> {
|
|
||||||
// No other type is relevant for propagation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleScimEndpointConfigurationEvent(AdminEvent event, String id) {
|
|
||||||
// In case of a component deletion
|
|
||||||
if (event.getOperationType() == OperationType.DELETE) {
|
|
||||||
// Check if it was a Scim endpoint configuration, and forward deletion if so
|
|
||||||
Stream<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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
package sh.libre.scim.event;
|
|
||||||
|
|
||||||
import org.keycloak.Config.Scope;
|
|
||||||
import org.keycloak.events.EventListenerProvider;
|
|
||||||
import org.keycloak.events.EventListenerProviderFactory;
|
|
||||||
import org.keycloak.models.KeycloakSession;
|
|
||||||
import org.keycloak.models.KeycloakSessionFactory;
|
|
||||||
|
|
||||||
public class ScimEventListenerProviderFactory implements EventListenerProviderFactory {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public EventListenerProvider create(KeycloakSession session) {
|
|
||||||
return new ScimEventListenerProvider(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getId() {
|
|
||||||
return "scim";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void init(Scope config) {
|
|
||||||
// Nothing to initialize
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void postInit(KeycloakSessionFactory factory) {
|
|
||||||
// Nothing to initialize
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
// Nothing to close
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,96 +0,0 @@
|
||||||
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,83 +0,0 @@
|
||||||
package sh.libre.scim.jpa;
|
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public class ScimResourceId implements Serializable {
|
|
||||||
private String id;
|
|
||||||
private String realmId;
|
|
||||||
private String componentId;
|
|
||||||
private String type;
|
|
||||||
private String externalId;
|
|
||||||
|
|
||||||
public ScimResourceId() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public ScimResourceId(String id, String realmId, String componentId, String type, String externalId) {
|
|
||||||
this.setId(id);
|
|
||||||
this.setRealmId(realmId);
|
|
||||||
this.setComponentId(componentId);
|
|
||||||
this.setType(type);
|
|
||||||
this.setExternalId(externalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setId(String id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getRealmId() {
|
|
||||||
return realmId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRealmId(String realmId) {
|
|
||||||
this.realmId = realmId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getComponentId() {
|
|
||||||
return componentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setComponentId(String componentId) {
|
|
||||||
this.componentId = componentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setType(String type) {
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getExternalId() {
|
|
||||||
return externalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setExternalId(String externalId) {
|
|
||||||
this.externalId = externalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object other) {
|
|
||||||
if (this == other)
|
|
||||||
return true;
|
|
||||||
if (!(other instanceof ScimResourceId o))
|
|
||||||
return false;
|
|
||||||
return (StringUtils.equals(o.id, id) &&
|
|
||||||
StringUtils.equals(o.realmId, realmId) &&
|
|
||||||
StringUtils.equals(o.componentId, componentId) &&
|
|
||||||
StringUtils.equals(o.type, type) &&
|
|
||||||
StringUtils.equals(o.externalId, externalId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
return Objects.hash(realmId, componentId, type, id, externalId);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,89 +0,0 @@
|
||||||
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,29 +0,0 @@
|
||||||
package sh.libre.scim.jpa;
|
|
||||||
|
|
||||||
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class ScimResourceProvider implements JpaEntityProvider {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<Class<?>> getEntities() {
|
|
||||||
return Collections.singletonList(ScimResourceMapping.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getChangelogLocation() {
|
|
||||||
return "META-INF/scim-resource-changelog.xml";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
// Nothing to close
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getFactoryId() {
|
|
||||||
return ScimResourceProviderFactory.ID;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
package sh.libre.scim.jpa;
|
|
||||||
|
|
||||||
import org.keycloak.Config.Scope;
|
|
||||||
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
|
|
||||||
import org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory;
|
|
||||||
import org.keycloak.models.KeycloakSession;
|
|
||||||
import org.keycloak.models.KeycloakSessionFactory;
|
|
||||||
|
|
||||||
public class ScimResourceProviderFactory implements JpaEntityProviderFactory {
|
|
||||||
|
|
||||||
static final String ID = "scim-resource";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public JpaEntityProvider create(KeycloakSession session) {
|
|
||||||
return new ScimResourceProvider();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getId() {
|
|
||||||
return ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void init(Scope scope) {
|
|
||||||
// Nothing to initialise
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void postInit(KeycloakSessionFactory sessionFactory) {
|
|
||||||
// Nothing to do
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
// Nothing to close
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<jboss-deployment-structure>
|
|
||||||
<deployment>
|
|
||||||
<dependencies>
|
|
||||||
<module name="org.keycloak.keycloak-services" />
|
|
||||||
<module name="org.keycloak.keycloak-model-jpa" />
|
|
||||||
<module name="org.hibernate" />
|
|
||||||
</dependencies>
|
|
||||||
</deployment>
|
|
||||||
</jboss-deployment-structure>
|
|
|
@ -1,35 +0,0 @@
|
||||||
<?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">
|
|
||||||
<changeSet author="contact@indiehosters.net" id="scim-resource-1.0">
|
|
||||||
|
|
||||||
<createTable tableName="SCIM_RESOURCE_MAPPING">
|
|
||||||
<column name="ID" type="VARCHAR(36)">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
<column name="REALM_ID" type="VARCHAR(36)">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
<column name="TYPE" type="VARCHAR(36)">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
<column name="COMPONENT_ID" type="VARCHAR(36)">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
<column name="EXTERNAL_ID" type="VARCHAR(36)">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
</createTable>
|
|
||||||
|
|
||||||
<addPrimaryKey constraintName="PK_SCIM_RESOURCE_MAPPING" tableName="SCIM_RESOURCE_MAPPING"
|
|
||||||
columnNames="ID,REALM_ID,TYPE,COMPONENT_ID,EXTERNAL_ID"/>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
|
@ -1 +0,0 @@
|
||||||
sh.libre.scim.jpa.ScimResourceProviderFactory
|
|
|
@ -1 +0,0 @@
|
||||||
sh.libre.scim.event.ScimEventListenerProviderFactory
|
|
|
@ -1 +0,0 @@
|
||||||
sh.libre.scim.core.ScimEndpointConfigurationStorageProviderFactory
|
|
|
@ -394,16 +394,6 @@ describe("Group test", () => {
|
||||||
.assertNotificationUserLeftTheGroup(1)
|
.assertNotificationUserLeftTheGroup(1)
|
||||||
.assertNoUsersFoundEmptyStateMessageExist(true);
|
.assertNoUsersFoundEmptyStateMessageExist(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Show memberships from item bar", () => {
|
|
||||||
sidebarPage.goToGroups();
|
|
||||||
groupPage.goToGroupChildGroupsTab(predefinedGroups[0]);
|
|
||||||
childGroupsTab.goToMembersTab();
|
|
||||||
membersTab
|
|
||||||
.showGroupMembershipsItem(users[3].username)
|
|
||||||
.assertGroupItemExist(predefinedGroups[0], true)
|
|
||||||
.cancelShowGroupMembershipsModal();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Breadcrumbs", () => {
|
describe("Breadcrumbs", () => {
|
||||||
|
|
|
@ -54,17 +54,6 @@ export default class MembersTab extends GroupDetailPage {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public showGroupMembershipsItem(username: string) {
|
|
||||||
listingPage.clickRowDetails(username);
|
|
||||||
listingPage.clickDetailMenu("Show memberships");
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public cancelShowGroupMembershipsModal() {
|
|
||||||
modalUtils.cancelModal();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public clickCheckboxIncludeSubGroupUsers() {
|
public clickCheckboxIncludeSubGroupUsers() {
|
||||||
cy.findByTestId(this.#includeSubGroupsCheck).click();
|
cy.findByTestId(this.#includeSubGroupsCheck).click();
|
||||||
return this;
|
return this;
|
||||||
|
|
|
@ -1102,7 +1102,7 @@ connectionTimeoutHelp=LDAP connection timeout in milliseconds
|
||||||
repeat=Repeat
|
repeat=Repeat
|
||||||
defaultSigAlgHelp=Default algorithm used to sign tokens for the realm
|
defaultSigAlgHelp=Default algorithm used to sign tokens for the realm
|
||||||
save-admin-eventsHelp=If enabled, admin events are saved to the database, which makes events available to the Admin UI.
|
save-admin-eventsHelp=If enabled, admin events are saved to the database, which makes events available to the Admin UI.
|
||||||
policyGroupsHelp=Specifies which user(s) are allowed by this policy.
|
policyGroups=Specifies which user(s) are allowed by this policy.
|
||||||
searchForProtocol=Search protocol mapper
|
searchForProtocol=Search protocol mapper
|
||||||
eventTypes.CLIENT_INFO.name=Client info
|
eventTypes.CLIENT_INFO.name=Client info
|
||||||
eventTypes.OAUTH2_DEVICE_CODE_TO_TOKEN.description=OAuth2 device code to token
|
eventTypes.OAUTH2_DEVICE_CODE_TO_TOKEN.description=OAuth2 device code to token
|
||||||
|
@ -3273,7 +3273,3 @@ groupDuplicated=Group duplicated
|
||||||
duplicateAGroup=Duplicate group
|
duplicateAGroup=Duplicate group
|
||||||
couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}}
|
couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}}
|
||||||
duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups.
|
duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups.
|
||||||
showMemberships=Show memberships
|
|
||||||
showMembershipsTitle={{username}} Group Memberships
|
|
||||||
noGroupMembershipsText=This user is not a member of any groups.
|
|
||||||
noGroupMemberships=No memberships
|
|
||||||
|
|
|
@ -117,7 +117,7 @@
|
||||||
"ldap-server-mock": "^6.0.1",
|
"ldap-server-mock": "^6.0.1",
|
||||||
"lightningcss": "^1.27.0",
|
"lightningcss": "^1.27.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"uuid": "^11.0.2",
|
"uuid": "^11.0.1",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vite-plugin-checker": "^0.8.0",
|
"vite-plugin-checker": "^0.8.0",
|
||||||
"vite-plugin-dts": "^4.3.0",
|
"vite-plugin-dts": "^4.3.0",
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import { fetchWithError } from "@keycloak/keycloak-admin-client";
|
import { fetchWithError } from "@keycloak/keycloak-admin-client";
|
||||||
import {
|
import type AuthenticationFlowRepresentation from "@keycloak/keycloak-admin-client/lib/defs/authenticationFlowRepresentation";
|
||||||
KeycloakDataTable,
|
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
KeycloakSpinner,
|
import { useAlerts } from "@keycloak/keycloak-ui-shared";
|
||||||
ListEmptyState,
|
|
||||||
useAlerts,
|
|
||||||
} from "@keycloak/keycloak-ui-shared";
|
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
Button,
|
Button,
|
||||||
|
@ -19,12 +16,16 @@ import { sortBy } from "lodash-es";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { useAdminClient } from "../admin-client";
|
import { useAdminClient } from "../admin-client";
|
||||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||||
|
import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared";
|
||||||
|
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
|
||||||
import {
|
import {
|
||||||
RoutableTabs,
|
RoutableTabs,
|
||||||
useRoutableTab,
|
useRoutableTab,
|
||||||
} from "../components/routable-tabs/RoutableTabs";
|
} from "../components/routable-tabs/RoutableTabs";
|
||||||
|
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
|
||||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import helpUrls from "../help-urls";
|
import helpUrls from "../help-urls";
|
||||||
|
@ -36,12 +37,28 @@ import { BindFlowDialog } from "./BindFlowDialog";
|
||||||
import { DuplicateFlowModal } from "./DuplicateFlowModal";
|
import { DuplicateFlowModal } from "./DuplicateFlowModal";
|
||||||
import { RequiredActions } from "./RequiredActions";
|
import { RequiredActions } from "./RequiredActions";
|
||||||
import { UsedBy } from "./components/UsedBy";
|
import { UsedBy } from "./components/UsedBy";
|
||||||
import { AuthenticationType } from "./constants";
|
|
||||||
import { Policies } from "./policies/Policies";
|
import { Policies } from "./policies/Policies";
|
||||||
import { AuthenticationTab, toAuthentication } from "./routes/Authentication";
|
import { AuthenticationTab, toAuthentication } from "./routes/Authentication";
|
||||||
import { toCreateFlow } from "./routes/CreateFlow";
|
import { toCreateFlow } from "./routes/CreateFlow";
|
||||||
import { toFlow } from "./routes/Flow";
|
import { toFlow } from "./routes/Flow";
|
||||||
|
|
||||||
|
type UsedBy = "SPECIFIC_CLIENTS" | "SPECIFIC_PROVIDERS" | "DEFAULT";
|
||||||
|
|
||||||
|
export type AuthenticationType = AuthenticationFlowRepresentation & {
|
||||||
|
usedBy?: { type?: UsedBy; values: string[] };
|
||||||
|
realm: RealmRepresentation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REALM_FLOWS = new Map<string, string>([
|
||||||
|
["browserFlow", "browser"],
|
||||||
|
["registrationFlow", "registration"],
|
||||||
|
["directGrantFlow", "direct grant"],
|
||||||
|
["resetCredentialsFlow", "reset credentials"],
|
||||||
|
["clientAuthenticationFlow", "clients"],
|
||||||
|
["dockerAuthenticationFlow", "docker auth"],
|
||||||
|
["firstBrokerLoginFlow", "firstBrokerLogin"],
|
||||||
|
]);
|
||||||
|
|
||||||
const AliasRenderer = ({ id, alias, usedBy, builtIn }: AuthenticationType) => {
|
const AliasRenderer = ({ id, alias, usedBy, builtIn }: AuthenticationType) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { realm } = useRealm();
|
const { realm } = useRealm();
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
import { SelectControl, useAlerts } from "@keycloak/keycloak-ui-shared";
|
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
Button,
|
Button,
|
||||||
|
@ -9,9 +8,11 @@ import {
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAdminClient } from "../admin-client";
|
import { SelectControl } from "@keycloak/keycloak-ui-shared";
|
||||||
|
import { useAlerts } from "@keycloak/keycloak-ui-shared";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import { REALM_FLOWS } from "./constants";
|
import { REALM_FLOWS } from "./AuthenticationSection";
|
||||||
|
import { useAdminClient } from "../admin-client";
|
||||||
|
|
||||||
type BindingForm = {
|
type BindingForm = {
|
||||||
bindingType: keyof RealmRepresentation;
|
bindingType: keyof RealmRepresentation;
|
||||||
|
|
|
@ -20,7 +20,7 @@ type DataType = RequiredActionProviderRepresentation &
|
||||||
};
|
};
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
name?: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
defaultAction: boolean;
|
defaultAction: boolean;
|
||||||
data: DataType;
|
data: DataType;
|
||||||
|
@ -173,14 +173,14 @@ export const RequiredActions = () => {
|
||||||
displayKey: "enabled",
|
displayKey: "enabled",
|
||||||
cellRenderer: (row) => (
|
cellRenderer: (row) => (
|
||||||
<Switch
|
<Switch
|
||||||
id={`enable-${toKey(row.name || "")}`}
|
id={`enable-${toKey(row.name)}`}
|
||||||
label={t("on")}
|
label={t("on")}
|
||||||
labelOff={t("off")}
|
labelOff={t("off")}
|
||||||
isChecked={row.enabled}
|
isChecked={row.enabled}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
updateAction(row.data, "enabled");
|
updateAction(row.data, "enabled");
|
||||||
}}
|
}}
|
||||||
aria-label={row.name}
|
aria-label={toKey(row.name)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
width: 20,
|
width: 20,
|
||||||
|
@ -191,7 +191,7 @@ export const RequiredActions = () => {
|
||||||
thTooltipText: "authDefaultActionTooltip",
|
thTooltipText: "authDefaultActionTooltip",
|
||||||
cellRenderer: (row) => (
|
cellRenderer: (row) => (
|
||||||
<Switch
|
<Switch
|
||||||
id={`default-${toKey(row.name || "")}`}
|
id={`default-${toKey(row.name)}`}
|
||||||
label={t("on")}
|
label={t("on")}
|
||||||
isDisabled={!row.enabled}
|
isDisabled={!row.enabled}
|
||||||
labelOff={!row.enabled ? t("disabledOff") : t("off")}
|
labelOff={!row.enabled ? t("disabledOff") : t("off")}
|
||||||
|
@ -199,7 +199,7 @@ export const RequiredActions = () => {
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
updateAction(row.data, "defaultAction");
|
updateAction(row.data, "defaultAction");
|
||||||
}}
|
}}
|
||||||
aria-label={row.name}
|
aria-label={toKey(row.name)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
width: 20,
|
width: 20,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
|
@ -12,11 +11,12 @@ import { CheckCircleIcon } from "@patternfly/react-icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAdminClient } from "../../admin-client";
|
import { useAdminClient } from "../../admin-client";
|
||||||
import { fetchUsedBy } from "../../components/role-mapping/resource";
|
import { fetchUsedBy } from "../../components/role-mapping/resource";
|
||||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
|
||||||
import useToggle from "../../utils/useToggle";
|
import useToggle from "../../utils/useToggle";
|
||||||
import { AuthenticationType, REALM_FLOWS } from "../constants";
|
import { AuthenticationType, REALM_FLOWS } from "../AuthenticationSection";
|
||||||
|
|
||||||
import style from "./used-by.module.css";
|
import style from "./used-by.module.css";
|
||||||
|
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||||
|
|
||||||
type UsedByProps = {
|
type UsedByProps = {
|
||||||
authType: AuthenticationType;
|
authType: AuthenticationType;
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import AuthenticationFlowRepresentation from "@keycloak/keycloak-admin-client/lib/defs/authenticationFlowRepresentation";
|
|
||||||
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
|
||||||
|
|
||||||
type UsedBy = "SPECIFIC_CLIENTS" | "SPECIFIC_PROVIDERS" | "DEFAULT";
|
|
||||||
|
|
||||||
export type AuthenticationType = AuthenticationFlowRepresentation & {
|
|
||||||
usedBy?: { type?: UsedBy; values: string[] };
|
|
||||||
realm: RealmRepresentation;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const REALM_FLOWS = new Map<string, string>([
|
|
||||||
["browserFlow", "browser"],
|
|
||||||
["registrationFlow", "registration"],
|
|
||||||
["directGrantFlow", "direct grant"],
|
|
||||||
["resetCredentialsFlow", "reset credentials"],
|
|
||||||
["clientAuthenticationFlow", "clients"],
|
|
||||||
["dockerAuthenticationFlow", "docker auth"],
|
|
||||||
["firstBrokerLoginFlow", "firstBrokerLogin"],
|
|
||||||
]);
|
|
|
@ -2,9 +2,9 @@ import {
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupItem,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TextInputProps,
|
TextInputProps,
|
||||||
|
InputGroupItem,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
||||||
import { Fragment, useEffect, useMemo } from "react";
|
import { Fragment, useEffect, useMemo } from "react";
|
||||||
|
@ -25,7 +25,6 @@ export type MultiLineInputProps = Omit<TextInputProps, "form"> & {
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
defaultValue?: string[];
|
defaultValue?: string[];
|
||||||
stringify?: boolean;
|
stringify?: boolean;
|
||||||
isRequired?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MultiLineInput = ({
|
export const MultiLineInput = ({
|
||||||
|
@ -34,7 +33,6 @@ export const MultiLineInput = ({
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
stringify = false,
|
stringify = false,
|
||||||
isRequired = false,
|
|
||||||
id,
|
id,
|
||||||
...rest
|
...rest
|
||||||
}: MultiLineInputProps) => {
|
}: MultiLineInputProps) => {
|
||||||
|
@ -80,17 +78,11 @@ export const MultiLineInput = ({
|
||||||
const fieldValue = values.flatMap((field) => field);
|
const fieldValue = values.flatMap((field) => field);
|
||||||
setValue(name, stringify ? toStringValue(fieldValue) : fieldValue, {
|
setValue(name, stringify ? toStringValue(fieldValue) : fieldValue, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: true,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
register(name, {
|
register(name);
|
||||||
validate: (value) =>
|
|
||||||
isRequired && toStringValue(value || []).length === 0
|
|
||||||
? t("required")
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}, [register]);
|
}, [register]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -22,7 +22,7 @@ import {
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
|
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
|
||||||
import { PageHandler } from "../../page/PageHandler";
|
import { PageHandler } from "../../page/PageHandler";
|
||||||
import { TAB_PROVIDER } from "../../page/constants";
|
import { TAB_PROVIDER } from "../../page/PageList";
|
||||||
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";
|
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
|
|
@ -32,8 +32,6 @@ import { emptyFormatter } from "../util";
|
||||||
import { MemberModal } from "./MembersModal";
|
import { MemberModal } from "./MembersModal";
|
||||||
import { useSubGroups } from "./SubGroupsContext";
|
import { useSubGroups } from "./SubGroupsContext";
|
||||||
import { getLastId } from "./groupIdUtils";
|
import { getLastId } from "./groupIdUtils";
|
||||||
import { MembershipsModal } from "./MembershipsModal";
|
|
||||||
import useToggle from "../utils/useToggle";
|
|
||||||
|
|
||||||
const UserDetailLink = (user: UserRepresentation) => {
|
const UserDetailLink = (user: UserRepresentation) => {
|
||||||
const { realm } = useRealm();
|
const { realm } = useRealm();
|
||||||
|
@ -52,7 +50,9 @@ const UserDetailLink = (user: UserRepresentation) => {
|
||||||
|
|
||||||
export const Members = () => {
|
export const Members = () => {
|
||||||
const { adminClient } = useAdminClient();
|
const { adminClient } = useAdminClient();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const id = getLastId(location.pathname);
|
const id = getLastId(location.pathname);
|
||||||
|
@ -62,8 +62,6 @@ export const Members = () => {
|
||||||
const [addMembers, setAddMembers] = useState(false);
|
const [addMembers, setAddMembers] = useState(false);
|
||||||
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
||||||
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
|
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
|
||||||
const [selectedUser, setSelectedUser] = useState<UserRepresentation>();
|
|
||||||
const [showMemberships, toggleShowMemberships] = useToggle();
|
|
||||||
const { hasAccess } = useAccess();
|
const { hasAccess } = useAccess();
|
||||||
|
|
||||||
useFetch(
|
useFetch(
|
||||||
|
@ -164,14 +162,6 @@ export const Members = () => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showMemberships && (
|
|
||||||
<MembershipsModal
|
|
||||||
onClose={() => {
|
|
||||||
toggleShowMemberships();
|
|
||||||
}}
|
|
||||||
user={selectedUser!}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<KeycloakDataTable
|
<KeycloakDataTable
|
||||||
data-testid="members-table"
|
data-testid="members-table"
|
||||||
key={`${id}${key}${includeSubGroup}`}
|
key={`${id}${key}${includeSubGroup}`}
|
||||||
|
@ -252,8 +242,8 @@ export const Members = () => {
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
actions={[
|
actions={
|
||||||
...(isManager
|
isManager
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: t("leave"),
|
title: t("leave"),
|
||||||
|
@ -267,19 +257,13 @@ export const Members = () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError("usersLeftError", error);
|
addError("usersLeftError", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
} as Action<UserRepresentation>,
|
} as Action<UserRepresentation>,
|
||||||
]
|
]
|
||||||
: []),
|
: []
|
||||||
{
|
}
|
||||||
title: t("showMemberships"),
|
|
||||||
onRowClick: (user) => {
|
|
||||||
setSelectedUser(user);
|
|
||||||
toggleShowMemberships();
|
|
||||||
},
|
|
||||||
} as Action<UserRepresentation>,
|
|
||||||
]}
|
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
name: "username",
|
name: "username",
|
||||||
|
|
|
@ -1,157 +0,0 @@
|
||||||
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
|
|
||||||
import UserRepresentation from "js/libs/keycloak-admin-client/lib/defs/userRepresentation";
|
|
||||||
import { Modal, ModalVariant } from "@patternfly/react-core";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
ButtonVariant,
|
|
||||||
Checkbox,
|
|
||||||
Popover,
|
|
||||||
} from "@patternfly/react-core";
|
|
||||||
import { QuestionCircleIcon } from "@patternfly/react-icons";
|
|
||||||
import { cellWidth } from "@patternfly/react-table";
|
|
||||||
import { useHelp } from "@keycloak/keycloak-ui-shared";
|
|
||||||
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
|
|
||||||
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
|
|
||||||
import { sortBy, uniqBy } from "lodash-es";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useAdminClient } from "../admin-client";
|
|
||||||
import { GroupPath } from "../components/group/GroupPath";
|
|
||||||
|
|
||||||
type CredentialDataDialogProps = {
|
|
||||||
user: UserRepresentation;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MembershipsModal = ({
|
|
||||||
user,
|
|
||||||
onClose,
|
|
||||||
}: CredentialDataDialogProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { adminClient } = useAdminClient();
|
|
||||||
const [key, setKey] = useState(0);
|
|
||||||
const refresh = () => setKey(key + 1);
|
|
||||||
const [isDirectMembership, setDirectMembership] = useState(true);
|
|
||||||
const { enabled } = useHelp();
|
|
||||||
const alphabetize = (groupsList: GroupRepresentation[]) => {
|
|
||||||
return sortBy(groupsList, (group) => group.path?.toUpperCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
const loader = async (first?: number, max?: number, search?: string) => {
|
|
||||||
const params: { [name: string]: string | number } = {
|
|
||||||
first: first!,
|
|
||||||
max: max!,
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchParam = search || "";
|
|
||||||
if (searchParam) {
|
|
||||||
params.search = searchParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
const joinedUserGroups = await adminClient.users.listGroups({
|
|
||||||
...params,
|
|
||||||
id: user.id!,
|
|
||||||
});
|
|
||||||
|
|
||||||
const indirect: GroupRepresentation[] = [];
|
|
||||||
if (!isDirectMembership)
|
|
||||||
joinedUserGroups.forEach((g) => {
|
|
||||||
const paths = (
|
|
||||||
g.path?.substring(1).match(/((~\/)|[^/])+/g) || []
|
|
||||||
).slice(0, -1);
|
|
||||||
|
|
||||||
indirect.push(
|
|
||||||
...paths.map((p) => ({
|
|
||||||
name: p,
|
|
||||||
path: g.path?.substring(0, g.path.indexOf(p) + p.length),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return alphabetize(uniqBy([...joinedUserGroups, ...indirect], "path"));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
variant={ModalVariant.large}
|
|
||||||
title={t("showMembershipsTitle", { username: user.username })}
|
|
||||||
data-testid="showMembershipsDialog"
|
|
||||||
isOpen
|
|
||||||
onClose={onClose}
|
|
||||||
actions={[
|
|
||||||
<Button
|
|
||||||
id="modal-cancel"
|
|
||||||
data-testid="cancel"
|
|
||||||
key="cancel"
|
|
||||||
variant={ButtonVariant.primary}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<KeycloakDataTable
|
|
||||||
key={key}
|
|
||||||
loader={loader}
|
|
||||||
className="keycloak_user-section_groups-table"
|
|
||||||
isPaginated
|
|
||||||
ariaLabelKey="roleList"
|
|
||||||
searchPlaceholderKey="searchGroup"
|
|
||||||
toolbarItem={
|
|
||||||
<>
|
|
||||||
<Checkbox
|
|
||||||
label={t("directMembership")}
|
|
||||||
key="direct-membership-check"
|
|
||||||
id="kc-direct-membership-checkbox"
|
|
||||||
onChange={() => {
|
|
||||||
setDirectMembership(!isDirectMembership);
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
isChecked={isDirectMembership}
|
|
||||||
className="pf-v5-u-mt-sm"
|
|
||||||
/>
|
|
||||||
{enabled && (
|
|
||||||
<Popover
|
|
||||||
aria-label="Basic popover"
|
|
||||||
position="bottom"
|
|
||||||
bodyContent={<div>{t("whoWillAppearPopoverTextUsers")}</div>}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="kc-who-will-appear-button"
|
|
||||||
key="who-will-appear-button"
|
|
||||||
icon={<QuestionCircleIcon />}
|
|
||||||
>
|
|
||||||
{t("whoWillAppearLinkTextUsers")}
|
|
||||||
</Button>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
name: "groupMembership",
|
|
||||||
displayKey: "groupMembership",
|
|
||||||
cellRenderer: (group: GroupRepresentation) => group.name || "-",
|
|
||||||
transforms: [cellWidth(40)],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "path",
|
|
||||||
displayKey: "path",
|
|
||||||
cellRenderer: (group: GroupRepresentation) => (
|
|
||||||
<GroupPath group={group} />
|
|
||||||
),
|
|
||||||
transforms: [cellWidth(45)],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
emptyState={
|
|
||||||
<ListEmptyState
|
|
||||||
hasIcon
|
|
||||||
message={t("noGroupMemberships")}
|
|
||||||
instructions={t("noGroupMembershipsText")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -363,7 +363,6 @@ export const GroupTree = ({
|
||||||
name="exact"
|
name="exact"
|
||||||
isChecked={exact}
|
isChecked={exact}
|
||||||
onChange={(_event, value) => setExact(value)}
|
onChange={(_event, value) => setExact(value)}
|
||||||
className="pf-v5-u-mr-xs"
|
|
||||||
/>
|
/>
|
||||||
</InputGroupItem>
|
</InputGroupItem>
|
||||||
<InputGroupItem>
|
<InputGroupItem>
|
||||||
|
|
|
@ -22,7 +22,7 @@ export default function NewOrganization() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { realm } = useRealm();
|
const { realm } = useRealm();
|
||||||
const form = useForm({ mode: "onChange" });
|
const form = useForm();
|
||||||
const { handleSubmit, formState } = form;
|
const { handleSubmit, formState } = form;
|
||||||
|
|
||||||
const save = async (org: OrganizationFormType) => {
|
const save = async (org: OrganizationFormType) => {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
||||||
import {
|
import {
|
||||||
FormErrorText,
|
|
||||||
HelpItem,
|
HelpItem,
|
||||||
TextAreaControl,
|
TextAreaControl,
|
||||||
TextControl,
|
TextControl,
|
||||||
|
@ -34,10 +33,7 @@ export const OrganizationForm = ({
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: OrganizationFormProps) => {
|
}: OrganizationFormProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const { setValue } = useFormContext();
|
||||||
setValue,
|
|
||||||
formState: { errors },
|
|
||||||
} = useFormContext();
|
|
||||||
const name = useWatch({ name: "name" });
|
const name = useWatch({ name: "name" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -68,18 +64,13 @@ export const OrganizationForm = ({
|
||||||
fieldLabelId="domain"
|
fieldLabelId="domain"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
isRequired
|
|
||||||
>
|
>
|
||||||
<MultiLineInput
|
<MultiLineInput
|
||||||
id="domain"
|
id="domain"
|
||||||
name="domains"
|
name="domains"
|
||||||
aria-label={t("domain")}
|
aria-label={t("domain")}
|
||||||
addButtonLabel="addDomain"
|
addButtonLabel="addDomain"
|
||||||
isRequired
|
|
||||||
/>
|
/>
|
||||||
{errors?.["domains"]?.message && (
|
|
||||||
<FormErrorText message={errors["domains"].message.toString()} />
|
|
||||||
)}
|
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<TextControl
|
<TextControl
|
||||||
label={t("redirectUrl")}
|
label={t("redirectUrl")}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
|
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
|
||||||
import { PageHandler } from "./PageHandler";
|
import { PageHandler } from "./PageHandler";
|
||||||
import { PAGE_PROVIDER } from "./constants";
|
import { PAGE_PROVIDER } from "./PageList";
|
||||||
import { PageParams, toPage } from "./routes";
|
import { PageParams, toPage } from "./routes";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { useAdminClient } from "../admin-client";
|
||||||
import { DynamicComponents } from "../components/dynamic/DynamicComponents";
|
import { DynamicComponents } from "../components/dynamic/DynamicComponents";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import { useParams } from "../utils/useParams";
|
import { useParams } from "../utils/useParams";
|
||||||
import { type PAGE_PROVIDER, TAB_PROVIDER } from "./constants";
|
import { type PAGE_PROVIDER, TAB_PROVIDER } from "./PageList";
|
||||||
import { toPage } from "./routes";
|
import { toPage } from "./routes";
|
||||||
|
|
||||||
type PageHandlerProps = {
|
type PageHandlerProps = {
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
import ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
|
import ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
|
||||||
import type { ComponentQuery } from "@keycloak/keycloak-admin-client/lib/resources/components";
|
import type { ComponentQuery } from "@keycloak/keycloak-admin-client/lib/resources/components";
|
||||||
import {
|
import { useAlerts } from "@keycloak/keycloak-ui-shared";
|
||||||
KeycloakDataTable,
|
|
||||||
ListEmptyState,
|
|
||||||
useAlerts,
|
|
||||||
} from "@keycloak/keycloak-ui-shared";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
|
@ -18,12 +14,16 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { useAdminClient } from "../admin-client";
|
import { useAdminClient } from "../admin-client";
|
||||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||||
|
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
|
||||||
|
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
|
||||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
|
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
|
||||||
import { PAGE_PROVIDER } from "./constants";
|
|
||||||
import { addDetailPage, PageListParams, toDetailPage } from "./routes";
|
import { addDetailPage, PageListParams, toDetailPage } from "./routes";
|
||||||
|
|
||||||
|
export const PAGE_PROVIDER = "org.keycloak.services.ui.extend.UiPageProvider";
|
||||||
|
export const TAB_PROVIDER = "org.keycloak.services.ui.extend.UiTabProvider";
|
||||||
|
|
||||||
type DetailLinkProps = {
|
type DetailLinkProps = {
|
||||||
obj: ComponentRepresentation;
|
obj: ComponentRepresentation;
|
||||||
field: string;
|
field: string;
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
export const PAGE_PROVIDER = "org.keycloak.services.ui.extend.UiPageProvider";
|
|
||||||
export const TAB_PROVIDER = "org.keycloak.services.ui.extend.UiTabProvider";
|
|
|
@ -205,14 +205,13 @@ export const UserGroups = ({ user }: UserGroupsProps) => {
|
||||||
refresh();
|
refresh();
|
||||||
}}
|
}}
|
||||||
isChecked={isDirectMembership}
|
isChecked={isDirectMembership}
|
||||||
className="pf-v5-u-mt-sm"
|
className="direct-membership-check"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => leave(selectedGroups)}
|
onClick={() => leave(selectedGroups)}
|
||||||
data-testid="leave-group-button"
|
data-testid="leave-group-button"
|
||||||
variant="link"
|
variant="link"
|
||||||
isDisabled={selectedGroups.length === 0}
|
isDisabled={selectedGroups.length === 0}
|
||||||
className="pf-v5-u-ml-md"
|
|
||||||
>
|
>
|
||||||
{t("leave")}
|
{t("leave")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -26,7 +26,7 @@ button#kc-join-groups-button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.kc-who-will-appear-button {
|
.kc-who-will-appear-button {
|
||||||
margin-left: var(--pf-v5-global--spacer--sm);
|
margin-left: var(--pf-v5-global--spacer--md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pf-v5-c-chip.kc-consents-chip::before {
|
.pf-v5-c-chip.kc-consents-chip::before {
|
||||||
|
|
|
@ -25,7 +25,6 @@ export default defineConfig(({ mode }) => {
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
outDir: "target/classes/theme/keycloak.v2/admin/resources",
|
outDir: "target/classes/theme/keycloak.v2/admin/resources",
|
||||||
external: ["src/index.ts"],
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
base: "",
|
base: "",
|
||||||
|
|
|
@ -284,8 +284,8 @@ importers:
|
||||||
specifier: ^10.9.2
|
specifier: ^10.9.2
|
||||||
version: 10.9.2(@swc/core@1.7.36)(@types/node@22.8.2)(typescript@5.6.3)
|
version: 10.9.2(@swc/core@1.7.36)(@types/node@22.8.2)(typescript@5.6.3)
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.1
|
||||||
version: 11.0.2
|
version: 11.0.1
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.10
|
specifier: ^5.4.10
|
||||||
version: 5.4.10(@types/node@22.8.2)(lightningcss@1.27.0)(terser@5.36.0)
|
version: 5.4.10(@types/node@22.8.2)(lightningcss@1.27.0)(terser@5.36.0)
|
||||||
|
@ -471,8 +471,8 @@ importers:
|
||||||
specifier: ^1.5.3
|
specifier: ^1.5.3
|
||||||
version: 1.5.3
|
version: 1.5.3
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.1
|
||||||
version: 11.0.2
|
version: 11.0.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@rollup/plugin-commonjs':
|
'@rollup/plugin-commonjs':
|
||||||
specifier: ^28.0.1
|
specifier: ^28.0.1
|
||||||
|
@ -4786,8 +4786,8 @@ packages:
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
uuid@11.0.2:
|
uuid@11.0.1:
|
||||||
resolution: {integrity: sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==}
|
resolution: {integrity: sha512-wt9UB5EcLhnboy1UvA1mvGPXkIIrHSu+3FmUksARfdVw9tuPf3CH/CohxO0Su1ApoKAeT6BVzAJIvjTuQVSmuQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uuid@8.3.2:
|
uuid@8.3.2:
|
||||||
|
@ -9752,7 +9752,7 @@ snapshots:
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
uuid@11.0.2: {}
|
uuid@11.0.1: {}
|
||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
|
|
|
@ -40,10 +40,6 @@
|
||||||
<artifactId>ojdbc11</artifactId>
|
<artifactId>ojdbc11</artifactId>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>com.oracle.database.jdbc</groupId>
|
|
||||||
<artifactId>ojdbc8</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"rfc4648": "^1.5.3",
|
"rfc4648": "^1.5.3",
|
||||||
"uuid": "^11.0.2"
|
"uuid": "^11.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-commonjs": "^28.0.1",
|
"@rollup/plugin-commonjs": "^28.0.1",
|
||||||
|
|
|
@ -92,13 +92,10 @@
|
||||||
"${url.ssoLoginInOtherTabsUrl?no_esc}"
|
"${url.ssoLoginInOtherTabsUrl?no_esc}"
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
<script>
|
|
||||||
// Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1404468
|
|
||||||
const isFirefox;
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="keycloak-bg" class="${properties.kcBodyClass!}">
|
<body id="keycloak-bg" class="${properties.kcBodyClass!}">
|
||||||
|
|
||||||
<div class="${properties.kcLogin!}">
|
<div class="${properties.kcLogin!}">
|
||||||
<div class="${properties.kcLoginContainer!}">
|
<div class="${properties.kcLoginContainer!}">
|
||||||
<header id="kc-header" class="pf-v5-c-login__header">
|
<header id="kc-header" class="pf-v5-c-login__header">
|
||||||
|
|
Loading…
Reference in a new issue