Remove Adapter and refactor ScimClient

This commit is contained in:
Alex Morel 2024-06-20 17:17:38 +02:00
parent 2ded3f7236
commit f81001503d
17 changed files with 822 additions and 1182 deletions

View file

@ -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<RMM extends RoleMapperModel, S extends ResourceNode> 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<S> 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<ScimResource> 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<RMM> getResourceStream();
public void importResources(SynchronizationResult syncRes) {
LOGGER.info("Import");
ScimClient<S> 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<KeycloakId> 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<KeycloakId> 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;
}
}

View file

@ -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 <M> The Keycloack {@link RoleMapperModel} (e.g. GroupModel, UserModel)
* @param <S> the SCIM {@link ResourceNode} (e.g. Group, User)
*/
public abstract class Adapter<M extends RoleMapperModel, S extends ResourceNode> {
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<ScimResource> query(String query, String id) {
return query(query, id, type);
}
public TypedQuery<ScimResource> 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<S> getResourceClass();
public abstract S toScim();
public abstract Boolean entityExists();
public abstract Boolean tryToMap();
public abstract void createEntity() throws Exception;
public abstract Stream<M> getResourceStream();
public abstract Boolean skipRefresh();
}

View file

@ -0,0 +1,6 @@
package sh.libre.scim.core;
public record EntityOnRemoteScimId(
String asString
) {
}

View file

@ -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<GroupModel, Group> {
private String displayName;
private Set<String> 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<Group> 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<Member> 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<String> names = Set.of(externalId, displayName);
Optional<GroupModel> 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<GroupModel> getResourceStream() {
return this.session.groups().getGroupsStream(this.session.getContext().getRealm());
}
@Override
public Boolean skipRefresh() {
return false;
}
}

View file

@ -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<GroupModel> {
public static GroupScimClient newGroupScimClient(ComponentModel scimServer, KeycloakSession session) {
return new GroupScimClient();
}
@Override
public void create(GroupModel resource) {
throw new UnsupportedOperationException();
}
@Override
public void replace(GroupModel resource) {
throw new UnsupportedOperationException();
}
@Override
public void delete(String id) {
throw new UnsupportedOperationException();
}
@Override
public void sync(SynchronizationResult result) {
throw new UnsupportedOperationException();
}
@Override
public ScrimProviderConfiguration getConfiguration() {
throw new UnsupportedOperationException();
}
@Override
public void close() throws Exception {
}
}

View file

@ -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<GroupModel, Group> {
private final Logger logger = Logger.getLogger(GroupScimService.class);
public GroupScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) {
super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP);
}
@Override
protected Stream<GroupModel> getResourceStream() {
return getKeycloakDao().getGroupsStream();
}
@Override
protected boolean entityExists(KeycloakId keycloakId) {
return getKeycloakDao().groupExists(keycloakId);
}
@Override
protected Optional<KeycloakId> tryToMap(Group resource) {
String externalId = resource.getId().get();
String displayName = resource.getDisplayName().get();
Set<String> names = Set.of(externalId, displayName);
Optional<GroupModel> 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<Member> 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<KeycloakId> 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;
}
}

View file

@ -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<GroupModel> getGroupsStream() {
return getKeycloakSession().groups().getGroupsStream(getRealm());
}
public GroupModel createGroup(String displayName) {
return getKeycloakSession().groups().createGroup(getRealm(), displayName);
}
public Set<KeycloakId> getGroupMembers(GroupModel groupModel) {
return getKeycloakSession().users()
.getGroupMembersStream(getRealm(), groupModel)
.map(UserModel::getId)
.map(KeycloakId::new)
.collect(Collectors.toSet());
}
public Stream<UserModel> 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);
}
}

View file

@ -0,0 +1,7 @@
package sh.libre.scim.core;
public record KeycloakId(
String asString
) {
}

View file

@ -3,7 +3,6 @@ package sh.libre.scim.core;
import com.google.common.net.HttpHeaders; import com.google.common.net.HttpHeaders;
import de.captaingoldfish.scim.sdk.client.ScimClientConfig; import de.captaingoldfish.scim.sdk.client.ScimClientConfig;
import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder; 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.client.response.ServerResponse;
import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException; import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException;
import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; 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.Retry;
import io.github.resilience4j.retry.RetryConfig; import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry; import io.github.resilience4j.retry.RetryRegistry;
import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.persistence.TypedQuery;
import jakarta.ws.rs.ProcessingException; import jakarta.ws.rs.ProcessingException;
import org.jboss.logging.Logger; 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.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
public class ScimClient<S extends ResourceNode> implements AutoCloseable {
private final Logger logger = Logger.getLogger(ScimClient.class);
public class ScimClient implements AutoCloseable { private final RetryRegistry retryRegistry;
private static final Logger LOGGER = Logger.getLogger(ScimClient.class);
private final ScimRequestBuilder scimRequestBuilder; private final ScimRequestBuilder scimRequestBuilder;
private final RetryRegistry registry; private final ScimResourceType scimResourceType;
private final KeycloakSession session; {
RetryConfig retryConfig = RetryConfig.custom()
private final ComponentModel model; .maxAttempts(10)
.intervalFunction(IntervalFunction.ofExponentialBackoff())
private ScimClient(ScimRequestBuilder scimRequestBuilder, RetryRegistry registry, KeycloakSession session, ComponentModel model) { .retryExceptions(ProcessingException.class)
this.scimRequestBuilder = scimRequestBuilder; .build();
this.registry = registry; retryRegistry = RetryRegistry.of(retryConfig);
this.session = session;
this.model = model;
} }
public static ScimClient newScimClient(ComponentModel model, KeycloakSession session) { private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType) {
String authMode = model.get("auth-mode"); this.scimRequestBuilder = scimRequestBuilder;
String authorizationHeaderValue = switch (authMode) { this.scimResourceType = scimResourceType;
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");
};
public static ScimClient open(ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) {
String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint();
Map<String, String> httpHeaders = new HashMap<>(); Map<String, String> httpHeaders = new HashMap<>();
httpHeaders.put(HttpHeaders.AUTHORIZATION, authorizationHeaderValue); httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue());
httpHeaders.put(HttpHeaders.CONTENT_TYPE, model.get("content-type")); httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType());
ScimClientConfig scimClientConfig = ScimClientConfig.builder() ScimClientConfig scimClientConfig = ScimClientConfig.builder()
.httpHeaders(httpHeaders) .httpHeaders(httpHeaders)
.connectTimeout(5) .connectTimeout(5)
.requestTimeout(5) .requestTimeout(5)
.socketTimeout(5) .socketTimeout(5)
.expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful? .expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful?
.hostnameVerifier((s, sslSession) -> true) // TODO Question Indiehoster : should we really allow connection with TLS ? .hostnameVerifier((s, sslSession) -> true)
.build(); .build();
String scimApplicationBaseUrl = model.get("endpoint");
ScimRequestBuilder scimRequestBuilder = ScimRequestBuilder scimRequestBuilder =
new ScimRequestBuilder( new ScimRequestBuilder(
scimApplicationBaseUrl, scimApplicationBaseUrl,
scimClientConfig scimClientConfig
); );
return new ScimClient(scimRequestBuilder, scimResourceType);
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);
} }
private static <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> TypedQuery<ScimResource> findById(A adapter) { public EntityOnRemoteScimId create(ResourceNode scimForCreation) {
return adapter.query("findById", adapter.getId()); Retry retry = retryRegistry.retry("create-" + scimForCreation.getId().get());
}
protected EntityManager getEntityManager() {
return session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
protected String getRealmId() {
return session.getContext().getRealm().getId();
}
protected <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> A newAdapter(
Class<A> adapterClass) {
try {
return adapterClass.getDeclaredConstructor(KeycloakSession.class, String.class)
.newInstance(session, this.model.getId());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void create(Class<A> 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());
ServerResponse<S> response = retry.executeSupplier(() -> { ServerResponse<S> response = retry.executeSupplier(() -> {
try { try {
return scimRequestBuilder return scimRequestBuilder
.create(adapter.getResourceClass(), adapter.getScimEndpoint()) .create(getResourceClass(), getScimEndpoint())
.setResource(adapter.toScim()) .setResource(scimForCreation)
.sendRequest(); .sendRequest();
} catch (ResponseException e) { } catch (ResponseException e) {
throw new RuntimeException(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<S> response) {
if (!response.isSuccess()) { if (!response.isSuccess()) {
LOGGER.warn(response.getResponseBody()); logger.warn(response.getResponseBody());
LOGGER.warn(response.getHttpStatus()); logger.warn(response.getHttpStatus());
}
adapter.apply(response.getResource());
adapter.saveMapping();
}
public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void replace(Class<A> 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<S> 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);
} }
} }
public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void delete(Class<A> adapterClass, private String getScimEndpoint() {
String id) { return scimResourceType.getEndpoint();
A adapter = newAdapter(adapterClass);
adapter.setId(id);
try {
ScimResource resource = findById(adapter).getSingleResult();
adapter.apply(resource);
Retry retry = registry.retry("delete-" + id);
ServerResponse<S> 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);
}
} }
public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void refreshResources( private Class<S> getResourceClass() {
Class<A> adapterClass, return scimResourceType.getResourceClass();
SynchronizationResult syncRes) { }
LOGGER.info("Refresh resources");
newAdapter(adapterClass).getResourceStream().forEach(resource -> { public void replace(EntityOnRemoteScimId externalId, ResourceNode scimForReplace) {
A adapter = newAdapter(adapterClass); Retry retry = retryRegistry.retry("replace-" + scimForReplace.getId().get());
adapter.apply(resource); ServerResponse<S> response = retry.executeSupplier(() -> {
LOGGER.infof("Reconciling local resource %s", adapter.getId()); try {
if (!adapter.skipRefresh()) { return scimRequestBuilder
ScimResource mapping = adapter.getMapping(); .update(getResourceClass(), getScimEndpoint(), externalId.asString())
if (mapping == null) { .setResource(scimForReplace)
LOGGER.info("Creating it"); .sendRequest();
this.create(adapterClass, resource); } catch (ResponseException e) {
} else { throw new RuntimeException(e);
LOGGER.info("Replacing it");
this.replace(adapterClass, resource);
}
syncRes.increaseUpdated();
} }
}); });
checkResponseIsSuccess(response);
} }
public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void importResources( public void delete(EntityOnRemoteScimId externalId) {
Class<A> adapterClass, SynchronizationResult syncRes) { Retry retry = retryRegistry.retry("delete-" + externalId.asString());
LOGGER.info("Import"); ServerResponse<S> response = retry.executeSupplier(() -> {
try { try {
A adapter = newAdapter(adapterClass); return scimRequestBuilder.delete(getResourceClass(), getScimEndpoint(), externalId.asString())
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); .sendRequest();
ListResponse<S> resourceTypeListResponse = response.getResource(); } catch (ResponseException e) {
throw new RuntimeException(e);
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();
}
} }
} catch (ResponseException e) { });
throw new RuntimeException(e); checkResponseIsSuccess(response);
}
}
public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void sync(Class<A> 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);
}
} }
@Override @Override
public void close() { public void close() {
scimRequestBuilder.close(); scimRequestBuilder.close();
} }
public List<S> listResources() {
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get().sendRequest();
ListResponse<S> resourceTypeListResponse = response.getResource();
return resourceTypeListResponse.getListedResources();
}
} }

View file

@ -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 <M> the keycloack model to synchronize (e.g. UserModel or GroupModel)
*/
public interface ScimClientInterface<M extends RoleMapperModel> extends AutoCloseable {
/**
* Propagates the creation of the given keycloack model to a Scim 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();
}

View file

@ -22,8 +22,8 @@ public class ScimDispatcher {
private static final Map<KeycloakSession, ScimDispatcher> sessionToScimDispatcher = new LinkedHashMap<>(); private static final Map<KeycloakSession, ScimDispatcher> sessionToScimDispatcher = new LinkedHashMap<>();
private final KeycloakSession session; private final KeycloakSession session;
private boolean clientsInitialized = false; private boolean clientsInitialized = false;
private final List<UserScimClient> userScimClients = new ArrayList<>(); private final List<UserScimService> userScimServices = new ArrayList<>();
private final List<GroupScimClient> groupScimClients = new ArrayList<>(); private final List<GroupScimService> groupScimServices = new ArrayList<>();
public static ScimDispatcher createForSession(KeycloakSession session) { public static ScimDispatcher createForSession(KeycloakSession session) {
@ -42,32 +42,33 @@ public class ScimDispatcher {
public void refreshActiveScimEndpoints() { public void refreshActiveScimEndpoints() {
try { try {
// Step 1: close existing clients // Step 1: close existing clients
for (GroupScimClient c : groupScimClients) { for (GroupScimService c : groupScimServices) {
c.close(); c.close();
} }
groupScimClients.clear(); groupScimServices.clear();
for (UserScimClient c : userScimClients) { for (UserScimService c : userScimServices) {
c.close(); c.close();
} }
userScimClients.clear(); userScimServices.clear();
// Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory) // Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory)
session.getContext().getRealm().getComponentsStream() session.getContext().getRealm().getComponentsStream()
.filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId()) .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId())
&& m.get("enabled", true)) && m.get("enabled", true))
.forEach(scimEndpoint -> { .forEach(scimEndpointConfigurationRaw -> {
ScrimProviderConfiguration scrimProviderConfiguration = new ScrimProviderConfiguration(scimEndpointConfigurationRaw);
try { try {
// Step 3 : create scim clients for each endpoint // Step 3 : create scim clients for each endpoint
if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
GroupScimClient groupScimClient = GroupScimClient.newGroupScimClient(scimEndpoint, session); GroupScimService groupScimService = new GroupScimService(session, scrimProviderConfiguration);
groupScimClients.add(groupScimClient); groupScimServices.add(groupScimService);
} }
if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session); UserScimService userScimService = new UserScimService(session, scrimProviderConfiguration);
userScimClients.add(userScimClient); userScimServices.add(userScimService);
} }
} catch (Exception e) { } 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 ? // TODO is it ok to log and try to create the other clients ?
} }
}); });
@ -77,22 +78,22 @@ public class ScimDispatcher {
} }
} }
public void dispatchUserModificationToAll(Consumer<UserScimClient> operationToDispatch) { public void dispatchUserModificationToAll(Consumer<UserScimService> operationToDispatch) {
initializeClientsIfNeeded(); initializeClientsIfNeeded();
userScimClients.forEach(operationToDispatch); userScimServices.forEach(operationToDispatch);
logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimClients.size()); logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimServices.size());
} }
public void dispatchGroupModificationToAll(Consumer<GroupScimClient> operationToDispatch) { public void dispatchGroupModificationToAll(Consumer<GroupScimService> operationToDispatch) {
initializeClientsIfNeeded(); initializeClientsIfNeeded();
groupScimClients.forEach(operationToDispatch); groupScimServices.forEach(operationToDispatch);
logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimClients.size()); logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimServices.size());
} }
public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimClient> operationToDispatch) { public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimService> operationToDispatch) {
initializeClientsIfNeeded(); initializeClientsIfNeeded();
// Scim client should already have been created // Scim client should already have been created
Optional<UserScimClient> matchingClient = userScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); Optional<UserScimService> matchingClient = userScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
if (matchingClient.isPresent()) { if (matchingClient.isPresent()) {
operationToDispatch.accept(matchingClient.get()); operationToDispatch.accept(matchingClient.get());
logger.infof("[SCIM] User operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId()); 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<GroupScimClient> operationToDispatch) { public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer<GroupScimService> operationToDispatch) {
initializeClientsIfNeeded(); initializeClientsIfNeeded();
// Scim client should already have been created // Scim client should already have been created
Optional<GroupScimClient> matchingClient = groupScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); Optional<GroupScimService> matchingClient = groupScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
if (matchingClient.isPresent()) { if (matchingClient.isPresent()) {
operationToDispatch.accept(matchingClient.get()); operationToDispatch.accept(matchingClient.get());
logger.infof("[SCIM] Group operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId()); 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 { public void close() throws Exception {
sessionToScimDispatcher.remove(session); sessionToScimDispatcher.remove(session);
for (GroupScimClient c : groupScimClients) { for (GroupScimService c : groupScimServices) {
c.close(); c.close();
} }
for (UserScimClient c : userScimClients) { for (UserScimService c : userScimServices) {
c.close(); c.close();
} }
groupScimClients.clear(); groupScimServices.clear();
userScimClients.clear(); userScimServices.clear();
} }
private void initializeClientsIfNeeded() { private void initializeClientsIfNeeded() {

View file

@ -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<? extends ResourceNode> resourceClass;
ScimResourceType(String endpoint, Class<? extends ResourceNode> resourceClass) {
this.endpoint = endpoint;
this.resourceClass = resourceClass;
}
public String getEndpoint() {
return endpoint;
}
public <T extends ResourceNode> Class<T> getResourceClass() {
return (Class<T>) resourceClass;
}
}

View file

@ -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<UserModel, User> {
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<User> 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<String> 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<Email> 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<PersonRole> 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<UserModel> getResourceStream() {
Map<String, String> params = new HashMap<>();
return this.session.users().searchForUserStream(realm, params);
}
@Override
public Boolean skipRefresh() {
return "admin".equals(getUsername());
}
}

View file

@ -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<UserModel> {
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<String, String> 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<User> 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<User> 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<User> 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<ListResponse<User>> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest();
ListResponse<User> 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();
}
}

View file

@ -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<UserModel, User> {
private final Logger logger = Logger.getLogger(UserScimService.class);
public UserScimService(
KeycloakSession keycloakSession,
ScrimProviderConfiguration scimProviderConfiguration) {
super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER);
}
@Override
protected Stream<UserModel> getResourceStream() {
return getKeycloakDao().getUsersStream();
}
@Override
protected boolean entityExists(KeycloakId keycloakId) {
return getKeycloakDao().userExists(keycloakId);
}
@Override
protected Optional<KeycloakId> 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<RoleModel> groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream);
Stream<RoleModel> roleModels = roleMapperModel.getRoleMappingsStream();
Stream<RoleModel> allRoleModels = Stream.concat(groupRoleModels, roleModels);
List<PersonRole> 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<Email> 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());
}
}

View file

@ -7,6 +7,8 @@ import jakarta.persistence.IdClass;
import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery; import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import sh.libre.scim.core.EntityOnRemoteScimId;
import sh.libre.scim.core.KeycloakId;
@Entity @Entity
@IdClass(ScimResourceId.class) @IdClass(ScimResourceId.class)
@ -76,4 +78,12 @@ public class ScimResource {
public void setType(String type) { public void setType(String type) {
this.type = type; this.type = type;
} }
public KeycloakId getIdAsKeycloakId() {
return new KeycloakId(id);
}
public EntityOnRemoteScimId getExternalIdAsEntityOnRemoteScimId() {
return new EntityOnRemoteScimId(externalId);
}
} }

View file

@ -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<ScimResource> 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<ScimResource> findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) {
try {
return Optional.of(
getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult()
);
} catch (NoResultException e) {
return Optional.empty();
}
}
public Optional<ScimResource> findById(KeycloakId keycloakId, ScimResourceType type) {
try {
return Optional.of(
getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult()
);
} catch (NoResultException e) {
return Optional.empty();
}
}
public Optional<ScimResource> findUserById(KeycloakId id) {
return findById(id, ScimResourceType.USER);
}
public Optional<ScimResource> findUserByExternalId(EntityOnRemoteScimId externalId) {
return findByExternalId(externalId, ScimResourceType.USER);
}
public void delete(ScimResource resource) {
EntityManager entityManager = getEntityManager();
entityManager.remove(resource);
}
}