diff --git a/src/main/java/sh/libre/scim/core/GroupScimClient.java b/src/main/java/sh/libre/scim/core/GroupScimClient.java new file mode 100644 index 0000000000..1483d3bda3 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/GroupScimClient.java @@ -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 { + 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(); + } +} diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java new file mode 100644 index 0000000000..1affb82c71 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimClientInterface.java @@ -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 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. + * + * @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); +} diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index df4e220a3d..dcbe886afe 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -6,13 +6,13 @@ import org.keycloak.models.KeycloakSession; import sh.libre.scim.storage.ScimStorageProviderFactory; 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 static final String SCOPE_USER = "user"; - - public static final String SCOPE_GROUP = "group"; - private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); private final KeycloakSession session; @@ -21,22 +21,45 @@ public class ScimDispatcher { this.session = session; } - public void run(String scope, Consumer f) { - session.getContext().getRealm().getComponentsStream() - .filter((m) -> { - return ScimStorageProviderFactory.ID.equals(m.getProviderId()) - && m.get("enabled", true) - && m.get("propagation-" + scope, false); - }) - .forEach(m -> runOne(m, f)); + public void dispatchUserModificationToAll(Consumer operationToDispatch) { + getAllSCIMServer(Scope.USER).forEach(scimServer -> dispatchUserModificationToOne(scimServer, operationToDispatch)); } - public void runOne(ComponentModel m, Consumer f) { - LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType()); - try (ScimClient client = ScimClient.newScimClient(m, session)) { - f.accept(client); + 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)) { + operationToDispatch.accept(client); } catch (Exception e) { LOGGER.error(e); } } + + public void dispatchGroupModificationToAll(Consumer operationToDispatch) { + getAllSCIMServer(Scope.GROUP).forEach(scimServer -> dispatchGroupModificationToOne(scimServer, 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)) { + 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 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 + } } diff --git a/src/main/java/sh/libre/scim/core/UserScimClient.java b/src/main/java/sh/libre/scim/core/UserScimClient.java index 789d852d68..4f257a0fca 100644 --- a/src/main/java/sh/libre/scim/core/UserScimClient.java +++ b/src/main/java/sh/libre/scim/core/UserScimClient.java @@ -26,7 +26,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; -public class UserScimClient implements AutoCloseable { +public class UserScimClient implements ScimClientInterface { 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); } + @Override public void create(UserModel userModel) { UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); adapter.apply(userModel); @@ -116,6 +117,7 @@ public class UserScimClient implements AutoCloseable { adapter.saveMapping(); } + @Override public void replace(UserModel userModel) { UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); try { @@ -146,6 +148,7 @@ public class UserScimClient implements AutoCloseable { } } + @Override public void delete(String id) { UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); adapter.setId(id); @@ -262,6 +265,7 @@ public class UserScimClient implements AutoCloseable { } } + @Override public void sync(SynchronizationResult syncRes) { if (this.scimProviderConfiguration.isSyncImport()) { this.importResources(syncRes); diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index a36dc57407..737e0819d4 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -10,11 +10,8 @@ import org.keycloak.events.admin.ResourceType; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; -import sh.libre.scim.core.GroupAdapter; import sh.libre.scim.core.ScimDispatcher; -import sh.libre.scim.core.UserAdapter; -import java.util.HashMap; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,14 +47,13 @@ public class ScimEventListenerProvider implements EventListenerProvider { switch (eventType) { case REGISTER -> { 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 -> { UserModel user = getUser(eventUserId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } - case DELETE_ACCOUNT -> - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, eventUserId)); + case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId)); default -> LOGGER.trace("ignore event " + eventType); } } @@ -77,17 +73,16 @@ public class ScimEventListenerProvider implements EventListenerProvider { switch (event.getOperationType()) { case CREATE -> { 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 -> { - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); + dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); }); } case UPDATE -> { UserModel user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } - case DELETE -> - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, userId)); + case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId)); } } case GROUP -> { @@ -96,14 +91,13 @@ public class ScimEventListenerProvider implements EventListenerProvider { switch (event.getOperationType()) { case CREATE -> { GroupModel group = getGroup(groupId); - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.create(GroupAdapter.class, group)); + dispatcher.dispatchGroupModificationToAll(client -> client.create(group)); } case UPDATE -> { 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, - (client) -> client.delete(GroupAdapter.class, groupId)); + case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId)); } } case GROUP_MEMBERSHIP -> { @@ -113,7 +107,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { GroupModel group = getGroup(groupId); group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); 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 -> { String type = matcher.group(1); @@ -122,12 +116,12 @@ public class ScimEventListenerProvider implements EventListenerProvider { switch (type) { case "users" -> { UserModel user = getUser(id); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } case "groups" -> { GroupModel group = getGroup(id); 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)); }); } } diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index f805cd65e7..e5f4b36ee7 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -137,10 +137,10 @@ public class ScimStorageProviderFactory session.getContext().setRealm(realm); ScimDispatcher dispatcher = new ScimDispatcher(session); 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"))) { - 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) .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) { LOGGER.debug(group.getName() + " is dirty"); - dispatcher.run(ScimDispatcher.SCOPE_GROUP, - (client) -> client.replace(GroupAdapter.class, group)); + dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); group.removeAttribute("scim-dirty"); } }