From 50029c254e22dfdf31879359f6385683d116f523 Mon Sep 17 00:00:00 2001 From: Hugo Renard Date: Wed, 23 Feb 2022 18:14:35 +0100 Subject: [PATCH] add group + group sync support --- src/main/java/sh/libre/scim/core/Adapter.java | 31 ++- .../java/sh/libre/scim/core/GroupAdapter.java | 184 ++++++++++++++++++ .../java/sh/libre/scim/core/ScimClient.java | 70 ++++--- .../java/sh/libre/scim/core/UserAdapter.java | 37 ++-- .../scim/event/ScimEventListenerProvider.java | 38 +++- .../storage/ScimStorageProviderFactory.java | 5 +- 6 files changed, 309 insertions(+), 56 deletions(-) create mode 100644 src/main/java/sh/libre/scim/core/GroupAdapter.java diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java index 6318ebc3af..2f8f40cb44 100644 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ b/src/main/java/sh/libre/scim/core/Adapter.java @@ -1,10 +1,14 @@ package sh.libre.scim.core; +import java.util.stream.Stream; + import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import javax.ws.rs.NotFoundException; import org.jboss.logging.Logger; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.RoleMapperModel; import sh.libre.scim.jpa.ScimResource; @@ -16,14 +20,16 @@ public abstract class Adapter query(String query, String id) { + return query(query, id, type); + } + + public TypedQuery query(String query, String id, String type) { return this.em .createNamedQuery(query, ScimResource.class) .setParameter("type", type) @@ -92,14 +102,20 @@ public abstract class Adapter getResourceClass(); public abstract S toSCIM(Boolean addMeta); @@ -107,4 +123,9 @@ public abstract class Adapter getResourceStream(); + + public abstract Boolean skipRefresh(); } diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java new file mode 100644 index 0000000000..0e4b84f56b --- /dev/null +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -0,0 +1,184 @@ +package sh.libre.scim.core; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.persistence.NoResultException; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.unboundid.scim2.common.types.GroupResource; +import com.unboundid.scim2.common.types.Member; +import com.unboundid.scim2.common.types.Meta; + +import org.jboss.logging.Logger; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.jpa.entities.GroupEntity; +import org.keycloak.models.jpa.entities.UserEntity; +import org.keycloak.models.jpa.entities.UserGroupMembershipEntity; +import org.keycloak.models.utils.KeycloakModelUtils; + +public class GroupAdapter extends Adapter { + + private String displayName; + private Set members = new HashSet(); + + public GroupAdapter(KeycloakSession session, String componentId) { + super(session, componentId, "Group", Logger.getLogger(GroupAdapter.class)); + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + if (this.displayName == null) { + this.displayName = displayName; + } + } + + @Override + public Class getResourceClass() { + return GroupResource.class; + } + + @Override + public void apply(GroupModel group) { + setId(group.getId()); + setDisplayName(group.getName()); + this.members = session.users() + .getGroupMembersStream(session.getContext().getRealm(), group) + .map(x -> x.getId()) + .collect(Collectors.toSet()); + ObjectMapper Obj = new ObjectMapper(); + try { + String jsonStr = Obj.writerWithDefaultPrettyPrinter().writeValueAsString(this.members); + LOGGER.info(jsonStr); + } catch (JsonProcessingException e) { + } + } + + @Override + public void apply(GroupResource group) { + setExternalId(group.getId()); + setDisplayName(group.getDisplayName()); + var groupMembers = group.getMembers(); + if (groupMembers != null && groupMembers.size() > 0) { + this.members = new HashSet(); + for (var groupMember : groupMembers) { + var userMapping = this.query("findByExternalId", groupMember.getValue(), "User") + .getSingleResult(); + this.members.add(userMapping.getId()); + } + } + } + + @Override + public GroupResource toSCIM(Boolean addMeta) { + var group = new GroupResource(); + group.setId(externalId); + group.setExternalId(id); + group.setDisplayName(displayName); + if (members.size() > 0) { + var groupMembers = new ArrayList(); + for (var member : members) { + var groupMember = new Member(); + try { + var userMapping = this.query("findById", member, "User").getSingleResult(); + groupMember.setValue(userMapping.getExternalId()); + var ref = new URI(String.format("Users/%s", userMapping.getExternalId())); + groupMember.setRef(ref); + groupMembers.add(groupMember); + } catch (Exception e) { + LOGGER.error(e); + } + } + group.setMembers(groupMembers); + } + if (addMeta) { + var meta = new Meta(); + try { + var uri = new URI("Groups/" + externalId); + meta.setLocation(uri); + } catch (URISyntaxException e) { + } + group.setMeta(meta); + } + ObjectMapper Obj = new ObjectMapper(); + try { + String jsonStr = Obj.writerWithDefaultPrettyPrinter().writeValueAsString(group); + LOGGER.info(jsonStr); + } catch (JsonProcessingException e) { + } + return group; + } + + @Override + public Boolean entityExists() { + if (this.id == null) { + return false; + } + var group = this.em.find(GroupEntity.class, this.id); + if (group != null) { + return true; + } + return false; + } + + @Override + public Boolean tryToMap() { + try { + var groupEntity = this.em + .createQuery("select g from GroupEntity g where g.name=:name", + GroupEntity.class) + .setParameter("name", displayName) + .getSingleResult(); + setId(groupEntity.getId()); + return true; + } catch (Exception e) { + } + return false; + } + + @Override + public void createEntity() { + var kcGroup = new GroupEntity(); + kcGroup.setId(KeycloakModelUtils.generateId()); + kcGroup.setRealm(realmId); + kcGroup.setName(displayName); + kcGroup.setParentId(GroupEntity.TOP_PARENT_ID); + this.em.persist(kcGroup); + this.id = kcGroup.getId(); + for (String mId : members) { + try { + var user = this.em.find(UserEntity.class, mId); + if (user == null) { + throw new NoResultException(); + } + var membership = new UserGroupMembershipEntity(); + membership.setUser(user); + membership.setGroupId(kcGroup.getId()); + this.em.persist(membership); + } catch (Exception e) { + LOGGER.warn(e); + } + } + } + + @Override + public Stream getResourceStream() { + return this.session.groups().getGroupsStream(this.session.getContext().getRealm()); + } + + @Override + public Boolean skipRefresh() { + return false; + } + +} diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 66e71d56cf..c35d2961fd 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -7,7 +7,6 @@ import javax.ws.rs.client.Client; import com.unboundid.scim2.client.ScimService; import com.unboundid.scim2.common.ScimResource; import com.unboundid.scim2.common.exceptions.ScimException; -import com.unboundid.scim2.common.types.UserResource; import org.jboss.logging.Logger; import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; @@ -17,7 +16,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RoleMapperModel; import org.keycloak.storage.user.SynchronizationResult; -import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.RetryConfig; import io.github.resilience4j.retry.RetryRegistry; @@ -60,8 +58,8 @@ public class ScimClient { protected > A getAdapter( Class aClass) { try { - return aClass.getDeclaredConstructor(String.class, String.class, EntityManager.class) - .newInstance(getRealmId(), this.model.getId(), getEM()); + return aClass.getDeclaredConstructor(KeycloakSession.class, String.class) + .newInstance(session, this.model.getId()); } catch (Exception e) { throw new RuntimeException(e); } @@ -72,16 +70,18 @@ public class ScimClient { var adapter = getAdapter(aClass); adapter.apply(kcModel); var retry = registry.retry("create-" + adapter.getId()); - var spUser = retry.executeSupplier(() -> { + var resource = retry.executeSupplier(() -> { try { - return scimService.createRequest(adapter.getSCIMEndpoint(), adapter.toSCIM(false)) + return scimService.createRequest(adapter.getSCIMEndpoint(), + adapter.toSCIM(false)) .contentType(contentType).invoke(); } catch (ScimException e) { throw new RuntimeException(e); } }); - adapter.apply(spUser); + adapter.apply(resource); adapter.saveMapping(); + }; public > void replace(Class aClass, @@ -129,35 +129,41 @@ public class ScimClient { } } - public void refreshUsers(SynchronizationResult syncRes) { - LOGGER.info("Refresh Users"); - this.session.users().getUsersStream(this.session.getContext().getRealm()).forEach(kcUser -> { - LOGGER.infof("Reconciling local user %s", kcUser.getId()); - if (!kcUser.getUsername().equals("admin")) { - var adapter = getAdapter(UserAdapter.class); - adapter.apply(kcUser); + public > void refreshResources( + Class aClass, + SynchronizationResult syncRes) { + LOGGER.info("Refresh resources"); + getAdapter(aClass).getResourceStream().forEach(resource -> { + var adapter = getAdapter(aClass); + adapter.apply(resource); + LOGGER.infof("Reconciling local resource %s", adapter.getId()); + if (!adapter.skipRefresh()) { var mapping = adapter.getMapping(); if (mapping == null) { LOGGER.info("Creating it"); - this.create(UserAdapter.class, kcUser); + this.create(aClass, resource); } else { LOGGER.info("Replacing it"); - this.replace(UserAdapter.class, kcUser); + this.replace(aClass, resource); } syncRes.increaseUpdated(); } }); + } - public void importUsers(SynchronizationResult syncRes) { - LOGGER.info("Import Users"); + public > void importResources( + Class aClass, SynchronizationResult syncRes) { + LOGGER.info("Import"); try { - var spUsers = scimService.searchRequest("Users").contentType(contentType).invoke(UserResource.class); - for (var spUser : spUsers) { + var adapter = getAdapter(aClass); + var resources = scimService.searchRequest(adapter.getSCIMEndpoint()).contentType(contentType) + .invoke(adapter.getResourceClass()); + for (var resource : resources) { try { - LOGGER.infof("Reconciling remote user %s", spUser.getId()); - var adapter = getAdapter(UserAdapter.class); - adapter.apply(spUser); + LOGGER.infof("Reconciling remote resource %s", resource.getId()); + adapter = getAdapter(aClass); + adapter.apply(resource); var mapping = adapter.getMapping(); if (mapping != null) { @@ -173,25 +179,28 @@ public class ScimClient { var mapped = adapter.tryToMap(); if (mapped) { - LOGGER.info("Matched a user"); + LOGGER.info("Matched"); adapter.saveMapping(); } else { switch (this.model.get("sync-import-action")) { case "CREATE_LOCAL": - LOGGER.info("Create local user"); + LOGGER.info("Create local resource"); adapter.createEntity(); adapter.saveMapping(); syncRes.increaseAdded(); break; case "DELETE_REMOTE": - LOGGER.info("Delete remote user"); - scimService.deleteRequest("Users", spUser.getId()).contentType(contentType) + LOGGER.info("Delete remote resource"); + scimService.deleteRequest(adapter.getSCIMEndpoint(), resource.getId()) + .contentType(contentType) .invoke(); syncRes.increaseRemoved(); break; } } } catch (Exception e) { + LOGGER.error(e); + e.printStackTrace(); syncRes.increaseFailed(); } } @@ -200,12 +209,13 @@ public class ScimClient { } } - public void sync(SynchronizationResult syncRes) { + public > void sync(Class aClass, + SynchronizationResult syncRes) { if (this.model.get("sync-import", false)) { - this.importUsers(syncRes); + this.importResources(aClass, syncRes); } if (this.model.get("sync-refresh", false)) { - this.refreshUsers(syncRes); + this.refreshResources(aClass, syncRes); } } diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index 0ed5328b52..11c2472e79 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -3,20 +3,18 @@ package sh.libre.scim.core; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; - -import javax.persistence.EntityManager; +import java.util.stream.Stream; import com.unboundid.scim2.common.types.Email; import com.unboundid.scim2.common.types.Meta; import com.unboundid.scim2.common.types.UserResource; import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; import org.keycloak.models.jpa.entities.UserEntity; import org.keycloak.models.utils.KeycloakModelUtils; -import sh.libre.scim.jpa.ScimResource; - public class UserAdapter extends Adapter { private String username; @@ -24,8 +22,8 @@ public class UserAdapter extends Adapter { private String email; private Boolean active; - public UserAdapter(String realmId, String componentId, EntityManager em) { - super(realmId, componentId, em, "User", Logger.getLogger(UserAdapter.class)); + public UserAdapter(KeycloakSession session, String componentId) { + super(session, componentId, "User", Logger.getLogger(UserAdapter.class)); } public String getUsername() { @@ -68,6 +66,11 @@ public class UserAdapter extends Adapter { } } + @Override + public Class getResourceClass() { + return UserResource.class; + } + @Override public void apply(UserModel user) { setId(user.getId()); @@ -94,12 +97,6 @@ public class UserAdapter extends Adapter { } } - @Override - public void apply(ScimResource mapping) { - setId(mapping.getId()); - setExternalId(mapping.getExternalId()); - } - @Override public UserResource toSCIM(Boolean addMeta) { var user = new UserResource(); @@ -126,7 +123,8 @@ public class UserAdapter extends Adapter { return user; } - public UserEntity createEntity() { + @Override + public void createEntity() { var kcUser = new UserEntity(); kcUser.setId(KeycloakModelUtils.generateId()); kcUser.setRealmId(realmId); @@ -134,9 +132,9 @@ public class UserAdapter extends Adapter { kcUser.setEmail(email, false); this.em.persist(kcUser); this.id = kcUser.getId(); - return kcUser; } + @Override public Boolean entityExists() { if (this.id == null) { return false; @@ -148,6 +146,7 @@ public class UserAdapter extends Adapter { return false; } + @Override public Boolean tryToMap() { try { var userEntity = this.em @@ -163,4 +162,14 @@ public class UserAdapter extends Adapter { } return false; } + + @Override + public Stream getResourceStream() { + return this.session.users().getUsersStream(this.session.getContext().getRealm()); + } + + @Override + public Boolean skipRefresh() { + return getUsername().equals("admin"); + } } diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index a64c82330b..13ccbada4f 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -1,5 +1,8 @@ package sh.libre.scim.event; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + import org.jboss.logging.Logger; import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; @@ -7,9 +10,12 @@ 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; import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.GroupRepresentation; +import sh.libre.scim.core.GroupAdapter; import sh.libre.scim.core.ScimDispatcher; import sh.libre.scim.core.UserAdapter; @@ -48,7 +54,6 @@ public class ScimEventListenerProvider implements EventListenerProvider { var userId = event.getResourcePath().replace("users/", ""); LOGGER.infof("%s %s", userId, event.getOperationType()); if (event.getOperationType() == OperationType.CREATE) { - // session.getTransactionManager().rollback(); var user = getUser(userId); dispatcher.run((client) -> client.create(UserAdapter.class, user)); } @@ -60,11 +65,28 @@ public class ScimEventListenerProvider implements EventListenerProvider { dispatcher.run((client) -> client.delete(UserAdapter.class, userId)); } } - if (event.getResourceType() == ResourceType.COMPONENT) { - if (event.getOperationType() == OperationType.CREATE - || (event.getOperationType() == OperationType.UPDATE)) { - LOGGER.infof("%s %s", event.getResourcePath(), event.getOperationType()); - // dispatcher.run((client) -> client.syncUsers()); + if (event.getResourceType() == ResourceType.GROUP) { + var groupId = event.getResourcePath().replace("groups/", ""); + LOGGER.infof("%s %s", event.getResourcePath(), event.getOperationType()); + if (event.getOperationType() == OperationType.CREATE) { + var group = getGroup(groupId); + dispatcher.run((client) -> client.create(GroupAdapter.class, group)); + } + if (event.getOperationType() == OperationType.UPDATE) { + var group = getGroup(groupId); + dispatcher.run((client) -> client.replace(GroupAdapter.class, group)); + } + if (event.getOperationType() == OperationType.DELETE) { + dispatcher.run((client) -> client.delete(GroupAdapter.class, groupId)); + } + } + if (event.getResourceType() == ResourceType.GROUP_MEMBERSHIP) { + ObjectMapper obj = new ObjectMapper(); + try { + var groupRepresentation = obj.readValue(event.getRepresentation(), GroupRepresentation.class); + var group = getGroup(groupRepresentation.getId()); + dispatcher.run((client) -> client.replace(GroupAdapter.class, group)); + } catch (JsonProcessingException e) { } } } @@ -72,4 +94,8 @@ public class ScimEventListenerProvider implements EventListenerProvider { private UserModel getUser(String id) { return session.users().getUserById(session.getContext().getRealm(), id); } + + private GroupModel getGroup(String id) { + return session.groups().getGroupById(session.getContext().getRealm(), id); + } } diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index f1bc82d502..081a1c6dac 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -21,7 +21,9 @@ import org.keycloak.storage.UserStorageProviderModel; import org.keycloak.storage.user.ImportSynchronization; import org.keycloak.storage.user.SynchronizationResult; +import sh.libre.scim.core.GroupAdapter; import sh.libre.scim.core.ScimClient; +import sh.libre.scim.core.UserAdapter; public class ScimStorageProviderFactory implements UserStorageProviderFactory, ImportSynchronization { @@ -110,7 +112,8 @@ public class ScimStorageProviderFactory var client = new ScimClient(model, session); model.setEnabled(false); realm.updateComponent(model); - client.sync(result); + client.sync(UserAdapter.class, result); + client.sync(GroupAdapter.class, result); client.close(); model.setEnabled(true); realm.updateComponent(model);