Remove Adapter and refactor ScimClient
This commit is contained in:
parent
2ded3f7236
commit
f81001503d
17 changed files with 822 additions and 1182 deletions
230
src/main/java/sh/libre/scim/core/AbstractScimService.java
Normal file
230
src/main/java/sh/libre/scim/core/AbstractScimService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
|
||||||
}
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
package sh.libre.scim.core;
|
||||||
|
|
||||||
|
public record EntityOnRemoteScimId(
|
||||||
|
String asString
|
||||||
|
) {
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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 {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
127
src/main/java/sh/libre/scim/core/GroupScimService.java
Normal file
127
src/main/java/sh/libre/scim/core/GroupScimService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
74
src/main/java/sh/libre/scim/core/KeycloakDao.java
Normal file
74
src/main/java/sh/libre/scim/core/KeycloakDao.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
7
src/main/java/sh/libre/scim/core/KeycloakId.java
Normal file
7
src/main/java/sh/libre/scim/core/KeycloakId.java
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package sh.libre.scim.core;
|
||||||
|
|
||||||
|
public record KeycloakId(
|
||||||
|
String asString
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
|
@ -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() {
|
||||||
|
|
29
src/main/java/sh/libre/scim/core/ScimResourceType.java
Normal file
29
src/main/java/sh/libre/scim/core/ScimResourceType.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
145
src/main/java/sh/libre/scim/core/UserScimService.java
Normal file
145
src/main/java/sh/libre/scim/core/UserScimService.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
97
src/main/java/sh/libre/scim/jpa/ScimResourceDao.java
Normal file
97
src/main/java/sh/libre/scim/jpa/ScimResourceDao.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue