diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java new file mode 100644 index 0000000000..11d6276084 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -0,0 +1,230 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException; +import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; +import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RoleMapperModel; +import org.keycloak.storage.user.SynchronizationResult; +import sh.libre.scim.jpa.ScimResource; +import sh.libre.scim.jpa.ScimResourceDao; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.stream.Stream; + +public abstract class AbstractScimService implements AutoCloseable { + + private static final Logger LOGGER = Logger.getLogger(AbstractScimService.class); + + private final KeycloakSession keycloakSession; + + private final ScrimProviderConfiguration scimProviderConfiguration; + + private final ScimResourceType type; + + private final ScimClient scimClient; + + protected AbstractScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType type) { + this.keycloakSession = keycloakSession; + this.scimProviderConfiguration = scimProviderConfiguration; + this.type = type; + this.scimClient = ScimClient.open(scimProviderConfiguration, type); + } + + public void create(RMM roleMapperModel) { + boolean skip = isSkip(roleMapperModel); + if (skip) + return; + // If mapping exist then it was created by import so skip. + KeycloakId id = getId(roleMapperModel); + if (findById(id).isPresent()) { + return; + } + ResourceNode scimForCreation = toScimForCreation(roleMapperModel); + EntityOnRemoteScimId externalId = scimClient.create(scimForCreation); + createMapping(id, externalId); + } + + protected abstract ResourceNode toScimForCreation(RMM roleMapperModel); + + protected abstract KeycloakId getId(RMM roleMapperModel); + + protected abstract boolean isSkip(RMM roleMapperModel); + + private void createMapping(KeycloakId keycloakId, EntityOnRemoteScimId externalId) { + getScimResourceDao().create(keycloakId, externalId, type); + } + + protected ScimResourceDao getScimResourceDao() { + return ScimResourceDao.newInstance(getKeycloakSession(), scimProviderConfiguration.getId()); + } + + private Optional findById(KeycloakId keycloakId) { + return getScimResourceDao().findById(keycloakId, type); + } + + private KeycloakSession getKeycloakSession() { + return keycloakSession; + } + + public void replace(RMM roleMapperModel) { + try { + if (isSkip(roleMapperModel)) + return; + KeycloakId id = getId(roleMapperModel); + ScimResource scimResource = findById(id).get(); + EntityOnRemoteScimId externalId = scimResource.getExternalIdAsEntityOnRemoteScimId(); + ResourceNode scimForReplace = toScimForReplace(roleMapperModel, externalId); + scimClient.replace(externalId, scimForReplace); + } catch (NoSuchElementException e) { + LOGGER.warnf("failed to replace resource %s, scim mapping not found", getId(roleMapperModel)); + } catch (Exception e) { + LOGGER.error(e); + } + } + + protected abstract ResourceNode toScimForReplace(RMM roleMapperModel, EntityOnRemoteScimId externalId); + + /** + * @deprecated use {@link #delete(KeycloakId)} + */ + @Deprecated + public void delete(String id) { + delete(new KeycloakId(id)); + } + + public void delete(KeycloakId id) { + try { + ScimResource resource = findById(id).get(); + EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId(); + scimClient.delete(externalId); + getScimResourceDao().delete(resource); + } catch (NoSuchElementException e) { + LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id); + } + } + + public void refreshResources(SynchronizationResult syncRes) { + LOGGER.info("Refresh resources"); + getResourceStream().forEach(resource -> { + KeycloakId id = getId(resource); + LOGGER.infof("Reconciling local resource %s", id); + if (!isSkipRefresh(resource)) { + try { + findById(id).get(); + LOGGER.info("Replacing it"); + replace(resource); + } catch (NoSuchElementException e) { + LOGGER.info("Creating it"); + create(resource); + } + syncRes.increaseUpdated(); + } + }); + } + + protected abstract boolean isSkipRefresh(RMM resource); + + protected abstract Stream getResourceStream(); + + public void importResources(SynchronizationResult syncRes) { + LOGGER.info("Import"); + ScimClient scimClient = this.scimClient; + try { + for (S resource : scimClient.listResources()) { + try { + LOGGER.infof("Reconciling remote resource %s", resource); + EntityOnRemoteScimId externalId = resource.getId().map(EntityOnRemoteScimId::new).get(); + try { + ScimResource mapping = getScimResourceDao().findByExternalId(externalId, type).get(); + if (entityExists(mapping.getIdAsKeycloakId())) { + LOGGER.info("Valid mapping found, skipping"); + continue; + } else { + LOGGER.info("Delete a dangling mapping"); + getScimResourceDao().delete(mapping); + } + } catch (NoSuchElementException e) { + // nothing to do + } + + Optional mapped = tryToMap(resource); + if (mapped.isPresent()) { + LOGGER.info("Matched"); + createMapping(mapped.get(), externalId); + } else { + switch (scimProviderConfiguration.getImportAction()) { + case CREATE_LOCAL: + LOGGER.info("Create local resource"); + try { + KeycloakId id = createEntity(resource); + createMapping(id, externalId); + syncRes.increaseAdded(); + } catch (Exception e) { + LOGGER.error(e); + } + break; + case DELETE_REMOTE: + LOGGER.info("Delete remote resource"); + scimClient.delete(externalId); + syncRes.increaseRemoved(); + break; + case NOTHING: + LOGGER.info("Import action set to NOTHING"); + break; + } + } + } catch (Exception e) { + LOGGER.error(e); + e.printStackTrace(); + syncRes.increaseFailed(); + } + } + } catch (ResponseException e) { + throw new RuntimeException(e); + } + } + + protected abstract KeycloakId createEntity(S resource); + + protected abstract Optional tryToMap(S resource); + + protected abstract boolean entityExists(KeycloakId keycloakId); + + public void sync(SynchronizationResult syncRes) { + if (this.scimProviderConfiguration.isSyncImport()) { + this.importResources(syncRes); + } + if (this.scimProviderConfiguration.isSyncRefresh()) { + this.refreshResources(syncRes); + } + } + + protected Meta newMetaLocation(EntityOnRemoteScimId externalId) { + Meta meta = new Meta(); + try { + URI uri = new URI("%ss/%s".formatted(type, externalId.asString())); + meta.setLocation(uri.toString()); + } catch (URISyntaxException e) { + LOGGER.warn(e); + } + return meta; + } + + protected KeycloakDao getKeycloakDao() { + return new KeycloakDao(getKeycloakSession()); + } + + @Override + public void close() { + scimClient.close(); + } + + public ScrimProviderConfiguration getConfiguration() { + return scimProviderConfiguration; + } +} diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java deleted file mode 100644 index 607a7e137c..0000000000 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ /dev/null @@ -1,137 +0,0 @@ -package sh.libre.scim.core; - -import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; -import jakarta.persistence.EntityManager; -import jakarta.persistence.NoResultException; -import jakarta.persistence.TypedQuery; -import org.jboss.logging.Logger; -import org.keycloak.connections.jpa.JpaConnectionProvider; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.RoleMapperModel; -import sh.libre.scim.jpa.ScimResource; - -import java.util.stream.Stream; - -/** - * Abstract class for converting a Keycloack {@link RoleMapperModel} into a SCIM {@link ResourceNode}. - * - * @param The Keycloack {@link RoleMapperModel} (e.g. GroupModel, UserModel) - * @param the SCIM {@link ResourceNode} (e.g. Group, User) - */ -public abstract class Adapter { - - protected final Logger logger; - protected final String realmId; - protected final RealmModel realm; - protected final String type; - protected final String componentId; - protected final EntityManager em; - protected final KeycloakSession session; - - protected String id; - protected String externalId; - protected Boolean skip = false; - - public Adapter(KeycloakSession session, String componentId, String type, Logger logger) { - this.session = session; - this.realm = session.getContext().getRealm(); - this.realmId = session.getContext().getRealm().getId(); - this.componentId = componentId; - this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); - this.type = type; - this.logger = logger; - } - - public String getId() { - return id; - } - - public void setId(String id) { - if (this.id == null) { - this.id = id; - } - } - - public String getExternalId() { - return externalId; - } - - public void setExternalId(String externalId) { - if (this.externalId == null) { - this.externalId = externalId; - } - } - - public String getScimEndpoint() { - return "/" + type + "s"; - } - - public ScimResource toMapping() { - ScimResource entity = new ScimResource(); - entity.setType(type); - entity.setId(id); - entity.setExternalId(externalId); - entity.setComponentId(componentId); - entity.setRealmId(realmId); - return entity; - } - - public TypedQuery 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) - .setParameter("realmId", realmId) - .setParameter("componentId", componentId) - .setParameter("id", id); - } - - public ScimResource getMapping() { - try { - if (this.id != null) { - return this.query("findById", id).getSingleResult(); - } - if (this.externalId != null) { - return this.query("findByExternalId", externalId).getSingleResult(); - } - } catch (NoResultException e) { - } - return null; - } - - public void saveMapping() { - this.em.persist(toMapping()); - } - - public void deleteMapping() { - ScimResource mapping = this.em.merge(toMapping()); - this.em.remove(mapping); - } - - public void apply(ScimResource mapping) { - setId(mapping.getId()); - setExternalId(mapping.getExternalId()); - } - - public abstract void apply(M model); - - public abstract void apply(S resource); - - public abstract Class getResourceClass(); - - public abstract S toScim(); - - public abstract Boolean entityExists(); - - public abstract Boolean tryToMap(); - - public abstract void createEntity() throws Exception; - - public abstract Stream getResourceStream(); - - public abstract Boolean skipRefresh(); -} diff --git a/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java b/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java new file mode 100644 index 0000000000..3249608baa --- /dev/null +++ b/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java @@ -0,0 +1,6 @@ +package sh.libre.scim.core; + +public record EntityOnRemoteScimId( + String asString +) { +} diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java deleted file mode 100644 index b87bf30b68..0000000000 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ /dev/null @@ -1,160 +0,0 @@ -package sh.libre.scim.core; - -import de.captaingoldfish.scim.sdk.common.resources.Group; -import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; -import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member; -import jakarta.persistence.NoResultException; -import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.BooleanUtils; -import org.jboss.logging.Logger; -import org.keycloak.models.GroupModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserModel; -import sh.libre.scim.jpa.ScimResource; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -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 Group.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()); - this.skip = BooleanUtils.TRUE.equals(group.getFirstAttribute("scim-skip")); - } - - @Override - public void apply(Group group) { - setExternalId(group.getId().get()); - setDisplayName(group.getDisplayName().get()); - List groupMembers = group.getMembers(); - if (CollectionUtils.isNotEmpty(groupMembers)) { - this.members = new HashSet<>(); - for (Member groupMember : groupMembers) { - try { - ScimResource userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User") - .getSingleResult(); - this.members.add(userMapping.getId()); - } catch (NoResultException e) { - logger.warnf("member %s not found for scim group %s", groupMember.getValue().get(), group.getId().get()); - } - } - } - } - - @Override - public Group toScim() { - Group group = new Group(); - group.setId(externalId); - group.setExternalId(id); - group.setDisplayName(displayName); - for (String member : members) { - Member groupMember = new Member(); - try { - ScimResource userMapping = this.query("findById", member, "User").getSingleResult(); - logger.debug(userMapping.getExternalId()); - logger.debug(userMapping.getId()); - groupMember.setValue(userMapping.getExternalId()); - URI ref = new URI(String.format("Users/%s", userMapping.getExternalId())); - groupMember.setRef(ref.toString()); - group.addMember(groupMember); - } catch (NoResultException e) { - logger.warnf("member %s not found for group %s", member, id); - } catch (URISyntaxException e) { - logger.warnf("bad ref uri"); - } - } - Meta meta = new Meta(); - try { - URI uri = new URI("Groups/" + externalId); - meta.setLocation(uri.toString()); - } catch (URISyntaxException e) { - logger.warn(e); - } - group.setMeta(meta); - return group; - } - - @Override - public Boolean entityExists() { - if (this.id == null) { - return false; - } - GroupModel group = session.groups().getGroupById(realm, id); - return group != null; - } - - @Override - public Boolean tryToMap() { - Set names = Set.of(externalId, displayName); - Optional group = session.groups().getGroupsStream(realm) - .filter(groupModel -> names.contains(groupModel.getName())) - .findFirst(); - if (group.isPresent()) { - setId(group.get().getId()); - return true; - } - return false; - } - - @Override - public void createEntity() { - GroupModel group = session.groups().createGroup(realm, displayName); - this.id = group.getId(); - for (String mId : members) { - try { - UserModel user = session.users().getUserById(realm, mId); - if (user == null) { - throw new NoResultException(); - } - user.joinGroup(group); - } 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/GroupScimClient.java b/src/main/java/sh/libre/scim/core/GroupScimClient.java deleted file mode 100644 index c1f09e1c28..0000000000 --- a/src/main/java/sh/libre/scim/core/GroupScimClient.java +++ /dev/null @@ -1,42 +0,0 @@ -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 ScrimProviderConfiguration getConfiguration() { - throw new UnsupportedOperationException(); - } - - @Override - public void close() throws Exception { - - } -} diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java new file mode 100644 index 0000000000..0648f10c10 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/GroupScimService.java @@ -0,0 +1,127 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.common.resources.Group; +import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member; +import jakarta.persistence.NoResultException; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.jboss.logging.Logger; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import sh.libre.scim.jpa.ScimResource; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +public class GroupScimService extends AbstractScimService { + private final Logger logger = Logger.getLogger(GroupScimService.class); + + public GroupScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP); + } + + @Override + protected Stream getResourceStream() { + return getKeycloakDao().getGroupsStream(); + } + + @Override + protected boolean entityExists(KeycloakId keycloakId) { + return getKeycloakDao().groupExists(keycloakId); + } + + @Override + protected Optional tryToMap(Group resource) { + String externalId = resource.getId().get(); + String displayName = resource.getDisplayName().get(); + Set names = Set.of(externalId, displayName); + Optional group = getKeycloakDao().getGroupsStream() + .filter(groupModel -> names.contains(groupModel.getName())) + .findFirst(); + return group.map(GroupModel::getId).map(KeycloakId::new); + } + + @Override + protected KeycloakId createEntity(Group resource) { + String displayName = resource.getDisplayName().get(); + GroupModel group = getKeycloakDao().createGroup(displayName); + List groupMembers = resource.getMembers(); + if (CollectionUtils.isNotEmpty(groupMembers)) { + for (Member groupMember : groupMembers) { + try { + EntityOnRemoteScimId externalId = new EntityOnRemoteScimId(groupMember.getValue().get()); + ScimResource userMapping = getScimResourceDao().findUserByExternalId(externalId).get(); + KeycloakId userId = userMapping.getIdAsKeycloakId(); + try { + UserModel user = getKeycloakDao().getUserById(userId); + if (user == null) { + throw new NoResultException(); + } + user.joinGroup(group); + } catch (Exception e) { + logger.warn(e); + } + } catch (NoSuchElementException e) { + logger.warnf("member %s not found for scim group %s", groupMember.getValue().get(), resource.getId().get()); + } + } + } + return new KeycloakId(group.getId()); + } + + @Override + protected boolean isSkip(GroupModel groupModel) { + return BooleanUtils.TRUE.equals(groupModel.getFirstAttribute("scim-skip")); + } + + @Override + protected KeycloakId getId(GroupModel groupModel) { + return new KeycloakId(groupModel.getId()); + } + + @Override + protected Group toScimForCreation(GroupModel groupModel) { + Set members = getKeycloakDao().getGroupMembers(groupModel); + Group group = new Group(); + group.setExternalId(groupModel.getId()); + group.setDisplayName(groupModel.getName()); + for (KeycloakId member : members) { + Member groupMember = new Member(); + try { + ScimResource userMapping = getScimResourceDao().findUserById(member).get(); + logger.debug(userMapping.getExternalIdAsEntityOnRemoteScimId()); + logger.debug(userMapping.getIdAsKeycloakId()); + groupMember.setValue(userMapping.getExternalIdAsEntityOnRemoteScimId().asString()); + URI ref = new URI(String.format("Users/%s", userMapping.getExternalIdAsEntityOnRemoteScimId())); + groupMember.setRef(ref.toString()); + group.addMember(groupMember); + } catch (NoSuchElementException e) { + logger.warnf("member %s not found for group %s", member, groupModel.getId()); + } catch (URISyntaxException e) { + logger.warnf("bad ref uri"); + } + } + return group; + } + + @Override + protected Group toScimForReplace(GroupModel groupModel, EntityOnRemoteScimId externalId) { + Group group = toScimForCreation(groupModel); + group.setId(externalId.asString()); + Meta meta = newMetaLocation(externalId); + group.setMeta(meta); + return group; + } + + @Override + protected boolean isSkipRefresh(GroupModel resource) { + return false; + } +} diff --git a/src/main/java/sh/libre/scim/core/KeycloakDao.java b/src/main/java/sh/libre/scim/core/KeycloakDao.java new file mode 100644 index 0000000000..1062e03e70 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/KeycloakDao.java @@ -0,0 +1,74 @@ +package sh.libre.scim.core; + +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class KeycloakDao { + + private final KeycloakSession keycloakSession; + + public KeycloakDao(KeycloakSession keycloakSession) { + this.keycloakSession = keycloakSession; + } + + private KeycloakSession getKeycloakSession() { + return keycloakSession; + } + + private RealmModel getRealm() { + return getKeycloakSession().getContext().getRealm(); + } + + public boolean groupExists(KeycloakId groupId) { + GroupModel group = getKeycloakSession().groups().getGroupById(getRealm(), groupId.asString()); + return group != null; + } + + public boolean userExists(KeycloakId userId) { + UserModel user = getUserById(userId); + return user != null; + } + + public UserModel getUserById(KeycloakId userId) { + return getKeycloakSession().users().getUserById(getRealm(), userId.asString()); + } + + public Stream getGroupsStream() { + return getKeycloakSession().groups().getGroupsStream(getRealm()); + } + + public GroupModel createGroup(String displayName) { + return getKeycloakSession().groups().createGroup(getRealm(), displayName); + } + + public Set getGroupMembers(GroupModel groupModel) { + return getKeycloakSession().users() + .getGroupMembersStream(getRealm(), groupModel) + .map(UserModel::getId) + .map(KeycloakId::new) + .collect(Collectors.toSet()); + } + + public Stream getUsersStream() { + return getKeycloakSession().users().searchForUserStream(getRealm(), Collections.emptyMap()); + } + + public UserModel getUserByUsername(String username) { + return getKeycloakSession().users().getUserByUsername(getRealm(), username); + } + + public UserModel getUserByEmail(String email) { + return getKeycloakSession().users().getUserByEmail(getRealm(), email); + } + + public UserModel addUser(String username) { + return getKeycloakSession().users().addUser(getRealm(), username); + } +} diff --git a/src/main/java/sh/libre/scim/core/KeycloakId.java b/src/main/java/sh/libre/scim/core/KeycloakId.java new file mode 100644 index 0000000000..f35817dfbe --- /dev/null +++ b/src/main/java/sh/libre/scim/core/KeycloakId.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core; + +public record KeycloakId( + String asString +) { + +} diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 071251bae0..ecbdb8d822 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -3,7 +3,6 @@ package sh.libre.scim.core; import com.google.common.net.HttpHeaders; import de.captaingoldfish.scim.sdk.client.ScimClientConfig; import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder; -import de.captaingoldfish.scim.sdk.client.http.BasicAuth; import de.captaingoldfish.scim.sdk.client.response.ServerResponse; import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException; import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; @@ -12,300 +11,128 @@ import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryConfig; import io.github.resilience4j.retry.RetryRegistry; -import jakarta.persistence.EntityManager; -import jakarta.persistence.NoResultException; -import jakarta.persistence.TypedQuery; import jakarta.ws.rs.ProcessingException; import org.jboss.logging.Logger; -import org.keycloak.component.ComponentModel; -import org.keycloak.connections.jpa.JpaConnectionProvider; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RoleMapperModel; -import org.keycloak.storage.user.SynchronizationResult; -import sh.libre.scim.jpa.ScimResource; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +public class ScimClient implements AutoCloseable { + private final Logger logger = Logger.getLogger(ScimClient.class); -public class ScimClient implements AutoCloseable { - - private static final Logger LOGGER = Logger.getLogger(ScimClient.class); + private final RetryRegistry retryRegistry; private final ScimRequestBuilder scimRequestBuilder; - private final RetryRegistry registry; + private final ScimResourceType scimResourceType; - private final KeycloakSession session; - - private final ComponentModel model; - - private ScimClient(ScimRequestBuilder scimRequestBuilder, RetryRegistry registry, KeycloakSession session, ComponentModel model) { - this.scimRequestBuilder = scimRequestBuilder; - this.registry = registry; - this.session = session; - this.model = model; + { + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(10) + .intervalFunction(IntervalFunction.ofExponentialBackoff()) + .retryExceptions(ProcessingException.class) + .build(); + retryRegistry = RetryRegistry.of(retryConfig); } - public static ScimClient newScimClient(ComponentModel model, KeycloakSession session) { - String authMode = model.get("auth-mode"); - String authorizationHeaderValue = switch (authMode) { - case "BEARER" -> "Bearer " + model.get("auth-pass"); - case "BASIC_AUTH" -> { - BasicAuth basicAuth = BasicAuth.builder() - .username(model.get("auth-user")) - .password(model.get("auth-pass")) - .build(); - yield basicAuth.getAuthorizationHeaderValue(); - } - default -> throw new IllegalArgumentException("authMode " + authMode + " is not supported"); - }; + private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType) { + this.scimRequestBuilder = scimRequestBuilder; + this.scimResourceType = scimResourceType; + } + public static ScimClient open(ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { + String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); Map httpHeaders = new HashMap<>(); - httpHeaders.put(HttpHeaders.AUTHORIZATION, authorizationHeaderValue); - httpHeaders.put(HttpHeaders.CONTENT_TYPE, model.get("content-type")); - + httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue()); + httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType()); ScimClientConfig scimClientConfig = ScimClientConfig.builder() .httpHeaders(httpHeaders) .connectTimeout(5) .requestTimeout(5) .socketTimeout(5) .expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful? - .hostnameVerifier((s, sslSession) -> true) + // TODO Question Indiehoster : should we really allow connection with TLS ? .hostnameVerifier((s, sslSession) -> true) .build(); - - String scimApplicationBaseUrl = model.get("endpoint"); ScimRequestBuilder scimRequestBuilder = new ScimRequestBuilder( scimApplicationBaseUrl, scimClientConfig ); - - RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(10) - .intervalFunction(IntervalFunction.ofExponentialBackoff()) - .retryExceptions(ProcessingException.class) - .build(); - - RetryRegistry retryRegistry = RetryRegistry.of(retryConfig); - return new ScimClient(scimRequestBuilder, retryRegistry, session, model); + return new ScimClient(scimRequestBuilder, scimResourceType); } - private static > TypedQuery findById(A adapter) { - return adapter.query("findById", adapter.getId()); - } - - protected EntityManager getEntityManager() { - return session.getProvider(JpaConnectionProvider.class).getEntityManager(); - } - - protected String getRealmId() { - return session.getContext().getRealm().getId(); - } - - protected > A newAdapter( - Class adapterClass) { - try { - return adapterClass.getDeclaredConstructor(KeycloakSession.class, String.class) - .newInstance(session, this.model.getId()); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - public > void create(Class adapterClass, - M kcModel) { - A adapter = newAdapter(adapterClass); - adapter.apply(kcModel); - if (adapter.skip) - return; - // If mapping exist then it was created by import so skip. - if (!findById(adapter).getResultList().isEmpty()) { - return; - } - Retry retry = registry.retry("create-" + adapter.getId()); - + public EntityOnRemoteScimId create(ResourceNode scimForCreation) { + Retry retry = retryRegistry.retry("create-" + scimForCreation.getId().get()); ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder - .create(adapter.getResourceClass(), adapter.getScimEndpoint()) - .setResource(adapter.toScim()) + .create(getResourceClass(), getScimEndpoint()) + .setResource(scimForCreation) .sendRequest(); } catch (ResponseException e) { throw new RuntimeException(e); } }); + checkResponseIsSuccess(response); + S resource = response.getResource(); + return resource.getId() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new IllegalStateException("created resource does not have id")); + } + private void checkResponseIsSuccess(ServerResponse response) { if (!response.isSuccess()) { - LOGGER.warn(response.getResponseBody()); - LOGGER.warn(response.getHttpStatus()); - } - - adapter.apply(response.getResource()); - adapter.saveMapping(); - } - - public > void replace(Class adapterClass, - M kcModel) { - A adapter = newAdapter(adapterClass); - try { - adapter.apply(kcModel); - if (adapter.skip) - return; - ScimResource resource = findById(adapter).getSingleResult(); - adapter.apply(resource); - Retry retry = registry.retry("replace-" + adapter.getId()); - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder - .update(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) - .setResource(adapter.toScim()) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); - if (!response.isSuccess()) { - LOGGER.warn(response.getResponseBody()); - LOGGER.warn(response.getHttpStatus()); - } - } catch (NoResultException e) { - LOGGER.warnf("failed to replace resource %s, scim mapping not found", adapter.getId()); - } catch (Exception e) { - LOGGER.error(e); + logger.warn(response.getResponseBody()); + logger.warn(response.getHttpStatus()); } } - public > void delete(Class adapterClass, - String id) { - A adapter = newAdapter(adapterClass); - adapter.setId(id); - - try { - ScimResource resource = findById(adapter).getSingleResult(); - adapter.apply(resource); - - Retry retry = registry.retry("delete-" + id); - - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); - - if (!response.isSuccess()) { - LOGGER.warn(response.getResponseBody()); - LOGGER.warn(response.getHttpStatus()); - } - - getEntityManager().remove(resource); - - } catch (NoResultException e) { - LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id); - } + private String getScimEndpoint() { + return scimResourceType.getEndpoint(); } - public > void refreshResources( - Class adapterClass, - SynchronizationResult syncRes) { - LOGGER.info("Refresh resources"); - newAdapter(adapterClass).getResourceStream().forEach(resource -> { - A adapter = newAdapter(adapterClass); - adapter.apply(resource); - LOGGER.infof("Reconciling local resource %s", adapter.getId()); - if (!adapter.skipRefresh()) { - ScimResource mapping = adapter.getMapping(); - if (mapping == null) { - LOGGER.info("Creating it"); - this.create(adapterClass, resource); - } else { - LOGGER.info("Replacing it"); - this.replace(adapterClass, resource); - } - syncRes.increaseUpdated(); + private Class getResourceClass() { + return scimResourceType.getResourceClass(); + } + + public void replace(EntityOnRemoteScimId externalId, ResourceNode scimForReplace) { + Retry retry = retryRegistry.retry("replace-" + scimForReplace.getId().get()); + ServerResponse response = retry.executeSupplier(() -> { + try { + return scimRequestBuilder + .update(getResourceClass(), getScimEndpoint(), externalId.asString()) + .setResource(scimForReplace) + .sendRequest(); + } catch (ResponseException e) { + throw new RuntimeException(e); } }); - + checkResponseIsSuccess(response); } - public > void importResources( - Class adapterClass, SynchronizationResult syncRes) { - LOGGER.info("Import"); - try { - A adapter = newAdapter(adapterClass); - ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); - ListResponse resourceTypeListResponse = response.getResource(); - - for (S resource : resourceTypeListResponse.getListedResources()) { - try { - LOGGER.infof("Reconciling remote resource %s", resource); - adapter = newAdapter(adapterClass); - adapter.apply(resource); - - ScimResource mapping = adapter.getMapping(); - if (mapping != null) { - adapter.apply(mapping); - if (adapter.entityExists()) { - LOGGER.info("Valid mapping found, skipping"); - continue; - } else { - LOGGER.info("Delete a dangling mapping"); - adapter.deleteMapping(); - } - } - - Boolean mapped = adapter.tryToMap(); - if (mapped) { - LOGGER.info("Matched"); - adapter.saveMapping(); - } else { - switch (this.model.get("sync-import-action")) { - case "CREATE_LOCAL": - LOGGER.info("Create local resource"); - try { - adapter.createEntity(); - adapter.saveMapping(); - syncRes.increaseAdded(); - } catch (Exception e) { - LOGGER.error(e); - } - break; - case "DELETE_REMOTE": - LOGGER.info("Delete remote resource"); - scimRequestBuilder - .delete(adapter.getResourceClass(), adapter.getScimEndpoint(), resource.getId().get()) - .sendRequest(); - syncRes.increaseRemoved(); - break; - } - } - } catch (Exception e) { - LOGGER.error(e); - e.printStackTrace(); - syncRes.increaseFailed(); - } + public void delete(EntityOnRemoteScimId externalId) { + Retry retry = retryRegistry.retry("delete-" + externalId.asString()); + ServerResponse response = retry.executeSupplier(() -> { + try { + return scimRequestBuilder.delete(getResourceClass(), getScimEndpoint(), externalId.asString()) + .sendRequest(); + } catch (ResponseException e) { + throw new RuntimeException(e); } - } catch (ResponseException e) { - throw new RuntimeException(e); - } - } - - public > void sync(Class aClass, - SynchronizationResult syncRes) { - if (this.model.get("sync-import", false)) { - this.importResources(aClass, syncRes); - } - if (this.model.get("sync-refresh", false)) { - this.refreshResources(aClass, syncRes); - } + }); + checkResponseIsSuccess(response); } @Override public void close() { scimRequestBuilder.close(); } + + public List listResources() { + ServerResponse> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get().sendRequest(); + ListResponse resourceTypeListResponse = response.getResource(); + return resourceTypeListResponse.getListedResources(); + } } diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java deleted file mode 100644 index 5316bf58a5..0000000000 --- a/src/main/java/sh/libre/scim/core/ScimClientInterface.java +++ /dev/null @@ -1,47 +0,0 @@ -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 endpoint 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 endpoint. - * - * @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 endpoint. - * - * @param resource the resource creation to propagate (e.g. a UserModel) - */ - void replace(M resource); - - /** - * Propagates the deletion of an element to a Scim endpoint. - * - * @param id the deleted resource's id to propagate (e.g. id of a UserModel) - */ - void delete(String id); - - /** - * Synchronizes resources between Scim endpoint and keycloack, according to configuration. - * - * @param result the synchronization result to update for indicating triggered operations (e.g. user deletions) - */ - void sync(SynchronizationResult result); - - /** - * @return the {@link ScrimProviderConfiguration} corresponding to this client. - */ - ScrimProviderConfiguration getConfiguration(); -} diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index b9070fbfe1..142d3ffe10 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -22,8 +22,8 @@ public class ScimDispatcher { private static final Map sessionToScimDispatcher = new LinkedHashMap<>(); private final KeycloakSession session; private boolean clientsInitialized = false; - private final List userScimClients = new ArrayList<>(); - private final List groupScimClients = new ArrayList<>(); + private final List userScimServices = new ArrayList<>(); + private final List groupScimServices = new ArrayList<>(); public static ScimDispatcher createForSession(KeycloakSession session) { @@ -42,32 +42,33 @@ public class ScimDispatcher { public void refreshActiveScimEndpoints() { try { // Step 1: close existing clients - for (GroupScimClient c : groupScimClients) { + for (GroupScimService c : groupScimServices) { c.close(); } - groupScimClients.clear(); - for (UserScimClient c : userScimClients) { + groupScimServices.clear(); + for (UserScimService c : userScimServices) { c.close(); } - userScimClients.clear(); + userScimServices.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 -> { + .forEach(scimEndpointConfigurationRaw -> { + ScrimProviderConfiguration scrimProviderConfiguration = new ScrimProviderConfiguration(scimEndpointConfigurationRaw); try { // Step 3 : create scim clients for each endpoint - if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { - GroupScimClient groupScimClient = GroupScimClient.newGroupScimClient(scimEndpoint, session); - groupScimClients.add(groupScimClient); + if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { + GroupScimService groupScimService = new GroupScimService(session, scrimProviderConfiguration); + groupScimServices.add(groupScimService); } - if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { - UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session); - userScimClients.add(userScimClient); + if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { + UserScimService userScimService = new UserScimService(session, scrimProviderConfiguration); + userScimServices.add(userScimService); } } catch (Exception e) { - logger.warnf("[SCIM] Invalid Endpoint configuration %s : %s", scimEndpoint.getId(), e.getMessage()); + logger.warnf("[SCIM] Invalid Endpoint configuration %s : %s", scimEndpointConfigurationRaw.getId(), e.getMessage()); // TODO is it ok to log and try to create the other clients ? } }); @@ -77,22 +78,22 @@ public class ScimDispatcher { } } - public void dispatchUserModificationToAll(Consumer operationToDispatch) { + public void dispatchUserModificationToAll(Consumer operationToDispatch) { initializeClientsIfNeeded(); - userScimClients.forEach(operationToDispatch); - logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimClients.size()); + userScimServices.forEach(operationToDispatch); + logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimServices.size()); } - public void dispatchGroupModificationToAll(Consumer operationToDispatch) { + public void dispatchGroupModificationToAll(Consumer operationToDispatch) { initializeClientsIfNeeded(); - groupScimClients.forEach(operationToDispatch); - logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimClients.size()); + groupScimServices.forEach(operationToDispatch); + logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimServices.size()); } - public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { + public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { initializeClientsIfNeeded(); // Scim client should already have been created - Optional matchingClient = userScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + Optional matchingClient = userScimServices.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()); @@ -102,10 +103,10 @@ public class ScimDispatcher { } - public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { + public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { initializeClientsIfNeeded(); // Scim client should already have been created - Optional matchingClient = groupScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + Optional matchingClient = groupScimServices.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()); @@ -116,14 +117,14 @@ public class ScimDispatcher { public void close() throws Exception { sessionToScimDispatcher.remove(session); - for (GroupScimClient c : groupScimClients) { + for (GroupScimService c : groupScimServices) { c.close(); } - for (UserScimClient c : userScimClients) { + for (UserScimService c : userScimServices) { c.close(); } - groupScimClients.clear(); - userScimClients.clear(); + groupScimServices.clear(); + userScimServices.clear(); } private void initializeClientsIfNeeded() { diff --git a/src/main/java/sh/libre/scim/core/ScimResourceType.java b/src/main/java/sh/libre/scim/core/ScimResourceType.java new file mode 100644 index 0000000000..23df9f8c88 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimResourceType.java @@ -0,0 +1,29 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.common.resources.Group; +import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; +import de.captaingoldfish.scim.sdk.common.resources.User; + +public enum ScimResourceType { + + USER("/Users", User.class), + + GROUP("/Groups", Group.class); + + private final String endpoint; + + private final Class resourceClass; + + ScimResourceType(String endpoint, Class resourceClass) { + this.endpoint = endpoint; + this.resourceClass = resourceClass; + } + + public String getEndpoint() { + return endpoint; + } + + public Class getResourceClass() { + return (Class) resourceClass; + } +} diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java deleted file mode 100644 index 1de86b3b72..0000000000 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ /dev/null @@ -1,239 +0,0 @@ -package sh.libre.scim.core; - -import de.captaingoldfish.scim.sdk.common.resources.User; -import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; -import de.captaingoldfish.scim.sdk.common.resources.complex.Name; -import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Email; -import de.captaingoldfish.scim.sdk.common.resources.multicomplex.PersonRole; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; -import org.jboss.logging.Logger; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserModel; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Stream; - -public class UserAdapter extends Adapter { - - private String username; - private String displayName; - private String email; - private Boolean active; - private String[] roles; - private String firstName; - private String lastName; - - public UserAdapter(KeycloakSession session, String componentId) { - super(session, componentId, "User", Logger.getLogger(UserAdapter.class)); - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - if (this.username == null) { - this.username = username; - } - } - - public String getDisplayName() { - return displayName; - } - - public void setDisplayName(String displayName) { - if (this.displayName == null) { - this.displayName = displayName; - } - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - if (this.email == null) { - this.email = email; - } - } - - public Boolean getActive() { - return active; - } - - public void setActive(Boolean active) { - if (this.active == null) { - this.active = active; - } - } - - public String[] getRoles() { - return roles; - } - - public void setRoles(String[] roles) { - this.roles = roles; - } - - public String getFirstName() { - return firstName; - } - - public void setFirstName(String firstName) { - this.firstName = firstName; - } - - public String getLastName() { - return lastName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; - } - - @Override - public Class getResourceClass() { - return User.class; - } - - @Override - public void apply(UserModel user) { - setId(user.getId()); - setUsername(user.getUsername()); - String displayName = String.format("%s %s", StringUtils.defaultString(user.getFirstName()), - StringUtils.defaultString(user.getLastName())).trim(); - if (StringUtils.isEmpty(displayName)) { - displayName = user.getUsername(); - } - setDisplayName(displayName); - setEmail(user.getEmail()); - setActive(user.isEnabled()); - setFirstName(user.getFirstName()); - setLastName(user.getLastName()); - Set rolesSet = new HashSet<>(); - user.getGroupsStream().flatMap(g -> g.getRoleMappingsStream()) - .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) - .map((r) -> r.getName()) - .forEach(r -> rolesSet.add(r)); - user.getRoleMappingsStream() - .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) - .map((r) -> r.getName()) - .forEach(r -> rolesSet.add(r)); - String[] roles = new String[rolesSet.size()]; - rolesSet.toArray(roles); - setRoles(roles); - this.skip = BooleanUtils.TRUE.equals(user.getFirstAttribute("scim-skip")); - } - - @Override - public void apply(User user) { - setExternalId(user.getId().get()); - setUsername(user.getUserName().get()); - setDisplayName(user.getDisplayName().get()); - setActive(user.isActive().get()); - if (!user.getEmails().isEmpty()) { - setEmail(user.getEmails().get(0).getValue().get()); - } - } - - @Override - public User toScim() { - User user = new User(); - user.setExternalId(id); - user.setUserName(username); - user.setId(externalId); - user.setDisplayName(displayName); - Name name = new Name(); - name.setFamilyName(lastName); - name.setGivenName(firstName); - user.setName(name); - List emails = new ArrayList<>(); - if (email != null) { - emails.add( - Email.builder().value(getEmail()).build()); - } - user.setEmails(emails); - user.setActive(active); - Meta meta = new Meta(); - try { - URI uri = new URI("Users/" + externalId); - meta.setLocation(uri.toString()); - } catch (URISyntaxException e) { - logger.warn(e); - } - user.setMeta(meta); - List roles = new ArrayList<>(); - for (String role : this.roles) { - PersonRole personRole = new PersonRole(); - personRole.setValue(role); - roles.add(personRole); - } - user.setRoles(roles); - return user; - } - - @Override - public void createEntity() throws Exception { - if (StringUtils.isEmpty(username)) { - throw new Exception("can't create user with empty username"); - } - UserModel user = session.users().addUser(realm, username); - user.setEmail(email); - user.setEnabled(active); - this.id = user.getId(); - } - - @Override - public Boolean entityExists() { - if (this.id == null) { - return false; - } - UserModel user = session.users().getUserById(realm, id); - return user != null; - } - - @Override - public Boolean tryToMap() { - UserModel sameUsernameUser = null; - UserModel sameEmailUser = null; - if (username != null) { - sameUsernameUser = session.users().getUserByUsername(realm, username); - } - if (email != null) { - sameEmailUser = session.users().getUserByEmail(realm, email); - } - if ((sameUsernameUser != null && sameEmailUser != null) - && (!StringUtils.equals(sameUsernameUser.getId(), sameEmailUser.getId()))) { - logger.warnf("found 2 possible users for remote user %s %s", username, email); - return false; - } - if (sameUsernameUser != null) { - this.id = sameUsernameUser.getId(); - return true; - } - if (sameEmailUser != null) { - this.id = sameEmailUser.getId(); - return true; - } - return false; - } - - @Override - public Stream getResourceStream() { - Map params = new HashMap<>(); - return this.session.users().searchForUserStream(realm, params); - } - - @Override - public Boolean skipRefresh() { - return "admin".equals(getUsername()); - } -} diff --git a/src/main/java/sh/libre/scim/core/UserScimClient.java b/src/main/java/sh/libre/scim/core/UserScimClient.java deleted file mode 100644 index 8d2f25279b..0000000000 --- a/src/main/java/sh/libre/scim/core/UserScimClient.java +++ /dev/null @@ -1,288 +0,0 @@ -package sh.libre.scim.core; - -import com.google.common.net.HttpHeaders; -import de.captaingoldfish.scim.sdk.client.ScimClientConfig; -import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder; -import de.captaingoldfish.scim.sdk.client.response.ServerResponse; -import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException; -import de.captaingoldfish.scim.sdk.common.resources.User; -import de.captaingoldfish.scim.sdk.common.response.ListResponse; -import io.github.resilience4j.core.IntervalFunction; -import io.github.resilience4j.retry.Retry; -import io.github.resilience4j.retry.RetryConfig; -import io.github.resilience4j.retry.RetryRegistry; -import jakarta.persistence.EntityManager; -import jakarta.persistence.NoResultException; -import jakarta.ws.rs.ProcessingException; -import org.jboss.logging.Logger; -import org.keycloak.component.ComponentModel; -import org.keycloak.connections.jpa.JpaConnectionProvider; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserModel; -import org.keycloak.storage.user.SynchronizationResult; -import sh.libre.scim.jpa.ScimResource; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -public class UserScimClient implements ScimClientInterface { - - private static final Logger LOGGER = Logger.getLogger(UserScimClient.class); - - private final ScimRequestBuilder scimRequestBuilder; - - private final RetryRegistry retryRegistry; - - private final KeycloakSession keycloakSession; - - private final ScrimProviderConfiguration scimProviderConfiguration; - - /** - * Builds a new {@link UserScimClient} - * - * @param scimRequestBuilder - * @param retryRegistry Retry policy to use - * @param keycloakSession - * @param scimProviderConfiguration - */ - private UserScimClient(ScimRequestBuilder scimRequestBuilder, RetryRegistry retryRegistry, KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) { - this.scimRequestBuilder = scimRequestBuilder; - this.retryRegistry = retryRegistry; - this.keycloakSession = keycloakSession; - this.scimProviderConfiguration = scimProviderConfiguration; - } - - - public static UserScimClient newUserScimClient(ComponentModel componentModel, KeycloakSession session) { - ScrimProviderConfiguration scimProviderConfiguration = new ScrimProviderConfiguration(componentModel); - Map httpHeaders = new HashMap<>(); - httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue()); - httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType()); - - ScimClientConfig scimClientConfig = ScimClientConfig.builder() - .httpHeaders(httpHeaders) - .connectTimeout(5) - .requestTimeout(5) - .socketTimeout(5) - .expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful? - // TODO Question Indiehoster : should we really allow connection with TLS ? .hostnameVerifier((s, sslSession) -> true) - .build(); - - String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); - ScimRequestBuilder scimRequestBuilder = - new ScimRequestBuilder( - scimApplicationBaseUrl, - scimClientConfig - ); - - RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(10) - .intervalFunction(IntervalFunction.ofExponentialBackoff()) - .retryExceptions(ProcessingException.class) - .build(); - - RetryRegistry retryRegistry = RetryRegistry.of(retryConfig); - 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); - if (adapter.skip) - return; - // If mapping exist then it was created by import so skip. - if (adapter.query("findById", adapter.getId()).getResultList().isEmpty()) { - return; - } - Retry retry = retryRegistry.retry("create-" + adapter.getId()); - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder - .create(adapter.getResourceClass(), adapter.getScimEndpoint()) - .setResource(adapter.toScim()) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); - - if (!response.isSuccess()) { - LOGGER.warn(response.getResponseBody()); - LOGGER.warn(response.getHttpStatus()); - } - - adapter.apply(response.getResource()); - adapter.saveMapping(); - } - - @Override - public void replace(UserModel userModel) { - UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); - try { - adapter.apply(userModel); - if (adapter.skip) - return; - ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult(); - adapter.apply(resource); - Retry retry = retryRegistry.retry("replace-" + adapter.getId()); - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder - .update(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) - .setResource(adapter.toScim()) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); - if (!response.isSuccess()) { - LOGGER.warn(response.getResponseBody()); - LOGGER.warn(response.getHttpStatus()); - } - } catch (NoResultException e) { - LOGGER.warnf("failed to replace resource %s, scim mapping not found", adapter.getId()); - } catch (Exception e) { - LOGGER.error(e); - } - } - - @Override - public void delete(String id) { - UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); - adapter.setId(id); - - try { - ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult(); - adapter.apply(resource); - - Retry retry = retryRegistry.retry("delete-" + id); - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); - - if (!response.isSuccess()) { - LOGGER.warn(response.getResponseBody()); - LOGGER.warn(response.getHttpStatus()); - } - EntityManager entityManager = this.keycloakSession.getProvider(JpaConnectionProvider.class).getEntityManager(); - entityManager.remove(resource); - } catch (NoResultException e) { - LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id); - } - } - - public void refreshResources( - SynchronizationResult syncRes) { - LOGGER.info("Refresh resources"); - UserAdapter a = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); - a.getResourceStream().forEach(resource -> { - UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); - adapter.apply(resource); - LOGGER.infof("Reconciling local resource %s", adapter.getId()); - if (!adapter.skipRefresh()) { - ScimResource mapping = adapter.getMapping(); - if (mapping == null) { - LOGGER.info("Creating it"); - this.create(resource); - } else { - LOGGER.info("Replacing it"); - this.replace(resource); - } - syncRes.increaseUpdated(); - } - }); - - } - - public void importResources(SynchronizationResult syncRes) { - LOGGER.info("Import"); - try { - UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); - ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); - ListResponse resourceTypeListResponse = response.getResource(); - - for (User resource : resourceTypeListResponse.getListedResources()) { - try { - LOGGER.infof("Reconciling remote resource %s", resource); - adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); - adapter.apply(resource); - - ScimResource mapping = adapter.getMapping(); - if (mapping != null) { - adapter.apply(mapping); - if (adapter.entityExists()) { - LOGGER.info("Valid mapping found, skipping"); - continue; - } else { - LOGGER.info("Delete a dangling mapping"); - adapter.deleteMapping(); - } - } - - Boolean mapped = adapter.tryToMap(); - if (mapped) { - LOGGER.info("Matched"); - adapter.saveMapping(); - } else { - switch (this.scimProviderConfiguration.getImportAction()) { - case CREATE_LOCAL: - LOGGER.info("Create local resource"); - try { - adapter.createEntity(); - adapter.saveMapping(); - syncRes.increaseAdded(); - } catch (Exception e) { - LOGGER.error(e); - } - break; - case DELETE_REMOTE: - LOGGER.info("Delete remote resource"); - scimRequestBuilder - .delete(adapter.getResourceClass(), adapter.getScimEndpoint(), resource.getId().get()) - .sendRequest(); - syncRes.increaseRemoved(); - break; - case NOTHING: - LOGGER.info("Import action set to NOTHING"); - break; - } - } - } catch (Exception e) { - LOGGER.error(e); - e.printStackTrace(); - syncRes.increaseFailed(); - } - } - } catch (ResponseException e) { - throw new RuntimeException(e); - } - } - - @Override - public void sync(SynchronizationResult syncRes) { - if (this.scimProviderConfiguration.isSyncImport()) { - this.importResources(syncRes); - } - if (this.scimProviderConfiguration.isSyncRefresh()) { - this.refreshResources(syncRes); - } - } - - @Override - public ScrimProviderConfiguration getConfiguration() { - return this.scimProviderConfiguration; - } - - - @Override - public void close() { - scimRequestBuilder.close(); - } -} diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/UserScimService.java new file mode 100644 index 0000000000..9a65849cc3 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/UserScimService.java @@ -0,0 +1,145 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.common.resources.User; +import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; +import de.captaingoldfish.scim.sdk.common.resources.complex.Name; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Email; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.MultiComplexNode; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.PersonRole; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RoleMapperModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +public class UserScimService extends AbstractScimService { + private final Logger logger = Logger.getLogger(UserScimService.class); + + public UserScimService( + KeycloakSession keycloakSession, + ScrimProviderConfiguration scimProviderConfiguration) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER); + } + + @Override + protected Stream getResourceStream() { + return getKeycloakDao().getUsersStream(); + } + + @Override + protected boolean entityExists(KeycloakId keycloakId) { + return getKeycloakDao().userExists(keycloakId); + } + + @Override + protected Optional tryToMap(User resource) { + String username = resource.getUserName().get(); + String email = resource.getEmails().stream() + .findFirst() + .flatMap(MultiComplexNode::getValue) + .orElse(null); + UserModel sameUsernameUser = null; + UserModel sameEmailUser = null; + if (username != null) { + sameUsernameUser = getKeycloakDao().getUserByUsername(username); + } + if (email != null) { + sameEmailUser = getKeycloakDao().getUserByEmail(email); + } + if ((sameUsernameUser != null && sameEmailUser != null) + && (!StringUtils.equals(sameUsernameUser.getId(), sameEmailUser.getId()))) { + logger.warnf("found 2 possible users for remote user %s %s", username, email); + return Optional.empty(); + } + if (sameUsernameUser != null) { + return Optional.of(getId(sameUsernameUser)); + } + if (sameEmailUser != null) { + return Optional.of(getId(sameEmailUser)); + } + return Optional.empty(); + } + + @Override + protected KeycloakId createEntity(User resource) { + String username = resource.getUserName().get(); + if (StringUtils.isEmpty(username)) { + throw new IllegalArgumentException("can't create user with empty username"); + } + UserModel user = getKeycloakDao().addUser(username); + resource.getEmails().stream() + .findFirst() + .flatMap(MultiComplexNode::getValue) + .ifPresent(user::setEmail); + user.setEnabled(resource.isActive().get()); + return new KeycloakId(user.getId()); + } + + @Override + protected boolean isSkip(UserModel userModel) { + return BooleanUtils.TRUE.equals(userModel.getFirstAttribute("scim-skip")); + } + + @Override + protected KeycloakId getId(UserModel userModel) { + return new KeycloakId(userModel.getId()); + } + + @Override + protected User toScimForCreation(UserModel roleMapperModel) { + String firstAndLastName = String.format("%s %s", + StringUtils.defaultString(roleMapperModel.getFirstName()), + StringUtils.defaultString(roleMapperModel.getLastName())).trim(); + String displayName = StringUtils.defaultString(firstAndLastName, roleMapperModel.getUsername()); + Stream groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream); + Stream roleModels = roleMapperModel.getRoleMappingsStream(); + Stream allRoleModels = Stream.concat(groupRoleModels, roleModels); + List roles = allRoleModels + .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) + .map(RoleModel::getName) + .map(roleName -> { + PersonRole personRole = new PersonRole(); + personRole.setValue(roleName); + return personRole; + }) + .toList(); + User user = new User(); + user.setRoles(roles); + user.setExternalId(roleMapperModel.getId()); + user.setUserName(roleMapperModel.getUsername()); + user.setDisplayName(displayName); + Name name = new Name(); + name.setFamilyName(roleMapperModel.getLastName()); + name.setGivenName(roleMapperModel.getFirstName()); + user.setName(name); + List emails = new ArrayList<>(); + if (roleMapperModel.getEmail() != null) { + emails.add( + Email.builder().value(roleMapperModel.getEmail()).build()); + } + user.setEmails(emails); + user.setActive(roleMapperModel.isEnabled()); + return user; + } + + @Override + protected User toScimForReplace(UserModel userModel, EntityOnRemoteScimId externalId) { + User user = toScimForCreation(userModel); + user.setId(externalId.asString()); + Meta meta = newMetaLocation(externalId); + user.setMeta(meta); + return user; + } + + @Override + protected boolean isSkipRefresh(UserModel userModel) { + return "admin".equals(userModel.getUsername()); + } +} diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java index 1de3faa040..5536a6dd96 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResource.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java @@ -7,6 +7,8 @@ import jakarta.persistence.IdClass; import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; +import sh.libre.scim.core.EntityOnRemoteScimId; +import sh.libre.scim.core.KeycloakId; @Entity @IdClass(ScimResourceId.class) @@ -76,4 +78,12 @@ public class ScimResource { public void setType(String type) { this.type = type; } + + public KeycloakId getIdAsKeycloakId() { + return new KeycloakId(id); + } + + public EntityOnRemoteScimId getExternalIdAsEntityOnRemoteScimId() { + return new EntityOnRemoteScimId(externalId); + } } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java new file mode 100644 index 0000000000..7d96b2819d --- /dev/null +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -0,0 +1,97 @@ +package sh.libre.scim.jpa; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.TypedQuery; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.KeycloakSession; +import sh.libre.scim.core.EntityOnRemoteScimId; +import sh.libre.scim.core.KeycloakId; +import sh.libre.scim.core.ScimResourceType; + +import java.util.Optional; + +public class ScimResourceDao { + + private final String realmId; + + private final String componentId; + + private final EntityManager entityManager; + + private ScimResourceDao(String realmId, String componentId, EntityManager entityManager) { + this.realmId = realmId; + this.componentId = componentId; + this.entityManager = entityManager; + } + + public static ScimResourceDao newInstance(KeycloakSession keycloakSession, String componentId) { + String realmId = keycloakSession.getContext().getRealm().getId(); + EntityManager entityManager = keycloakSession.getProvider(JpaConnectionProvider.class).getEntityManager(); + return new ScimResourceDao(realmId, componentId, entityManager); + } + + private EntityManager getEntityManager() { + return entityManager; + } + + private String getRealmId() { + return realmId; + } + + private String getComponentId() { + return componentId; + } + + public void create(KeycloakId id, EntityOnRemoteScimId externalId, ScimResourceType type) { + ScimResource entity = new ScimResource(); + entity.setType(type.name()); + entity.setExternalId(externalId.asString()); + entity.setComponentId(componentId); + entity.setRealmId(realmId); + entity.setId(id.asString()); + entityManager.persist(entity); + } + + private TypedQuery getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) { + return getEntityManager() + .createNamedQuery(queryName, ScimResource.class) + .setParameter("type", type.name()) + .setParameter("realmId", getRealmId()) + .setParameter("componentId", getComponentId()) + .setParameter("id", id); + } + + public Optional findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) { + try { + return Optional.of( + getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult() + ); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + public Optional findById(KeycloakId keycloakId, ScimResourceType type) { + try { + return Optional.of( + getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult() + ); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + public Optional findUserById(KeycloakId id) { + return findById(id, ScimResourceType.USER); + } + + public Optional findUserByExternalId(EntityOnRemoteScimId externalId) { + return findByExternalId(externalId, ScimResourceType.USER); + } + + public void delete(ScimResource resource) { + EntityManager entityManager = getEntityManager(); + entityManager.remove(resource); + } +}