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 @Override
public void close() throws Exception { 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 sh.libre.scim.storage.ScimStorageProviderFactory;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
@ -17,13 +19,21 @@ public class ScimDispatcher {
private static final Logger logger = Logger.getLogger(ScimDispatcher.class); private static final Logger logger = Logger.getLogger(ScimDispatcher.class);
private static final Map<KeycloakSession, ScimDispatcher> sessionToScimDispatcher = new LinkedHashMap<>();
private final KeycloakSession session; private final KeycloakSession session;
private boolean clientsInitialized = false;
private final List<UserScimClient> userScimClients = new ArrayList<>(); private final List<UserScimClient> userScimClients = new ArrayList<>();
private final List<GroupScimClient> groupScimClients = 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; this.session = session;
refreshActiveScimEndpoints();
} }
/** /**
@ -35,23 +45,30 @@ public class ScimDispatcher {
for (GroupScimClient c : groupScimClients) { for (GroupScimClient c : groupScimClients) {
c.close(); c.close();
} }
groupScimClients.clear();
for (UserScimClient c : userScimClients) { for (UserScimClient c : userScimClients) {
c.close(); c.close();
} }
userScimClients.clear();
// Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory) // Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory)
session.getContext().getRealm().getComponentsStream() session.getContext().getRealm().getComponentsStream()
.filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId()) .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId())
&& m.get("enabled", true)) && m.get("enabled", true))
.forEach(scimEndpoint -> { .forEach(scimEndpoint -> {
// Step 3 : create scim clients for each endpoint try {
if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { // Step 3 : create scim clients for each endpoint
GroupScimClient groupScimClient = GroupScimClient.newGroupScimClient(scimEndpoint, session); if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
groupScimClients.add(groupScimClient); GroupScimClient groupScimClient = GroupScimClient.newGroupScimClient(scimEndpoint, session);
} groupScimClients.add(groupScimClient);
if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { }
UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session); if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
userScimClients.add(userScimClient); 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) { } catch (Exception e) {
@ -61,25 +78,24 @@ public class ScimDispatcher {
} }
public void dispatchUserModificationToAll(Consumer<UserScimClient> operationToDispatch) { 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 initializeClientsIfNeeded();
refreshActiveScimEndpoints();
userScimClients.forEach(operationToDispatch); userScimClients.forEach(operationToDispatch);
logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimClients.size());
} }
public void dispatchGroupModificationToAll(Consumer<GroupScimClient> operationToDispatch) { 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 initializeClientsIfNeeded();
refreshActiveScimEndpoints();
groupScimClients.forEach(operationToDispatch); groupScimClients.forEach(operationToDispatch);
logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimClients.size());
} }
public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimClient> operationToDispatch) { 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 initializeClientsIfNeeded();
refreshActiveScimEndpoints();
// Scim client should already have been created // Scim client should already have been created
Optional<UserScimClient> matchingClient = userScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); Optional<UserScimClient> matchingClient = userScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
if (matchingClient.isPresent()) { if (matchingClient.isPresent()) {
operationToDispatch.accept(matchingClient.get()); operationToDispatch.accept(matchingClient.get());
logger.infof("[SCIM] User operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId());
} else { } else {
logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); 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) { 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 initializeClientsIfNeeded();
refreshActiveScimEndpoints();
// Scim client should already have been created // Scim client should already have been created
Optional<GroupScimClient> matchingClient = groupScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); Optional<GroupScimClient> matchingClient = groupScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
if (matchingClient.isPresent()) { if (matchingClient.isPresent()) {
operationToDispatch.accept(matchingClient.get()); operationToDispatch.accept(matchingClient.get());
logger.infof("[SCIM] Group operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId());
} else { } else {
logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); 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,25 +25,29 @@ public class ScrimProviderConfiguration {
private final boolean syncRefresh; private final boolean syncRefresh;
public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) { public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) {
AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE)); try {
authorizationHeaderValue = switch (authMode) { AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE));
case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD);
case BASIC_AUTH -> { authorizationHeaderValue = switch (authMode) {
BasicAuth basicAuth = BasicAuth.builder() case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD);
.username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER)) case BASIC_AUTH -> {
.password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD)) BasicAuth basicAuth = BasicAuth.builder()
.build(); .username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER))
yield basicAuth.getAuthorizationHeaderValue(); .password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD))
} .build();
default -> yield basicAuth.getAuthorizationHeaderValue();
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, "");
id = scimProviderConfiguration.getId(); endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT, "");
importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); id = scimProviderConfiguration.getId();
syncImport = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION));
syncRefresh = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); 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() { public boolean isSyncRefresh() {

View file

@ -6,6 +6,7 @@ import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider; import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType; import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
@ -33,12 +34,13 @@ public class ScimEventListenerProvider implements EventListenerProvider {
ResourceType.USER, Pattern.compile("users/(.+)"), ResourceType.USER, Pattern.compile("users/(.+)"),
ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"), ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"),
ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)"), 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) { public ScimEventListenerProvider(KeycloakSession session) {
this.session = 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 // React to User-related event : creation, deletion, update
EventType eventType = event.getType(); EventType eventType = event.getType();
String eventUserId = event.getUserId(); String eventUserId = event.getUserId();
LOGGER.infof("[SCIM] Propagate User Event %s - %s", eventType, eventUserId);
switch (eventType) { switch (eventType) {
case REGISTER -> { case REGISTER -> {
LOGGER.infof("[SCIM] Propagate User Registration - %s", eventUserId);
UserModel user = getUser(eventUserId); UserModel user = getUser(eventUserId);
dispatcher.dispatchUserModificationToAll(client -> client.create(user)); dispatcher.dispatchUserModificationToAll(client -> client.create(user));
} }
case UPDATE_EMAIL, UPDATE_PROFILE -> { case UPDATE_EMAIL, UPDATE_PROFILE -> {
LOGGER.infof("[SCIM] Propagate User %s - %s", eventType, eventUserId);
UserModel user = getUser(eventUserId); UserModel user = getUser(eventUserId);
dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); 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 -> { default -> {
// No other event has to be propagated to Scim endpoints // No other event has to be propagated to Scim endpoints
} }
} }
} }
@Override @Override
public void onEvent(AdminEvent event, boolean includeRepresentation) { public void onEvent(AdminEvent event, boolean includeRepresentation) {
// Step 1: check if event is relevant for propagation through SCIM // Step 1: check if event is relevant for propagation through SCIM
@ -74,6 +81,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
if (!matcher.find()) if (!matcher.find())
return; return;
// Step 2: propagate event (if needed) according to its resource type // Step 2: propagate event (if needed) according to its resource type
switch (event.getResourceType()) { switch (event.getResourceType()) {
case USER -> { case USER -> {
@ -94,12 +102,18 @@ public class ScimEventListenerProvider implements EventListenerProvider {
String id = matcher.group(2); String id = matcher.group(2);
handleRoleMappingEvent(event, type, id); handleRoleMappingEvent(event, type, id);
} }
case COMPONENT -> {
String id = matcher.group(1);
handleScimEndpointConfigurationEvent(event, id);
}
default -> { default -> {
// No other resource modification has to be propagated to Scim endpoints // No other resource modification has to be propagated to Scim endpoints
} }
} }
} }
private void handleUserEvent(AdminEvent userEvent, String userId) { private void handleUserEvent(AdminEvent userEvent, String userId) {
LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId); LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId);
switch (userEvent.getOperationType()) { 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) { private UserModel getUser(String id) {
return session.users().getUserById(session.getContext().getRealm(), id); return session.users().getUserById(session.getContext().getRealm(), id);
@ -185,7 +218,12 @@ public class ScimEventListenerProvider implements EventListenerProvider {
@Override @Override
public void close() { 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"; static final String ID = "scim-resource";
@Override
public void close() {
}
@Override @Override
public JpaEntityProvider create(KeycloakSession session) { public JpaEntityProvider create(KeycloakSession session) {
return new ScimResourceProvider(); return new ScimResourceProvider();
@ -26,9 +22,18 @@ public class ScimResourceProviderFactory implements JpaEntityProviderFactory {
@Override @Override
public void init(Scope scope) { public void init(Scope scope) {
// Nothing to initialise
} }
@Override @Override
public void postInit(KeycloakSessionFactory sessionFactory) { 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.Date;
import java.util.List; import java.util.List;
/**
* Allows to register Scim endpoints through Admin console, using the provided config properties.
*/
public class ScimStorageProviderFactory public class ScimStorageProviderFactory
implements UserStorageProviderFactory<ScimStorageProviderFactory.ScimStorageProvider>, ImportSynchronization { implements UserStorageProviderFactory<ScimStorageProviderFactory.ScimStorageProvider>, ImportSynchronization {
public static final String ID = "scim"; public static final String ID = "scim";
@ -39,13 +42,13 @@ public class ScimStorageProviderFactory
@Override @Override
public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId,
UserStorageProviderModel model) { UserStorageProviderModel model) {
// TODO if this should be kept here, better document prupose & usage // TODO if this should be kept here, better document purpose & usage
logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s", realmId); logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getId());
SynchronizationResult result = new SynchronizationResult(); SynchronizationResult result = new SynchronizationResult();
KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> {
RealmModel realm = session.realms().getRealm(realmId); RealmModel realm = session.realms().getRealm(realmId);
session.getContext().setRealm(realm); session.getContext().setRealm(realm);
ScimDispatcher dispatcher = new ScimDispatcher(session); ScimDispatcher dispatcher = ScimDispatcher.createForSession(session);
if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) {
dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result));
} }
@ -71,7 +74,7 @@ public class ScimStorageProviderFactory
for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) {
KeycloakModelUtils.runJobInTransaction(factory, session -> { KeycloakModelUtils.runJobInTransaction(factory, session -> {
session.getContext().setRealm(realm); session.getContext().setRealm(realm);
ScimDispatcher dispatcher = new ScimDispatcher(session); ScimDispatcher dispatcher = ScimDispatcher.createForSession(session);
for (GroupModel group : session.groups().getGroupsStream(realm) for (GroupModel group : session.groups().getGroupsStream(realm)
.filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) { .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) {
logger.infof("[SCIM] Dirty group : %s", group.getName()); logger.infof("[SCIM] Dirty group : %s", group.getName());