Update SCIM clients when configuration changes and improve ScimDispatcher lifecycle

This commit is contained in:
Alex Morel 2024-06-20 16:00:14 +02:00
parent 633291d401
commit 2ded3f7236
6 changed files with 137 additions and 53 deletions

View file

@ -37,6 +37,6 @@ public class GroupScimClient implements ScimClientInterface<GroupModel> {
@Override
public void close() throws Exception {
throw new UnsupportedOperationException();
}
}

View file

@ -6,7 +6,9 @@ import org.keycloak.models.KeycloakSession;
import sh.libre.scim.storage.ScimStorageProviderFactory;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
@ -17,13 +19,21 @@ public class ScimDispatcher {
private static final Logger logger = Logger.getLogger(ScimDispatcher.class);
private static final Map<KeycloakSession, ScimDispatcher> sessionToScimDispatcher = new LinkedHashMap<>();
private final KeycloakSession session;
private boolean clientsInitialized = false;
private final List<UserScimClient> userScimClients = new ArrayList<>();
private final List<GroupScimClient> groupScimClients = new ArrayList<>();
public ScimDispatcher(KeycloakSession session) {
public static ScimDispatcher createForSession(KeycloakSession session) {
// Only create a scim dispatcher if there is none already created for session
sessionToScimDispatcher.computeIfAbsent(session, ScimDispatcher::new);
return sessionToScimDispatcher.get(session);
}
private ScimDispatcher(KeycloakSession session) {
this.session = session;
refreshActiveScimEndpoints();
}
/**
@ -35,15 +45,18 @@ public class ScimDispatcher {
for (GroupScimClient c : groupScimClients) {
c.close();
}
groupScimClients.clear();
for (UserScimClient c : userScimClients) {
c.close();
}
userScimClients.clear();
// Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory)
session.getContext().getRealm().getComponentsStream()
.filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId())
&& m.get("enabled", true))
.forEach(scimEndpoint -> {
try {
// Step 3 : create scim clients for each endpoint
if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
GroupScimClient groupScimClient = GroupScimClient.newGroupScimClient(scimEndpoint, session);
@ -53,6 +66,10 @@ public class ScimDispatcher {
UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session);
userScimClients.add(userScimClient);
}
} catch (Exception e) {
logger.warnf("[SCIM] Invalid Endpoint configuration %s : %s", scimEndpoint.getId(), e.getMessage());
// TODO is it ok to log and try to create the other clients ?
}
});
} catch (Exception e) {
logger.error("[SCIM] Error while refreshing scim clients ", e);
@ -61,25 +78,24 @@ public class ScimDispatcher {
}
public void dispatchUserModificationToAll(Consumer<UserScimClient> operationToDispatch) {
// TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes
refreshActiveScimEndpoints();
initializeClientsIfNeeded();
userScimClients.forEach(operationToDispatch);
logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimClients.size());
}
public void dispatchGroupModificationToAll(Consumer<GroupScimClient> operationToDispatch) {
// TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes
refreshActiveScimEndpoints();
initializeClientsIfNeeded();
groupScimClients.forEach(operationToDispatch);
logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimClients.size());
}
public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimClient> operationToDispatch) {
// TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes
refreshActiveScimEndpoints();
initializeClientsIfNeeded();
// Scim client should already have been created
Optional<UserScimClient> matchingClient = userScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
if (matchingClient.isPresent()) {
operationToDispatch.accept(matchingClient.get());
logger.infof("[SCIM] User operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId());
} else {
logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId());
}
@ -87,15 +103,33 @@ public class ScimDispatcher {
public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer<GroupScimClient> operationToDispatch) {
// TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes
refreshActiveScimEndpoints();
initializeClientsIfNeeded();
// Scim client should already have been created
Optional<GroupScimClient> matchingClient = groupScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
if (matchingClient.isPresent()) {
operationToDispatch.accept(matchingClient.get());
logger.infof("[SCIM] Group operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId());
} else {
logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId());
}
}
public void close() throws Exception {
sessionToScimDispatcher.remove(session);
for (GroupScimClient c : groupScimClients) {
c.close();
}
for (UserScimClient c : userScimClients) {
c.close();
}
groupScimClients.clear();
userScimClients.clear();
}
private void initializeClientsIfNeeded() {
if (!clientsInitialized) {
clientsInitialized = true;
refreshActiveScimEndpoints();
}
}
}

View file

@ -25,7 +25,9 @@ public class ScrimProviderConfiguration {
private final boolean syncRefresh;
public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) {
try {
AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE));
authorizationHeaderValue = switch (authMode) {
case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD);
case BASIC_AUTH -> {
@ -35,15 +37,17 @@ public class ScrimProviderConfiguration {
.build();
yield basicAuth.getAuthorizationHeaderValue();
}
default ->
throw new IllegalArgumentException("authMode " + scimProviderConfiguration + " is not supported");
case NONE -> "";
};
contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE);
endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT);
contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE, "");
endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT, "");
id = scimProviderConfiguration.getId();
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);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("authMode '" + scimProviderConfiguration.get(CONF_KEY_AUTH_MODE) + "' is not supported");
}
}
public boolean isSyncRefresh() {

View file

@ -6,6 +6,7 @@ import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventType;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
@ -33,12 +34,13 @@ public class ScimEventListenerProvider implements EventListenerProvider {
ResourceType.USER, Pattern.compile("users/(.+)"),
ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"),
ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)"),
ResourceType.REALM_ROLE_MAPPING, Pattern.compile("^(.+)/(.+)/role-mappings")
ResourceType.REALM_ROLE_MAPPING, Pattern.compile("^(.+)/(.+)/role-mappings"),
ResourceType.COMPONENT, Pattern.compile("components/(.+)")
);
public ScimEventListenerProvider(KeycloakSession session) {
this.session = session;
dispatcher = new ScimDispatcher(session);
this.dispatcher = ScimDispatcher.createForSession(session);
}
@ -47,23 +49,28 @@ public class ScimEventListenerProvider implements EventListenerProvider {
// React to User-related event : creation, deletion, update
EventType eventType = event.getType();
String eventUserId = event.getUserId();
LOGGER.infof("[SCIM] Propagate User Event %s - %s", eventType, eventUserId);
switch (eventType) {
case REGISTER -> {
LOGGER.infof("[SCIM] Propagate User Registration - %s", eventUserId);
UserModel user = getUser(eventUserId);
dispatcher.dispatchUserModificationToAll(client -> client.create(user));
}
case UPDATE_EMAIL, UPDATE_PROFILE -> {
LOGGER.infof("[SCIM] Propagate User %s - %s", eventType, eventUserId);
UserModel user = getUser(eventUserId);
dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
}
case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId));
case DELETE_ACCOUNT -> {
LOGGER.infof("[SCIM] Propagate User deletion - %s", eventUserId);
dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId));
}
default -> {
// No other event has to be propagated to Scim endpoints
}
}
}
@Override
public void onEvent(AdminEvent event, boolean includeRepresentation) {
// Step 1: check if event is relevant for propagation through SCIM
@ -74,6 +81,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
if (!matcher.find())
return;
// Step 2: propagate event (if needed) according to its resource type
switch (event.getResourceType()) {
case USER -> {
@ -94,12 +102,18 @@ public class ScimEventListenerProvider implements EventListenerProvider {
String id = matcher.group(2);
handleRoleMappingEvent(event, type, id);
}
case COMPONENT -> {
String id = matcher.group(1);
handleScimEndpointConfigurationEvent(event, id);
}
default -> {
// No other resource modification has to be propagated to Scim endpoints
}
}
}
private void handleUserEvent(AdminEvent userEvent, String userId) {
LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId);
switch (userEvent.getOperationType()) {
@ -174,6 +188,25 @@ public class ScimEventListenerProvider implements EventListenerProvider {
}
}
private void handleScimEndpointConfigurationEvent(AdminEvent event, String id) {
LOGGER.infof("[SCIM] SCIM Endpoint configuration %s - %s ", event.getOperationType(), id);
// In case of a component deletion
if (event.getOperationType() == OperationType.DELETE) {
// Check if it was a Scim endpoint configuration, and forward deletion if so
// TODO : determine if deleted element is of ScimStorageProvider class and only delete in that case
dispatcher.refreshActiveScimEndpoints();
} else {
// In case of CREATE or UPDATE, we can directly use the string representation
// to check if it defines a SCIM endpoint (faster)
if (event.getRepresentation() != null
&& event.getRepresentation().contains("\"providerId\":\"scim\"")) {
dispatcher.refreshActiveScimEndpoints();
}
}
}
private UserModel getUser(String id) {
return session.users().getUserById(session.getContext().getRealm(), id);
@ -185,7 +218,12 @@ public class ScimEventListenerProvider implements EventListenerProvider {
@Override
public void close() {
// Nothing to close here
try {
dispatcher.close();
} catch (Exception e) {
LOGGER.error("Error while closing dispatcher", e);
}
}
}

View file

@ -10,10 +10,6 @@ public class ScimResourceProviderFactory implements JpaEntityProviderFactory {
static final String ID = "scim-resource";
@Override
public void close() {
}
@Override
public JpaEntityProvider create(KeycloakSession session) {
return new ScimResourceProvider();
@ -26,9 +22,18 @@ public class ScimResourceProviderFactory implements JpaEntityProviderFactory {
@Override
public void init(Scope scope) {
// Nothing to initialise
}
@Override
public void postInit(KeycloakSessionFactory sessionFactory) {
// Nothing to do
}
@Override
public void close() {
// Nothing to close
}
}

View file

@ -25,6 +25,9 @@ import java.time.Duration;
import java.util.Date;
import java.util.List;
/**
* Allows to register Scim endpoints through Admin console, using the provided config properties.
*/
public class ScimStorageProviderFactory
implements UserStorageProviderFactory<ScimStorageProviderFactory.ScimStorageProvider>, ImportSynchronization {
public static final String ID = "scim";
@ -39,13 +42,13 @@ public class ScimStorageProviderFactory
@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);
// TODO if this should be kept here, better document purpose & usage
logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getId());
SynchronizationResult result = new SynchronizationResult();
KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> {
RealmModel realm = session.realms().getRealm(realmId);
session.getContext().setRealm(realm);
ScimDispatcher dispatcher = new ScimDispatcher(session);
ScimDispatcher dispatcher = ScimDispatcher.createForSession(session);
if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) {
dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result));
}
@ -71,7 +74,7 @@ public class ScimStorageProviderFactory
for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) {
KeycloakModelUtils.runJobInTransaction(factory, session -> {
session.getContext().setRealm(realm);
ScimDispatcher dispatcher = new ScimDispatcher(session);
ScimDispatcher dispatcher = ScimDispatcher.createForSession(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());