From f00130d37af527c2ac0c717f25805de8b8eb0f6b Mon Sep 17 00:00:00 2001 From: Alex Morel <amorel@codelutin.com> Date: Tue, 18 Jun 2024 16:49:24 +0200 Subject: [PATCH] Simplify ScimStorageProvider --- .../libre/scim/core/ScimClientInterface.java | 10 +- .../sh/libre/scim/core/ScimDispatcher.java | 27 +-- .../scim/core/ScrimProviderConfiguration.java | 33 ++-- .../scim/event/ScimEventListenerProvider.java | 8 +- .../scim/storage/ScimStorageProvider.java | 9 - .../storage/ScimStorageProviderFactory.java | 164 +++++++++--------- 6 files changed, 125 insertions(+), 126 deletions(-) delete mode 100644 src/main/java/sh/libre/scim/storage/ScimStorageProvider.java diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java index 1affb82..d28f289 100644 --- a/src/main/java/sh/libre/scim/core/ScimClientInterface.java +++ b/src/main/java/sh/libre/scim/core/ScimClientInterface.java @@ -5,14 +5,14 @@ import org.keycloak.storage.user.SynchronizationResult; /** * An interface for defining ScimClient. - * A ScimClient provides methods for propagating CRUD and sync of a dedicated SCIM Resource (e.g. {@link de.captaingoldfish.scim.sdk.common.resources.User}). to a SCIM server defined in a {@link sh.libre.scim.storage.ScimStorageProvider}. + * A ScimClient provides methods for propagating CRUD and sync of a dedicated SCIM Resource (e.g. {@link de.captaingoldfish.scim.sdk.common.resources.User}). to a Scim endpoint defined in a {@link sh.libre.scim.storage.ScimStorageProvider}. * * @param <M> the keycloack model to synchronize (e.g. UserModel or GroupModel) */ public interface ScimClientInterface<M extends RoleMapperModel> extends AutoCloseable { /** - * Propagates the creation of the given keycloack model to a SCIM server. + * Propagates the creation of the given keycloack model to a Scim endpoint. * * @param resource the created resource to propagate (e.g. a new UserModel) */ @@ -20,21 +20,21 @@ public interface ScimClientInterface<M extends RoleMapperModel> extends AutoClos void create(M resource); /** - * Propagates the update of the given keycloack model to a SCIM server. + * Propagates the update of the given keycloack model to a Scim endpoint. * * @param resource the resource creation to propagate (e.g. a UserModel) */ void replace(M resource); /** - * Propagates the deletion of an element to a SCIM server. + * Propagates the deletion of an element to a Scim endpoint. * * @param id the deleted resource's id to propagate (e.g. id of a UserModel) */ void delete(String id); /** - * Synchronizes resources between SCIM server and keycloack, according to configuration. + * Synchronizes resources between Scim endpoint and keycloack, according to configuration. * * @param result the synchronization result to update for indicating triggered operations (e.g. user deletions) */ diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index dcbe886..3434911 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -9,7 +9,7 @@ import java.util.function.Consumer; import java.util.stream.Stream; /** - * In charge of sending SCIM Request to all registered SCIM servers. + * In charge of sending SCIM Request to all registered Scim endpoints. */ public class ScimDispatcher { @@ -22,12 +22,12 @@ public class ScimDispatcher { } public void dispatchUserModificationToAll(Consumer<UserScimClient> operationToDispatch) { - getAllSCIMServer(Scope.USER).forEach(scimServer -> dispatchUserModificationToOne(scimServer, operationToDispatch)); + getAllSCIMServer(Scope.USER).forEach(scimServerConfiguration -> dispatchUserModificationToOne(scimServerConfiguration, operationToDispatch)); } - public void dispatchUserModificationToOne(ComponentModel scimServer, Consumer<UserScimClient> operationToDispatch) { - LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType()); - try (UserScimClient client = UserScimClient.newUserScimClient(scimServer, session)) { + public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimClient> operationToDispatch) { + LOGGER.infof("%s %s %s %s", scimServerConfiguration.getId(), scimServerConfiguration.getName(), scimServerConfiguration.getProviderId(), scimServerConfiguration.getProviderType()); + try (UserScimClient client = UserScimClient.newUserScimClient(scimServerConfiguration, session)) { operationToDispatch.accept(client); } catch (Exception e) { LOGGER.error(e); @@ -35,12 +35,12 @@ public class ScimDispatcher { } public void dispatchGroupModificationToAll(Consumer<GroupScimClient> operationToDispatch) { - getAllSCIMServer(Scope.GROUP).forEach(scimServer -> dispatchGroupModificationToOne(scimServer, operationToDispatch)); + getAllSCIMServer(Scope.GROUP).forEach(scimServerConfiguration -> dispatchGroupModificationToOne(scimServerConfiguration, operationToDispatch)); } - public void dispatchGroupModificationToOne(ComponentModel scimServer, Consumer<GroupScimClient> operationToDispatch) { - LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType()); - try (GroupScimClient client = GroupScimClient.newGroupScimClient(scimServer, session)) { + public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer<GroupScimClient> operationToDispatch) { + LOGGER.infof("%s %s %s %s", scimServerConfiguration.getId(), scimServerConfiguration.getName(), scimServerConfiguration.getProviderId(), scimServerConfiguration.getProviderType()); + try (GroupScimClient client = GroupScimClient.newGroupScimClient(scimServerConfiguration, session)) { operationToDispatch.accept(client); } catch (Exception e) { LOGGER.error(e); @@ -49,14 +49,19 @@ public class ScimDispatcher { /** * @param scope The {@link Scope} to consider (User or Group) - * @return all enabled registered SCIM Servers with propagation enabled for the given scope + * @return all enabled registered Scim endpoints with propagation enabled for the given scope */ private Stream<ComponentModel> getAllSCIMServer(Scope scope) { // TODO : we could initiative this list once and invalidate it when configuration changes + + String propagationConfKey = switch (scope) { + case GROUP -> ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP; + case USER -> ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER; + }; return session.getContext().getRealm().getComponentsStream() .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true) - && m.get("propagation-" + scope.name(), false)); + && m.get(propagationConfKey, false)); } public enum Scope { diff --git a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java index c689385..cec792d 100644 --- a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java +++ b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java @@ -4,6 +4,17 @@ import de.captaingoldfish.scim.sdk.client.http.BasicAuth; import org.keycloak.component.ComponentModel; public class ScrimProviderConfiguration { + // 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"; private final String endPoint; private final String id; @@ -14,25 +25,25 @@ public class ScrimProviderConfiguration { private final boolean syncRefresh; public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) { - AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get("auth-mode")); + AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE)); authorizationHeaderValue = switch (authMode) { - case BEARER -> "Bearer " + scimProviderConfiguration.get("auth-pass"); + case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD); case BASIC_AUTH -> { BasicAuth basicAuth = BasicAuth.builder() - .username(scimProviderConfiguration.get("auth-user")) - .password(scimProviderConfiguration.get("auth-pass")) + .username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER)) + .password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD)) .build(); yield basicAuth.getAuthorizationHeaderValue(); } default -> throw new IllegalArgumentException("authMode " + scimProviderConfiguration + " is not supported"); }; - contentType = scimProviderConfiguration.get("content-type"); - endPoint = scimProviderConfiguration.get("endpoint"); + contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE); + endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT); id = scimProviderConfiguration.getId(); - importAction = ImportAction.valueOf(scimProviderConfiguration.get("sync-import-action")); - syncImport = scimProviderConfiguration.get("sync-import", false); - syncRefresh = scimProviderConfiguration.get("sync-refresh", false); + importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); + syncImport = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); + syncRefresh = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); } public boolean isSyncRefresh() { @@ -67,10 +78,6 @@ public class ScrimProviderConfiguration { 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/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index ef68c55..abfc3da 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -19,7 +19,7 @@ import java.util.regex.Pattern; /** * An {@link java.util.EventListener} in charge of reaction to Keycloak models * modification (e.g. User creation, Group deletion, membership modifications...) - * by propagating it to all registered SCIM servers. + * by propagating it to all registered Scim endpoints. */ public class ScimEventListenerProvider implements EventListenerProvider { @@ -59,7 +59,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { } case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId)); default -> { - // No other event has to be propagated to SCIM Servers + // No other event has to be propagated to Scim endpoints } } } @@ -95,7 +95,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { handleRoleMappingEvent(event, type, id); } default -> { - // No other resource modification has to be propagated to SCIM Servers + // No other resource modification has to be propagated to Scim endpoints } } } @@ -122,7 +122,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { } /** - * Propagating the given group-related event to SCIM servers. + * Propagating the given group-related event to Scim endpoints. * * @param event the event to propagate * @param groupId event target's id diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java b/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java deleted file mode 100644 index 1949606..0000000 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package sh.libre.scim.storage; - -import org.keycloak.storage.UserStorageProvider; - -public class ScimStorageProvider implements UserStorageProvider { - @Override - public void close() { - } -} diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index e5f4b36..62b931b 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -8,45 +8,96 @@ import org.keycloak.component.ComponentModel; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.KeycloakSessionTask; 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 org.keycloak.timer.TimerProvider; -import sh.libre.scim.core.GroupAdapter; import sh.libre.scim.core.ScimDispatcher; -import sh.libre.scim.core.UserAdapter; +import sh.libre.scim.core.ScrimProviderConfiguration; import java.time.Duration; import java.util.Date; import java.util.List; public class ScimStorageProviderFactory - implements UserStorageProviderFactory<ScimStorageProvider>, ImportSynchronization { + implements UserStorageProviderFactory<ScimStorageProviderFactory.ScimStorageProvider>, ImportSynchronization { + public static final String ID = "scim"; + private final Logger logger = Logger.getLogger(ScimStorageProviderFactory.class); - private final Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class); + @Override + public String getId() { + return ID; + } - public static final String ID = "scim"; - private static final List<ProviderConfigProperty> CONFIG_METADATA; + @Override + public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, + UserStorageProviderModel model) { + // TODO if this should be kept here, better document prupose & usage + logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s", realmId); + 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("propagation-user"))) { + dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); + } + if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { + dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); + } + }); + return result; + } - static { - CONFIG_METADATA = ProviderConfigurationBuilder.create() + @Override + public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, + UserStorageProviderModel model) { + return this.sync(sessionFactory, realmId, model); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // TODO : find a better way to handle scim dirty (use a QUEUE for SCIM queries ?) + try (KeycloakSession keycloakSession = factory.create()) { + TimerProvider timer = keycloakSession.getProvider(TimerProvider.class); + timer.scheduleTask(taskSession -> { + for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { + KeycloakModelUtils.runJobInTransaction(factory, session -> { + session.getContext().setRealm(realm); + ScimDispatcher dispatcher = new ScimDispatcher(session); + for (GroupModel group : session.groups().getGroupsStream(realm) + .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) { + logger.infof("[SCIM] Dirty group : %s", group.getName()); + dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); + group.removeAttribute("scim-dirty"); + } + }); + } + }, Duration.ofSeconds(30).toMillis(), "scim-background"); + } + } + + @Override + public List<ProviderConfigProperty> getConfigProperties() { + // These Config Properties will be use to generate configuration page in Admin Console + return ProviderConfigurationBuilder.create() .property() - .name("endpoint") + .name(ScrimProviderConfiguration.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)") + "URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)") .add() .property() - .name("content-type") + .name(ScrimProviderConfiguration.CONF_KEY_CONTENT_TYPE) .type(ProviderConfigProperty.LIST_TYPE) .label("Endpoint content type") .helpText("Only used when endpoint doesn't support application/scim+json") @@ -54,7 +105,7 @@ public class ScimStorageProviderFactory .defaultValue(HttpHeader.SCIM_CONTENT_TYPE) .add() .property() - .name("auth-mode") + .name(ScrimProviderConfiguration.CONF_KEY_AUTH_MODE) .type(ProviderConfigProperty.LIST_TYPE) .label("Auth mode") .helpText("Select the authorization mode") @@ -62,38 +113,38 @@ public class ScimStorageProviderFactory .defaultValue("NONE") .add() .property() - .name("auth-user") + .name(ScrimProviderConfiguration.CONF_KEY_AUTH_USER) .type(ProviderConfigProperty.STRING_TYPE) .label("Auth username") .helpText("Required for basic authentication.") .add() .property() - .name("auth-pass") + .name(ScrimProviderConfiguration.CONF_KEY_AUTH_PASSWORD) .type(ProviderConfigProperty.PASSWORD) .label("Auth password/token") .helpText("Password or token required for basic or bearer authentication.") .add() .property() - .name("propagation-user") + .name(ScrimProviderConfiguration.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("propagation-group") + .name(ScrimProviderConfiguration.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("sync-import") + .name(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable import during sync") .add() .property() - .name("sync-import-action") + .name(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT_ACTION) .type(ProviderConfigProperty.LIST_TYPE) .label("Import action") .helpText("What to do when the user doesn't exists in Keycloak.") @@ -101,81 +152,26 @@ public class ScimStorageProviderFactory .defaultValue("CREATE_LOCAL") .add() .property() - .name("sync-refresh") + .name(ScrimProviderConfiguration.CONF_KEY_SYNC_REFRESH) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable refresh during sync") .add() .build(); } + @Override public ScimStorageProvider create(KeycloakSession session, ComponentModel model) { - LOGGER.info("create"); return new ScimStorageProvider(); } - @Override - public String getId() { - return ID; - } - - @Override - public List<ProviderConfigProperty> getConfigProperties() { - return CONFIG_METADATA; - } - - @Override - public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, - UserStorageProviderModel model) { - LOGGER.info("sync"); - SynchronizationResult result = new SynchronizationResult(); - KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { - - @Override - public void run(KeycloakSession session) { - RealmModel realm = session.realms().getRealm(realmId); - session.getContext().setRealm(realm); - ScimDispatcher dispatcher = new ScimDispatcher(session); - if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { - dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); - } - if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { - dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); - } - } - - }); - - 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) { - TimerProvider timer = factory.create().getProvider(TimerProvider.class); - timer.scheduleTask(taskSession -> { - for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { - KeycloakModelUtils.runJobInTransaction(factory, new KeycloakSessionTask() { - @Override - public void run(KeycloakSession session) { - session.getContext().setRealm(realm); - ScimDispatcher dispatcher = new ScimDispatcher(session); - for (GroupModel group : session.groups().getGroupsStream(realm) - .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) { - LOGGER.debug(group.getName() + " is dirty"); - dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); - group.removeAttribute("scim-dirty"); - } - } - - }); - } - }, Duration.ofSeconds(30).toMillis(), "scim-background"); + /** + * Empty implementation : we used this {@link ScimStorageProviderFactory} to generate Admin Console page. + */ + public static final class ScimStorageProvider implements UserStorageProvider { + @Override + public void close() { + // Nothing to close here + } } } -- GitLab