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 de.captaingoldfish.scim.sdk.client.ScimClientConfig;
|
||||
import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder;
|
||||
import de.captaingoldfish.scim.sdk.client.http.BasicAuth;
|
||||
import de.captaingoldfish.scim.sdk.client.response.ServerResponse;
|
||||
import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
|
||||
|
@ -12,300 +11,128 @@ import io.github.resilience4j.core.IntervalFunction;
|
|||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryConfig;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.NoResultException;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import jakarta.ws.rs.ProcessingException;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RoleMapperModel;
|
||||
import org.keycloak.storage.user.SynchronizationResult;
|
||||
import sh.libre.scim.jpa.ScimResource;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class ScimClient<S extends ResourceNode> implements AutoCloseable {
|
||||
private final Logger logger = Logger.getLogger(ScimClient.class);
|
||||
|
||||
public class ScimClient implements AutoCloseable {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ScimClient.class);
|
||||
private final RetryRegistry retryRegistry;
|
||||
|
||||
private final ScimRequestBuilder scimRequestBuilder;
|
||||
|
||||
private final RetryRegistry registry;
|
||||
private final ScimResourceType scimResourceType;
|
||||
|
||||
private final KeycloakSession session;
|
||||
|
||||
private final ComponentModel model;
|
||||
|
||||
private ScimClient(ScimRequestBuilder scimRequestBuilder, RetryRegistry registry, KeycloakSession session, ComponentModel model) {
|
||||
this.scimRequestBuilder = scimRequestBuilder;
|
||||
this.registry = registry;
|
||||
this.session = session;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public static ScimClient newScimClient(ComponentModel model, KeycloakSession session) {
|
||||
String authMode = model.get("auth-mode");
|
||||
String authorizationHeaderValue = switch (authMode) {
|
||||
case "BEARER" -> "Bearer " + model.get("auth-pass");
|
||||
case "BASIC_AUTH" -> {
|
||||
BasicAuth basicAuth = BasicAuth.builder()
|
||||
.username(model.get("auth-user"))
|
||||
.password(model.get("auth-pass"))
|
||||
{
|
||||
RetryConfig retryConfig = RetryConfig.custom()
|
||||
.maxAttempts(10)
|
||||
.intervalFunction(IntervalFunction.ofExponentialBackoff())
|
||||
.retryExceptions(ProcessingException.class)
|
||||
.build();
|
||||
yield basicAuth.getAuthorizationHeaderValue();
|
||||
retryRegistry = RetryRegistry.of(retryConfig);
|
||||
}
|
||||
default -> throw new IllegalArgumentException("authMode " + authMode + " is not supported");
|
||||
};
|
||||
|
||||
private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType) {
|
||||
this.scimRequestBuilder = scimRequestBuilder;
|
||||
this.scimResourceType = scimResourceType;
|
||||
}
|
||||
|
||||
public static ScimClient open(ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) {
|
||||
String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint();
|
||||
Map<String, String> httpHeaders = new HashMap<>();
|
||||
httpHeaders.put(HttpHeaders.AUTHORIZATION, authorizationHeaderValue);
|
||||
httpHeaders.put(HttpHeaders.CONTENT_TYPE, model.get("content-type"));
|
||||
|
||||
httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue());
|
||||
httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType());
|
||||
ScimClientConfig scimClientConfig = ScimClientConfig.builder()
|
||||
.httpHeaders(httpHeaders)
|
||||
.connectTimeout(5)
|
||||
.requestTimeout(5)
|
||||
.socketTimeout(5)
|
||||
.expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful?
|
||||
.hostnameVerifier((s, sslSession) -> true)
|
||||
// TODO Question Indiehoster : should we really allow connection with TLS ? .hostnameVerifier((s, sslSession) -> true)
|
||||
.build();
|
||||
|
||||
String scimApplicationBaseUrl = model.get("endpoint");
|
||||
ScimRequestBuilder scimRequestBuilder =
|
||||
new ScimRequestBuilder(
|
||||
scimApplicationBaseUrl,
|
||||
scimClientConfig
|
||||
);
|
||||
|
||||
RetryConfig retryConfig = RetryConfig.custom()
|
||||
.maxAttempts(10)
|
||||
.intervalFunction(IntervalFunction.ofExponentialBackoff())
|
||||
.retryExceptions(ProcessingException.class)
|
||||
.build();
|
||||
|
||||
RetryRegistry retryRegistry = RetryRegistry.of(retryConfig);
|
||||
return new ScimClient(scimRequestBuilder, retryRegistry, session, model);
|
||||
return new ScimClient(scimRequestBuilder, scimResourceType);
|
||||
}
|
||||
|
||||
private static <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> TypedQuery<ScimResource> findById(A adapter) {
|
||||
return adapter.query("findById", adapter.getId());
|
||||
}
|
||||
|
||||
protected EntityManager getEntityManager() {
|
||||
return session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||
}
|
||||
|
||||
protected String getRealmId() {
|
||||
return session.getContext().getRealm().getId();
|
||||
}
|
||||
|
||||
protected <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());
|
||||
|
||||
public EntityOnRemoteScimId create(ResourceNode scimForCreation) {
|
||||
Retry retry = retryRegistry.retry("create-" + scimForCreation.getId().get());
|
||||
ServerResponse<S> response = retry.executeSupplier(() -> {
|
||||
try {
|
||||
return scimRequestBuilder
|
||||
.create(adapter.getResourceClass(), adapter.getScimEndpoint())
|
||||
.setResource(adapter.toScim())
|
||||
.create(getResourceClass(), getScimEndpoint())
|
||||
.setResource(scimForCreation)
|
||||
.sendRequest();
|
||||
} catch (ResponseException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
checkResponseIsSuccess(response);
|
||||
S resource = response.getResource();
|
||||
return resource.getId()
|
||||
.map(EntityOnRemoteScimId::new)
|
||||
.orElseThrow(() -> new IllegalStateException("created resource does not have id"));
|
||||
}
|
||||
|
||||
private void checkResponseIsSuccess(ServerResponse<S> response) {
|
||||
if (!response.isSuccess()) {
|
||||
LOGGER.warn(response.getResponseBody());
|
||||
LOGGER.warn(response.getHttpStatus());
|
||||
logger.warn(response.getResponseBody());
|
||||
logger.warn(response.getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
adapter.apply(response.getResource());
|
||||
adapter.saveMapping();
|
||||
private String getScimEndpoint() {
|
||||
return scimResourceType.getEndpoint();
|
||||
}
|
||||
|
||||
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());
|
||||
private Class<S> getResourceClass() {
|
||||
return scimResourceType.getResourceClass();
|
||||
}
|
||||
|
||||
public void replace(EntityOnRemoteScimId externalId, ResourceNode scimForReplace) {
|
||||
Retry retry = retryRegistry.retry("replace-" + scimForReplace.getId().get());
|
||||
ServerResponse<S> response = retry.executeSupplier(() -> {
|
||||
try {
|
||||
return scimRequestBuilder
|
||||
.update(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId())
|
||||
.setResource(adapter.toScim())
|
||||
.update(getResourceClass(), getScimEndpoint(), externalId.asString())
|
||||
.setResource(scimForReplace)
|
||||
.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);
|
||||
}
|
||||
checkResponseIsSuccess(response);
|
||||
}
|
||||
|
||||
public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void delete(Class<A> adapterClass,
|
||||
String id) {
|
||||
A adapter = newAdapter(adapterClass);
|
||||
adapter.setId(id);
|
||||
|
||||
try {
|
||||
ScimResource resource = findById(adapter).getSingleResult();
|
||||
adapter.apply(resource);
|
||||
|
||||
Retry retry = registry.retry("delete-" + id);
|
||||
|
||||
public void delete(EntityOnRemoteScimId externalId) {
|
||||
Retry retry = retryRegistry.retry("delete-" + externalId.asString());
|
||||
ServerResponse<S> response = retry.executeSupplier(() -> {
|
||||
try {
|
||||
return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId())
|
||||
return scimRequestBuilder.delete(getResourceClass(), getScimEndpoint(), externalId.asString())
|
||||
.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(
|
||||
Class<A> adapterClass,
|
||||
SynchronizationResult syncRes) {
|
||||
LOGGER.info("Refresh resources");
|
||||
newAdapter(adapterClass).getResourceStream().forEach(resource -> {
|
||||
A adapter = newAdapter(adapterClass);
|
||||
adapter.apply(resource);
|
||||
LOGGER.infof("Reconciling local resource %s", adapter.getId());
|
||||
if (!adapter.skipRefresh()) {
|
||||
ScimResource mapping = adapter.getMapping();
|
||||
if (mapping == null) {
|
||||
LOGGER.info("Creating it");
|
||||
this.create(adapterClass, resource);
|
||||
} else {
|
||||
LOGGER.info("Replacing it");
|
||||
this.replace(adapterClass, resource);
|
||||
}
|
||||
syncRes.increaseUpdated();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void importResources(
|
||||
Class<A> adapterClass, SynchronizationResult syncRes) {
|
||||
LOGGER.info("Import");
|
||||
try {
|
||||
A adapter = newAdapter(adapterClass);
|
||||
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest();
|
||||
ListResponse<S> resourceTypeListResponse = response.getResource();
|
||||
|
||||
for (S resource : resourceTypeListResponse.getListedResources()) {
|
||||
try {
|
||||
LOGGER.infof("Reconciling remote resource %s", resource);
|
||||
adapter = newAdapter(adapterClass);
|
||||
adapter.apply(resource);
|
||||
|
||||
ScimResource mapping = adapter.getMapping();
|
||||
if (mapping != null) {
|
||||
adapter.apply(mapping);
|
||||
if (adapter.entityExists()) {
|
||||
LOGGER.info("Valid mapping found, skipping");
|
||||
continue;
|
||||
} else {
|
||||
LOGGER.info("Delete a dangling mapping");
|
||||
adapter.deleteMapping();
|
||||
}
|
||||
}
|
||||
|
||||
Boolean mapped = adapter.tryToMap();
|
||||
if (mapped) {
|
||||
LOGGER.info("Matched");
|
||||
adapter.saveMapping();
|
||||
} else {
|
||||
switch (this.model.get("sync-import-action")) {
|
||||
case "CREATE_LOCAL":
|
||||
LOGGER.info("Create local resource");
|
||||
try {
|
||||
adapter.createEntity();
|
||||
adapter.saveMapping();
|
||||
syncRes.increaseAdded();
|
||||
} catch (Exception e) {
|
||||
LOGGER.error(e);
|
||||
}
|
||||
break;
|
||||
case "DELETE_REMOTE":
|
||||
LOGGER.info("Delete remote resource");
|
||||
scimRequestBuilder
|
||||
.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), resource.getId().get())
|
||||
.sendRequest();
|
||||
syncRes.increaseRemoved();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error(e);
|
||||
e.printStackTrace();
|
||||
syncRes.increaseFailed();
|
||||
}
|
||||
}
|
||||
} catch (ResponseException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
checkResponseIsSuccess(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void 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 final KeycloakSession session;
|
||||
private boolean clientsInitialized = false;
|
||||
private final List<UserScimClient> userScimClients = new ArrayList<>();
|
||||
private final List<GroupScimClient> groupScimClients = new ArrayList<>();
|
||||
private final List<UserScimService> userScimServices = new ArrayList<>();
|
||||
private final List<GroupScimService> groupScimServices = new ArrayList<>();
|
||||
|
||||
|
||||
public static ScimDispatcher createForSession(KeycloakSession session) {
|
||||
|
@ -42,32 +42,33 @@ public class ScimDispatcher {
|
|||
public void refreshActiveScimEndpoints() {
|
||||
try {
|
||||
// Step 1: close existing clients
|
||||
for (GroupScimClient c : groupScimClients) {
|
||||
for (GroupScimService c : groupScimServices) {
|
||||
c.close();
|
||||
}
|
||||
groupScimClients.clear();
|
||||
for (UserScimClient c : userScimClients) {
|
||||
groupScimServices.clear();
|
||||
for (UserScimService c : userScimServices) {
|
||||
c.close();
|
||||
}
|
||||
userScimClients.clear();
|
||||
userScimServices.clear();
|
||||
|
||||
// Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory)
|
||||
session.getContext().getRealm().getComponentsStream()
|
||||
.filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId())
|
||||
&& m.get("enabled", true))
|
||||
.forEach(scimEndpoint -> {
|
||||
.forEach(scimEndpointConfigurationRaw -> {
|
||||
ScrimProviderConfiguration scrimProviderConfiguration = new ScrimProviderConfiguration(scimEndpointConfigurationRaw);
|
||||
try {
|
||||
// Step 3 : create scim clients for each endpoint
|
||||
if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
|
||||
GroupScimClient groupScimClient = GroupScimClient.newGroupScimClient(scimEndpoint, session);
|
||||
groupScimClients.add(groupScimClient);
|
||||
if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
|
||||
GroupScimService groupScimService = new GroupScimService(session, scrimProviderConfiguration);
|
||||
groupScimServices.add(groupScimService);
|
||||
}
|
||||
if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
|
||||
UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session);
|
||||
userScimClients.add(userScimClient);
|
||||
if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
|
||||
UserScimService userScimService = new UserScimService(session, scrimProviderConfiguration);
|
||||
userScimServices.add(userScimService);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warnf("[SCIM] Invalid Endpoint configuration %s : %s", scimEndpoint.getId(), e.getMessage());
|
||||
logger.warnf("[SCIM] Invalid Endpoint configuration %s : %s", scimEndpointConfigurationRaw.getId(), e.getMessage());
|
||||
// TODO is it ok to log and try to create the other clients ?
|
||||
}
|
||||
});
|
||||
|
@ -77,22 +78,22 @@ public class ScimDispatcher {
|
|||
}
|
||||
}
|
||||
|
||||
public void dispatchUserModificationToAll(Consumer<UserScimClient> operationToDispatch) {
|
||||
public void dispatchUserModificationToAll(Consumer<UserScimService> operationToDispatch) {
|
||||
initializeClientsIfNeeded();
|
||||
userScimClients.forEach(operationToDispatch);
|
||||
logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimClients.size());
|
||||
userScimServices.forEach(operationToDispatch);
|
||||
logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimServices.size());
|
||||
}
|
||||
|
||||
public void dispatchGroupModificationToAll(Consumer<GroupScimClient> operationToDispatch) {
|
||||
public void dispatchGroupModificationToAll(Consumer<GroupScimService> operationToDispatch) {
|
||||
initializeClientsIfNeeded();
|
||||
groupScimClients.forEach(operationToDispatch);
|
||||
logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimClients.size());
|
||||
groupScimServices.forEach(operationToDispatch);
|
||||
logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimServices.size());
|
||||
}
|
||||
|
||||
public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimClient> operationToDispatch) {
|
||||
public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimService> operationToDispatch) {
|
||||
initializeClientsIfNeeded();
|
||||
// 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()) {
|
||||
operationToDispatch.accept(matchingClient.get());
|
||||
logger.infof("[SCIM] User operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId());
|
||||
|
@ -102,10 +103,10 @@ public class ScimDispatcher {
|
|||
}
|
||||
|
||||
|
||||
public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer<GroupScimClient> operationToDispatch) {
|
||||
public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer<GroupScimService> operationToDispatch) {
|
||||
initializeClientsIfNeeded();
|
||||
// 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()) {
|
||||
operationToDispatch.accept(matchingClient.get());
|
||||
logger.infof("[SCIM] Group operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId());
|
||||
|
@ -116,14 +117,14 @@ public class ScimDispatcher {
|
|||
|
||||
public void close() throws Exception {
|
||||
sessionToScimDispatcher.remove(session);
|
||||
for (GroupScimClient c : groupScimClients) {
|
||||
for (GroupScimService c : groupScimServices) {
|
||||
c.close();
|
||||
}
|
||||
for (UserScimClient c : userScimClients) {
|
||||
for (UserScimService c : userScimServices) {
|
||||
c.close();
|
||||
}
|
||||
groupScimClients.clear();
|
||||
userScimClients.clear();
|
||||
groupScimServices.clear();
|
||||
userScimServices.clear();
|
||||
}
|
||||
|
||||
private void initializeClientsIfNeeded() {
|
||||
|
|
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.NamedQuery;
|
||||
import jakarta.persistence.Table;
|
||||
import sh.libre.scim.core.EntityOnRemoteScimId;
|
||||
import sh.libre.scim.core.KeycloakId;
|
||||
|
||||
@Entity
|
||||
@IdClass(ScimResourceId.class)
|
||||
|
@ -76,4 +78,12 @@ public class ScimResource {
|
|||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public KeycloakId getIdAsKeycloakId() {
|
||||
return new KeycloakId(id);
|
||||
}
|
||||
|
||||
public EntityOnRemoteScimId getExternalIdAsEntityOnRemoteScimId() {
|
||||
return new EntityOnRemoteScimId(externalId);
|
||||
}
|
||||
}
|
||||
|
|
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