ScimDispatcher simplification : split User and Group client class to simplify usage

This commit is contained in:
Alex Morel 2024-06-18 15:34:31 +02:00
parent 9a45e9c30f
commit 5976031ac6
6 changed files with 139 additions and 40 deletions

View file

@ -0,0 +1,37 @@
package sh.libre.scim.core;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.storage.user.SynchronizationResult;
public class GroupScimClient implements ScimClientInterface<GroupModel> {
public static GroupScimClient newGroupScimClient(ComponentModel scimServer, KeycloakSession session) {
return new GroupScimClient();
}
@Override
public void create(GroupModel resource) {
throw new UnsupportedOperationException();
}
@Override
public void replace(GroupModel resource) {
throw new UnsupportedOperationException();
}
@Override
public void delete(String id) {
throw new UnsupportedOperationException();
}
@Override
public void sync(SynchronizationResult result) {
throw new UnsupportedOperationException();
}
@Override
public void close() throws Exception {
throw new UnsupportedOperationException();
}
}

View file

@ -0,0 +1,42 @@
package sh.libre.scim.core;
import org.keycloak.models.RoleMapperModel;
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}.
*
* @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.
*
* @param resource the created resource to propagate (e.g. a new UserModel)
*/
// TODO rename method (e.g. propagateCreation)
void create(M resource);
/**
* Propagates the update of the given keycloack model to a SCIM server.
*
* @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.
*
* @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.
*
* @param result the synchronization result to update for indicating triggered operations (e.g. user deletions)
*/
void sync(SynchronizationResult result);
}

View file

@ -6,13 +6,13 @@ import org.keycloak.models.KeycloakSession;
import sh.libre.scim.storage.ScimStorageProviderFactory; import sh.libre.scim.storage.ScimStorageProviderFactory;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Stream;
/**
* In charge of sending SCIM Request to all registered SCIM servers.
*/
public class ScimDispatcher { public class ScimDispatcher {
public static final String SCOPE_USER = "user";
public static final String SCOPE_GROUP = "group";
private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class);
private final KeycloakSession session; private final KeycloakSession session;
@ -21,22 +21,45 @@ public class ScimDispatcher {
this.session = session; this.session = session;
} }
public void run(String scope, Consumer<ScimClient> f) { public void dispatchUserModificationToAll(Consumer<UserScimClient> operationToDispatch) {
session.getContext().getRealm().getComponentsStream() getAllSCIMServer(Scope.USER).forEach(scimServer -> dispatchUserModificationToOne(scimServer, operationToDispatch));
.filter((m) -> {
return ScimStorageProviderFactory.ID.equals(m.getProviderId())
&& m.get("enabled", true)
&& m.get("propagation-" + scope, false);
})
.forEach(m -> runOne(m, f));
} }
public void runOne(ComponentModel m, Consumer<ScimClient> f) { public void dispatchUserModificationToOne(ComponentModel scimServer, Consumer<UserScimClient> operationToDispatch) {
LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType()); LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType());
try (ScimClient client = ScimClient.newScimClient(m, session)) { try (UserScimClient client = UserScimClient.newUserScimClient(scimServer, session)) {
f.accept(client); operationToDispatch.accept(client);
} catch (Exception e) { } catch (Exception e) {
LOGGER.error(e); LOGGER.error(e);
} }
} }
public void dispatchGroupModificationToAll(Consumer<GroupScimClient> operationToDispatch) {
getAllSCIMServer(Scope.GROUP).forEach(scimServer -> dispatchGroupModificationToOne(scimServer, 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)) {
operationToDispatch.accept(client);
} catch (Exception e) {
LOGGER.error(e);
}
}
/**
* @param scope The {@link Scope} to consider (User or Group)
* @return all enabled registered SCIM Servers 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
return session.getContext().getRealm().getComponentsStream()
.filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId())
&& m.get("enabled", true)
&& m.get("propagation-" + scope.name(), false));
}
public enum Scope {
USER, GROUP
}
} }

View file

@ -26,7 +26,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
public class UserScimClient implements AutoCloseable { public class UserScimClient implements ScimClientInterface<UserModel> {
private static final Logger LOGGER = Logger.getLogger(UserScimClient.class); private static final Logger LOGGER = Logger.getLogger(UserScimClient.class);
@ -86,6 +86,7 @@ public class UserScimClient implements AutoCloseable {
return new UserScimClient(scimRequestBuilder, retryRegistry, session, scimProviderConfiguration); return new UserScimClient(scimRequestBuilder, retryRegistry, session, scimProviderConfiguration);
} }
@Override
public void create(UserModel userModel) { public void create(UserModel userModel) {
UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
adapter.apply(userModel); adapter.apply(userModel);
@ -116,6 +117,7 @@ public class UserScimClient implements AutoCloseable {
adapter.saveMapping(); adapter.saveMapping();
} }
@Override
public void replace(UserModel userModel) { public void replace(UserModel userModel) {
UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
try { try {
@ -146,6 +148,7 @@ public class UserScimClient implements AutoCloseable {
} }
} }
@Override
public void delete(String id) { public void delete(String id) {
UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
adapter.setId(id); adapter.setId(id);
@ -262,6 +265,7 @@ public class UserScimClient implements AutoCloseable {
} }
} }
@Override
public void sync(SynchronizationResult syncRes) { public void sync(SynchronizationResult syncRes) {
if (this.scimProviderConfiguration.isSyncImport()) { if (this.scimProviderConfiguration.isSyncImport()) {
this.importResources(syncRes); this.importResources(syncRes);

View file

@ -10,11 +10,8 @@ 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;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import sh.libre.scim.core.GroupAdapter;
import sh.libre.scim.core.ScimDispatcher; import sh.libre.scim.core.ScimDispatcher;
import sh.libre.scim.core.UserAdapter;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -50,14 +47,13 @@ public class ScimEventListenerProvider implements EventListenerProvider {
switch (eventType) { switch (eventType) {
case REGISTER -> { case REGISTER -> {
UserModel user = getUser(eventUserId); UserModel user = getUser(eventUserId);
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); dispatcher.dispatchUserModificationToAll(client -> client.create(user));
} }
case UPDATE_EMAIL, UPDATE_PROFILE -> { case UPDATE_EMAIL, UPDATE_PROFILE -> {
UserModel user = getUser(eventUserId); UserModel user = getUser(eventUserId);
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
} }
case DELETE_ACCOUNT -> case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId));
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, eventUserId));
default -> LOGGER.trace("ignore event " + eventType); default -> LOGGER.trace("ignore event " + eventType);
} }
} }
@ -77,17 +73,16 @@ public class ScimEventListenerProvider implements EventListenerProvider {
switch (event.getOperationType()) { switch (event.getOperationType()) {
case CREATE -> { case CREATE -> {
UserModel user = getUser(userId); UserModel user = getUser(userId);
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); dispatcher.dispatchUserModificationToAll(client -> client.create(user));
user.getGroupsStream().forEach(group -> { user.getGroupsStream().forEach(group -> {
dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); dispatcher.dispatchGroupModificationToAll(client -> client.replace(group));
}); });
} }
case UPDATE -> { case UPDATE -> {
UserModel user = getUser(userId); UserModel user = getUser(userId);
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
} }
case DELETE -> case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId));
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, userId));
} }
} }
case GROUP -> { case GROUP -> {
@ -96,14 +91,13 @@ public class ScimEventListenerProvider implements EventListenerProvider {
switch (event.getOperationType()) { switch (event.getOperationType()) {
case CREATE -> { case CREATE -> {
GroupModel group = getGroup(groupId); GroupModel group = getGroup(groupId);
dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.create(GroupAdapter.class, group)); dispatcher.dispatchGroupModificationToAll(client -> client.create(group));
} }
case UPDATE -> { case UPDATE -> {
GroupModel group = getGroup(groupId); GroupModel group = getGroup(groupId);
dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); dispatcher.dispatchGroupModificationToAll(client -> client.replace(group));
} }
case DELETE -> dispatcher.run(ScimDispatcher.SCOPE_GROUP, case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId));
(client) -> client.delete(GroupAdapter.class, groupId));
} }
} }
case GROUP_MEMBERSHIP -> { case GROUP_MEMBERSHIP -> {
@ -113,7 +107,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
GroupModel group = getGroup(groupId); GroupModel group = getGroup(groupId);
group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE);
UserModel user = getUser(userId); UserModel user = getUser(userId);
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
} }
case REALM_ROLE_MAPPING -> { case REALM_ROLE_MAPPING -> {
String type = matcher.group(1); String type = matcher.group(1);
@ -122,12 +116,12 @@ public class ScimEventListenerProvider implements EventListenerProvider {
switch (type) { switch (type) {
case "users" -> { case "users" -> {
UserModel user = getUser(id); UserModel user = getUser(id);
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
} }
case "groups" -> { case "groups" -> {
GroupModel group = getGroup(id); GroupModel group = getGroup(id);
session.users().getGroupMembersStream(session.getContext().getRealm(), group).forEach(user -> { session.users().getGroupMembersStream(session.getContext().getRealm(), group).forEach(user -> {
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
}); });
} }
} }

View file

@ -137,10 +137,10 @@ public class ScimStorageProviderFactory
session.getContext().setRealm(realm); session.getContext().setRealm(realm);
ScimDispatcher dispatcher = new ScimDispatcher(session); ScimDispatcher dispatcher = new ScimDispatcher(session);
if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) {
dispatcher.runOne(model, (client) -> client.sync(UserAdapter.class, result)); dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result));
} }
if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) {
dispatcher.runOne(model, (client) -> client.sync(GroupAdapter.class, result)); dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result));
} }
} }
@ -169,8 +169,7 @@ public class ScimStorageProviderFactory
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.debug(group.getName() + " is dirty"); LOGGER.debug(group.getName() + " is dirty");
dispatcher.run(ScimDispatcher.SCOPE_GROUP, dispatcher.dispatchGroupModificationToAll(client -> client.replace(group));
(client) -> client.replace(GroupAdapter.class, group));
group.removeAttribute("scim-dirty"); group.removeAttribute("scim-dirty");
} }
} }