Simplfy code by splitting UserScimClient and GroupSclient - basic UserScimClient

This commit is contained in:
Alex Morel 2024-06-17 16:35:12 +02:00
parent 3ef6c81e98
commit 9a45e9c30f
2 changed files with 356 additions and 0 deletions

View file

@ -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
}
}

View file

@ -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<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)
.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<User> 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<User> 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<User> 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<ListResponse<User>> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest();
ListResponse<User> 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();
}
}