diff --git a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java new file mode 100644 index 0000000000..c68938511b --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java @@ -0,0 +1,77 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.client.http.BasicAuth; +import org.keycloak.component.ComponentModel; + +public class ScrimProviderConfiguration { + + private final String endPoint; + private final String id; + private final String contentType; + private final String authorizationHeaderValue; + private final ImportAction importAction; + private final boolean syncImport; + private final boolean syncRefresh; + + public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) { + AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get("auth-mode")); + authorizationHeaderValue = switch (authMode) { + case BEARER -> "Bearer " + scimProviderConfiguration.get("auth-pass"); + case BASIC_AUTH -> { + BasicAuth basicAuth = BasicAuth.builder() + .username(scimProviderConfiguration.get("auth-user")) + .password(scimProviderConfiguration.get("auth-pass")) + .build(); + yield basicAuth.getAuthorizationHeaderValue(); + } + default -> + throw new IllegalArgumentException("authMode " + scimProviderConfiguration + " is not supported"); + }; + contentType = scimProviderConfiguration.get("content-type"); + endPoint = scimProviderConfiguration.get("endpoint"); + id = scimProviderConfiguration.getId(); + importAction = ImportAction.valueOf(scimProviderConfiguration.get("sync-import-action")); + syncImport = scimProviderConfiguration.get("sync-import", false); + syncRefresh = scimProviderConfiguration.get("sync-refresh", false); + } + + public boolean isSyncRefresh() { + return syncRefresh; + } + + public boolean isSyncImport() { + return syncImport; + } + + public String getContentType() { + return contentType; + } + + public String getAuthorizationHeaderValue() { + return authorizationHeaderValue; + } + + public String getId() { + return id; + } + + public ImportAction getImportAction() { + return importAction; + } + + public String getEndPoint() { + return endPoint; + } + + public enum AuthMode { + BEARER, BASIC_AUTH, NONE + } + + public enum EndpointContentType { + JSON, SCIM_JSON + } + + public enum ImportAction { + CREATE_LOCAL, DELETE_REMOTE, NOTHING + } +} diff --git a/src/main/java/sh/libre/scim/core/UserScimClient.java b/src/main/java/sh/libre/scim/core/UserScimClient.java new file mode 100644 index 0000000000..789d852d68 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/UserScimClient.java @@ -0,0 +1,279 @@ +package sh.libre.scim.core; + +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.exceptions.ResponseException; +import de.captaingoldfish.scim.sdk.common.resources.User; +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.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.ws.rs.ProcessingException; +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.storage.user.SynchronizationResult; +import sh.libre.scim.jpa.ScimResource; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class UserScimClient implements AutoCloseable { + + private static final Logger LOGGER = Logger.getLogger(UserScimClient.class); + + private final ScimRequestBuilder scimRequestBuilder; + + private final RetryRegistry retryRegistry; + + private final KeycloakSession keycloakSession; + + private final ScrimProviderConfiguration scimProviderConfiguration; + + /** + * Builds a new {@link UserScimClient} + * + * @param scimRequestBuilder + * @param retryRegistry Retry policy to use + * @param keycloakSession + * @param scimProviderConfiguration + */ + private UserScimClient(ScimRequestBuilder scimRequestBuilder, RetryRegistry retryRegistry, KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) { + this.scimRequestBuilder = scimRequestBuilder; + this.retryRegistry = retryRegistry; + this.keycloakSession = keycloakSession; + this.scimProviderConfiguration = scimProviderConfiguration; + } + + + public static UserScimClient newUserScimClient(ComponentModel componentModel, KeycloakSession session) { + ScrimProviderConfiguration scimProviderConfiguration = new ScrimProviderConfiguration(componentModel); + Map httpHeaders = new HashMap<>(); + httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue()); + httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType()); + + ScimClientConfig scimClientConfig = ScimClientConfig.builder() + .httpHeaders(httpHeaders) + .connectTimeout(5) + .requestTimeout(5) + .socketTimeout(5) + .expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful? + // TODO Question Indiehoster : should we really allow connection with TLS ? .hostnameVerifier((s, sslSession) -> true) + .build(); + + String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); + ScimRequestBuilder scimRequestBuilder = + new ScimRequestBuilder( + scimApplicationBaseUrl, + scimClientConfig + ); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(10) + .intervalFunction(IntervalFunction.ofExponentialBackoff()) + .retryExceptions(ProcessingException.class) + .build(); + + RetryRegistry retryRegistry = RetryRegistry.of(retryConfig); + return new UserScimClient(scimRequestBuilder, retryRegistry, session, scimProviderConfiguration); + } + + public void create(UserModel userModel) { + UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); + adapter.apply(userModel); + if (adapter.skip) + return; + // If mapping exist then it was created by import so skip. + if (adapter.query("findById", adapter.getId()).getResultList().isEmpty()) { + return; + } + Retry retry = retryRegistry.retry("create-" + adapter.getId()); + ServerResponse response = retry.executeSupplier(() -> { + try { + return scimRequestBuilder + .create(adapter.getResourceClass(), adapter.getScimEndpoint()) + .setResource(adapter.toScim()) + .sendRequest(); + } catch (ResponseException e) { + throw new RuntimeException(e); + } + }); + + if (!response.isSuccess()) { + LOGGER.warn(response.getResponseBody()); + LOGGER.warn(response.getHttpStatus()); + } + + adapter.apply(response.getResource()); + adapter.saveMapping(); + } + + public void replace(UserModel userModel) { + UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); + try { + adapter.apply(userModel); + if (adapter.skip) + return; + ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult(); + adapter.apply(resource); + Retry retry = retryRegistry.retry("replace-" + adapter.getId()); + ServerResponse response = retry.executeSupplier(() -> { + try { + return scimRequestBuilder + .update(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) + .setResource(adapter.toScim()) + .sendRequest(); + } catch (ResponseException e) { + throw new RuntimeException(e); + } + }); + if (!response.isSuccess()) { + LOGGER.warn(response.getResponseBody()); + LOGGER.warn(response.getHttpStatus()); + } + } catch (NoResultException e) { + LOGGER.warnf("failed to replace resource %s, scim mapping not found", adapter.getId()); + } catch (Exception e) { + LOGGER.error(e); + } + } + + public void delete(String id) { + UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); + adapter.setId(id); + + try { + ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult(); + adapter.apply(resource); + + Retry retry = retryRegistry.retry("delete-" + id); + ServerResponse response = retry.executeSupplier(() -> { + try { + return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) + .sendRequest(); + } catch (ResponseException e) { + throw new RuntimeException(e); + } + }); + + if (!response.isSuccess()) { + LOGGER.warn(response.getResponseBody()); + LOGGER.warn(response.getHttpStatus()); + } + EntityManager entityManager = this.keycloakSession.getProvider(JpaConnectionProvider.class).getEntityManager(); + entityManager.remove(resource); + } catch (NoResultException e) { + LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id); + } + } + + public void refreshResources( + SynchronizationResult syncRes) { + LOGGER.info("Refresh resources"); + UserAdapter a = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); + a.getResourceStream().forEach(resource -> { + UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); + adapter.apply(resource); + LOGGER.infof("Reconciling local resource %s", adapter.getId()); + if (!adapter.skipRefresh()) { + ScimResource mapping = adapter.getMapping(); + if (mapping == null) { + LOGGER.info("Creating it"); + this.create(resource); + } else { + LOGGER.info("Replacing it"); + this.replace(resource); + } + syncRes.increaseUpdated(); + } + }); + + } + + public void importResources(SynchronizationResult syncRes) { + LOGGER.info("Import"); + try { + UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); + ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); + ListResponse resourceTypeListResponse = response.getResource(); + + for (User resource : resourceTypeListResponse.getListedResources()) { + try { + LOGGER.infof("Reconciling remote resource %s", resource); + adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); + adapter.apply(resource); + + ScimResource mapping = adapter.getMapping(); + if (mapping != null) { + adapter.apply(mapping); + if (adapter.entityExists()) { + LOGGER.info("Valid mapping found, skipping"); + continue; + } else { + LOGGER.info("Delete a dangling mapping"); + adapter.deleteMapping(); + } + } + + Boolean mapped = adapter.tryToMap(); + if (mapped) { + LOGGER.info("Matched"); + adapter.saveMapping(); + } else { + switch (this.scimProviderConfiguration.getImportAction()) { + case CREATE_LOCAL: + LOGGER.info("Create local resource"); + try { + adapter.createEntity(); + adapter.saveMapping(); + syncRes.increaseAdded(); + } catch (Exception e) { + LOGGER.error(e); + } + break; + case DELETE_REMOTE: + LOGGER.info("Delete remote resource"); + scimRequestBuilder + .delete(adapter.getResourceClass(), adapter.getScimEndpoint(), resource.getId().get()) + .sendRequest(); + syncRes.increaseRemoved(); + break; + case NOTHING: + LOGGER.info("Import action set to NOTHING"); + break; + } + } + } catch (Exception e) { + LOGGER.error(e); + e.printStackTrace(); + syncRes.increaseFailed(); + } + } + } catch (ResponseException e) { + throw new RuntimeException(e); + } + } + + public void sync(SynchronizationResult syncRes) { + if (this.scimProviderConfiguration.isSyncImport()) { + this.importResources(syncRes); + } + if (this.scimProviderConfiguration.isSyncRefresh()) { + this.refreshResources(syncRes); + } + } + + + @Override + public void close() { + scimRequestBuilder.close(); + } +}