Simplify ScimStorageProvider

This commit is contained in:
Alex Morel 2024-06-18 16:49:24 +02:00
parent 764767185e
commit f00130d37a
6 changed files with 161 additions and 162 deletions

View file

@ -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)
*/

View file

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

View file

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

View file

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

View file

@ -1,9 +0,0 @@
package sh.libre.scim.storage;
import org.keycloak.storage.UserStorageProvider;
public class ScimStorageProvider implements UserStorageProvider {
@Override
public void close() {
}
}

View file

@ -8,131 +8,41 @@ 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 {
private final Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class);
implements UserStorageProviderFactory<ScimStorageProviderFactory.ScimStorageProvider>, ImportSynchronization {
public static final String ID = "scim";
private static final List<ProviderConfigProperty> 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<ProviderConfigProperty> 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) {
KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> {
RealmModel realm = session.realms().getRealm(realmId);
session.getContext().setRealm(realm);
ScimDispatcher dispatcher = new ScimDispatcher(session);
@ -142,12 +52,8 @@ public class ScimStorageProviderFactory
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);
// 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, new KeycloakSessionTask() {
@Override
public void run(KeycloakSession session) {
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");
}
}
@Override
public List<ProviderConfigProperty> 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
}
}
}