diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java index 1affb82c71..d28f289fae 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 the keycloack model to synchronize (e.g. UserModel or GroupModel) */ public interface ScimClientInterface 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 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 dcbe886afe..3434911014 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 operationToDispatch) { - getAllSCIMServer(Scope.USER).forEach(scimServer -> dispatchUserModificationToOne(scimServer, operationToDispatch)); + getAllSCIMServer(Scope.USER).forEach(scimServerConfiguration -> dispatchUserModificationToOne(scimServerConfiguration, operationToDispatch)); } - public void dispatchUserModificationToOne(ComponentModel scimServer, Consumer 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 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 operationToDispatch) { - getAllSCIMServer(Scope.GROUP).forEach(scimServer -> dispatchGroupModificationToOne(scimServer, operationToDispatch)); + getAllSCIMServer(Scope.GROUP).forEach(scimServerConfiguration -> dispatchGroupModificationToOne(scimServerConfiguration, operationToDispatch)); } - public void dispatchGroupModificationToOne(ComponentModel scimServer, Consumer 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 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 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 c68938511b..cec792def0 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 ef68c552e7..abfc3da89c 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 19496069fe..0000000000 --- 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 e5f4b36ee7..62b931bb1a 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -8,146 +8,52 @@ 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, ImportSynchronization { - - private final Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class); - + implements UserStorageProviderFactory, ImportSynchronization { public static final String ID = "scim"; - - private static final List CONFIG_METADATA; - - static { - CONFIG_METADATA = ProviderConfigurationBuilder.create() - .property() - .name("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("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("auth-mode") - .type(ProviderConfigProperty.LIST_TYPE) - .label("Auth mode") - .helpText("Select the authorization mode") - .options("NONE", "BASIC_AUTH", "BEARER") - .defaultValue("NONE") - .add() - .property() - .name("auth-user") - .type(ProviderConfigProperty.STRING_TYPE) - .label("Auth username") - .helpText("Required for basic authentication.") - .add() - .property() - .name("auth-pass") - .type(ProviderConfigProperty.PASSWORD) - .label("Auth password/token") - .helpText("Password or token required for basic or bearer authentication.") - .add() - .property() - .name("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") - .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") - .type(ProviderConfigProperty.BOOLEAN_TYPE) - .label("Enable import during sync") - .add() - .property() - .name("sync-import-action") - .type(ProviderConfigProperty.LIST_TYPE) - .label("Import action") - .helpText("What to do when the user doesn't exists in Keycloak.") - .options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE") - .defaultValue("CREATE_LOCAL") - .add() - .property() - .name("sync-refresh") - .type(ProviderConfigProperty.BOOLEAN_TYPE) - .label("Enable refresh during sync") - .add() - .build(); - } - - @Override - public ScimStorageProvider create(KeycloakSession session, ComponentModel model) { - LOGGER.info("create"); - return new ScimStorageProvider(); - } + private final Logger logger = Logger.getLogger(ScimStorageProviderFactory.class); @Override public String getId() { return ID; } - @Override - public List getConfigProperties() { - return CONFIG_METADATA; - } @Override public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) { - LOGGER.info("sync"); + // 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, 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)); - } + 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; - } @Override @@ -158,24 +64,114 @@ public class ScimStorageProviderFactory @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) { + // 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.debug(group.getName() + " is dirty"); + logger.infof("[SCIM] Dirty group : %s", group.getName()); dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); group.removeAttribute("scim-dirty"); } - } + }); + } + }, Duration.ofSeconds(30).toMillis(), "scim-background"); + } + } - }); - } - }, Duration.ofSeconds(30).toMillis(), "scim-background"); + @Override + public List getConfigProperties() { + // These Config Properties will be use to generate configuration page in Admin Console + return ProviderConfigurationBuilder.create() + .property() + .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)") + .add() + .property() + .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") + .options(MediaType.APPLICATION_JSON, HttpHeader.SCIM_CONTENT_TYPE) + .defaultValue(HttpHeader.SCIM_CONTENT_TYPE) + .add() + .property() + .name(ScrimProviderConfiguration.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(ScrimProviderConfiguration.CONF_KEY_AUTH_USER) + .type(ProviderConfigProperty.STRING_TYPE) + .label("Auth username") + .helpText("Required for basic authentication.") + .add() + .property() + .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(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(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(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Enable import during sync") + .add() + .property() + .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.") + .options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE") + .defaultValue("CREATE_LOCAL") + .add() + .property() + .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) { + return new ScimStorageProvider(); + } + + /** + * 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 + } } }