Exception Handler - Step 3 : exception categorization
This commit is contained in:
parent
399dfbd17e
commit
9d52c0eb4b
16 changed files with 224 additions and 206 deletions
|
@ -1,14 +1,17 @@
|
|||
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.core.exceptions.ErrorForScimEndpointException;
|
||||
import sh.libre.scim.core.exceptions.InconsistentScimDataException;
|
||||
import sh.libre.scim.core.exceptions.ScimPropagationException;
|
||||
import sh.libre.scim.core.exceptions.UnexpectedScimDataException;
|
||||
import sh.libre.scim.jpa.ScimResourceDao;
|
||||
import sh.libre.scim.jpa.ScimResourceMapping;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
@ -17,7 +20,8 @@ import java.util.Set;
|
|||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public abstract class AbstractScimService<RMM extends RoleMapperModel, S extends ResourceNode> implements AutoCloseable {
|
||||
// TODO Document K and S
|
||||
public abstract class AbstractScimService<K extends RoleMapperModel, S extends ResourceNode> implements AutoCloseable {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(AbstractScimService.class);
|
||||
|
||||
|
@ -36,25 +40,130 @@ public abstract class AbstractScimService<RMM extends RoleMapperModel, S extends
|
|||
this.scimClient = ScimClient.open(scimProviderConfiguration, type);
|
||||
}
|
||||
|
||||
public void create(RMM roleMapperModel) throws ScimPropagationException {
|
||||
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()) {
|
||||
public void create(K roleMapperModel) throws InconsistentScimDataException, ErrorForScimEndpointException {
|
||||
if (isMarkedToIgnore(roleMapperModel)) {
|
||||
// Silently return: resource is explicitly marked as to ignore
|
||||
return;
|
||||
}
|
||||
S scimForCreation = toScimForCreation(roleMapperModel);
|
||||
// If mapping, then we are trying to recreate a user that was already created by import
|
||||
KeycloakId id = getId(roleMapperModel);
|
||||
if (findMappingById(id).isPresent()) {
|
||||
throw new InconsistentScimDataException("Trying to create user with id " + id + ": id already exists in Keycloak database");
|
||||
}
|
||||
S scimForCreation = scimRequestBodyForCreate(roleMapperModel);
|
||||
EntityOnRemoteScimId externalId = scimClient.create(id, scimForCreation);
|
||||
createMapping(id, externalId);
|
||||
}
|
||||
|
||||
protected abstract S toScimForCreation(RMM roleMapperModel);
|
||||
public void update(K roleMapperModel) throws InconsistentScimDataException, ErrorForScimEndpointException {
|
||||
if (isMarkedToIgnore(roleMapperModel)) {
|
||||
// Silently return: resource is explicitly marked as to ignore
|
||||
return;
|
||||
}
|
||||
KeycloakId keycloakId = getId(roleMapperModel);
|
||||
EntityOnRemoteScimId entityOnRemoteScimId = findMappingById(keycloakId)
|
||||
.map(ScimResourceMapping::getExternalIdAsEntityOnRemoteScimId)
|
||||
.orElseThrow(() -> new InconsistentScimDataException("Failed to find SCIM mapping for " + keycloakId));
|
||||
S scimForReplace = scimRequestBodyForUpdate(roleMapperModel, entityOnRemoteScimId);
|
||||
scimClient.update(entityOnRemoteScimId, scimForReplace);
|
||||
}
|
||||
|
||||
protected abstract KeycloakId getId(RMM roleMapperModel);
|
||||
protected abstract S scimRequestBodyForUpdate(K roleMapperModel, EntityOnRemoteScimId externalId);
|
||||
|
||||
protected abstract boolean isSkip(RMM roleMapperModel);
|
||||
public void delete(KeycloakId id) throws InconsistentScimDataException, ErrorForScimEndpointException {
|
||||
ScimResourceMapping resource = findMappingById(id)
|
||||
.orElseThrow(() -> new InconsistentScimDataException("Failed to delete resource %s, scim mapping not found: ".formatted(id)));
|
||||
EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId();
|
||||
scimClient.delete(externalId);
|
||||
getScimResourceDao().delete(resource);
|
||||
}
|
||||
|
||||
public void pushResourcesToScim(SynchronizationResult syncRes) throws ErrorForScimEndpointException, InconsistentScimDataException {
|
||||
LOGGER.info("[SCIM] Push resources to endpoint " + this.getConfiguration().getEndPoint());
|
||||
try (Stream<K> resourcesStream = getResourceStream()) {
|
||||
Set<K> resources = resourcesStream.collect(Collectors.toUnmodifiableSet());
|
||||
for (K resource : resources) {
|
||||
try {
|
||||
KeycloakId id = getId(resource);
|
||||
LOGGER.infof("[SCIM] Reconciling local resource %s", id);
|
||||
if (shouldIgnoreForScimSynchronization(resource)) {
|
||||
LOGGER.infof("[SCIM] Skip local resource %s", id);
|
||||
continue;
|
||||
}
|
||||
if (findMappingById(id).isPresent()) {
|
||||
LOGGER.info("[SCIM] Replacing it");
|
||||
update(resource);
|
||||
} else {
|
||||
LOGGER.info("[SCIM] Creating it");
|
||||
create(resource);
|
||||
}
|
||||
syncRes.increaseUpdated();
|
||||
} catch (ErrorForScimEndpointException e) {
|
||||
// TODO Error handling use strategy SOUPLE or STICT to determine if we re-trhoguh or log & continue
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void pullResourcesFromScim(SynchronizationResult syncRes) {
|
||||
LOGGER.info("[SCIM] Import resources from endpoint " + this.getConfiguration().getEndPoint());
|
||||
for (S resource : scimClient.listResources()) {
|
||||
try {
|
||||
LOGGER.infof("[SCIM] Reconciling remote resource %s", resource);
|
||||
EntityOnRemoteScimId externalId = resource.getId()
|
||||
.map(EntityOnRemoteScimId::new)
|
||||
.orElseThrow(() -> new UnexpectedScimDataException("Remote SCIM resource doesn't have an id, cannot import it in Keycloak"));
|
||||
Optional<ScimResourceMapping> optionalMapping = getScimResourceDao().findByExternalId(externalId, type);
|
||||
|
||||
// If an existing mapping exists, delete potential dangling references
|
||||
if (optionalMapping.isPresent()) {
|
||||
ScimResourceMapping mapping = optionalMapping.get();
|
||||
if (entityExists(mapping.getIdAsKeycloakId())) {
|
||||
LOGGER.debug("[SCIM] Valid mapping found, skipping");
|
||||
continue;
|
||||
} else {
|
||||
LOGGER.info("[SCIM] Delete a dangling mapping");
|
||||
getScimResourceDao().delete(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// Here no keycloak user/group matching the SCIM external id exists
|
||||
// Try to match existing keycloak resource by properties (username, email, name)
|
||||
Optional<KeycloakId> mapped = matchKeycloakMappingByScimProperties(resource);
|
||||
if (mapped.isPresent()) {
|
||||
LOGGER.info("[SCIM] Matched SCIM resource " + externalId + " from properties with keycloak entity " + mapped.get());
|
||||
createMapping(mapped.get(), externalId);
|
||||
syncRes.increaseUpdated();
|
||||
} else {
|
||||
switch (scimProviderConfiguration.getImportAction()) {
|
||||
case CREATE_LOCAL -> {
|
||||
LOGGER.info("[SCIM] Create local resource for SCIM resource " + externalId);
|
||||
KeycloakId id = createEntity(resource);
|
||||
createMapping(id, externalId);
|
||||
syncRes.increaseAdded();
|
||||
}
|
||||
case DELETE_REMOTE -> {
|
||||
LOGGER.info("[SCIM] Delete remote resource " + externalId);
|
||||
scimClient.delete(externalId);
|
||||
}
|
||||
case NOTHING -> LOGGER.info("[SCIM] Import action set to NOTHING");
|
||||
}
|
||||
}
|
||||
} catch (ScimPropagationException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected abstract S scimRequestBodyForCreate(K roleMapperModel);
|
||||
|
||||
protected abstract KeycloakId getId(K roleMapperModel);
|
||||
|
||||
protected abstract boolean isMarkedToIgnore(K roleMapperModel);
|
||||
|
||||
private void createMapping(KeycloakId keycloakId, EntityOnRemoteScimId externalId) {
|
||||
getScimResourceDao().create(keycloakId, externalId, type);
|
||||
|
@ -64,7 +173,7 @@ public abstract class AbstractScimService<RMM extends RoleMapperModel, S extends
|
|||
return ScimResourceDao.newInstance(getKeycloakSession(), scimProviderConfiguration.getId());
|
||||
}
|
||||
|
||||
private Optional<ScimResource> findById(KeycloakId keycloakId) {
|
||||
private Optional<ScimResourceMapping> findMappingById(KeycloakId keycloakId) {
|
||||
return getScimResourceDao().findById(keycloakId, type);
|
||||
}
|
||||
|
||||
|
@ -72,143 +181,23 @@ public abstract class AbstractScimService<RMM extends RoleMapperModel, S extends
|
|||
return keycloakSession;
|
||||
}
|
||||
|
||||
public void replace(RMM roleMapperModel) throws ScimPropagationException {
|
||||
try {
|
||||
if (isSkip(roleMapperModel))
|
||||
return;
|
||||
KeycloakId id = getId(roleMapperModel);
|
||||
Optional<EntityOnRemoteScimId> entityOnRemoteScimId = findById(id)
|
||||
.map(ScimResource::getExternalIdAsEntityOnRemoteScimId);
|
||||
entityOnRemoteScimId
|
||||
.ifPresentOrElse(
|
||||
externalId -> doReplace(roleMapperModel, externalId),
|
||||
() -> {
|
||||
// TODO Exception Handling : should we throw a ScimPropagationException here ?
|
||||
LOGGER.warnf("failed to replace resource %s, scim mapping not found", id);
|
||||
}
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new ScimPropagationException("[SCIM] Error while replacing SCIM resource", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void doReplace(RMM roleMapperModel, EntityOnRemoteScimId externalId) {
|
||||
S scimForReplace = toScimForReplace(roleMapperModel, externalId);
|
||||
scimClient.replace(externalId, scimForReplace);
|
||||
}
|
||||
protected abstract boolean shouldIgnoreForScimSynchronization(K resource);
|
||||
|
||||
protected abstract S toScimForReplace(RMM roleMapperModel, EntityOnRemoteScimId externalId);
|
||||
protected abstract Stream<K> getResourceStream();
|
||||
|
||||
public void delete(KeycloakId id) throws ScimPropagationException {
|
||||
ScimResource resource = findById(id)
|
||||
.orElseThrow(() -> new ScimPropagationException("Failed to delete resource %s, scim mapping not found: ".formatted(id)));
|
||||
EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId();
|
||||
scimClient.delete(externalId);
|
||||
getScimResourceDao().delete(resource);
|
||||
}
|
||||
protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException;
|
||||
|
||||
public void refreshResources(SynchronizationResult syncRes) throws ScimPropagationException {
|
||||
LOGGER.info("[SCIM] Refresh resources for endpoint " + this.getConfiguration().getEndPoint());
|
||||
try (Stream<RMM> resourcesStream = getResourceStream()) {
|
||||
Set<RMM> resources = resourcesStream.collect(Collectors.toUnmodifiableSet());
|
||||
for (RMM resource : resources) {
|
||||
KeycloakId id = getId(resource);
|
||||
LOGGER.infof("[SCIM] Reconciling local resource %s", id);
|
||||
if (isSkipRefresh(resource)) {
|
||||
LOGGER.infof("[SCIM] Skip local resource %s", id);
|
||||
continue;
|
||||
}
|
||||
if (findById(id).isPresent()) {
|
||||
LOGGER.info("[SCIM] Replacing it");
|
||||
replace(resource);
|
||||
} else {
|
||||
LOGGER.info("[SCIM] Creating it");
|
||||
create(resource);
|
||||
}
|
||||
syncRes.increaseUpdated();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract boolean isSkipRefresh(RMM resource);
|
||||
|
||||
protected abstract Stream<RMM> getResourceStream();
|
||||
|
||||
public void importResources(SynchronizationResult syncRes) throws ScimPropagationException {
|
||||
LOGGER.info("[SCIM] Import resources for scim endpoint " + this.getConfiguration().getEndPoint());
|
||||
try {
|
||||
for (S resource : scimClient.listResources()) {
|
||||
try {
|
||||
LOGGER.infof("[SCIM] Reconciling remote resource %s", resource);
|
||||
EntityOnRemoteScimId externalId = resource.getId()
|
||||
.map(EntityOnRemoteScimId::new)
|
||||
.orElseThrow(() -> new ScimPropagationException("remote SCIM resource doesn't have an id"));
|
||||
Optional<ScimResource> optionalMapping = getScimResourceDao().findByExternalId(externalId, type);
|
||||
if (optionalMapping.isPresent()) {
|
||||
ScimResource mapping = optionalMapping.get();
|
||||
if (entityExists(mapping.getIdAsKeycloakId())) {
|
||||
LOGGER.info("[SCIM] Valid mapping found, skipping");
|
||||
continue;
|
||||
} else {
|
||||
LOGGER.info("[SCIM] Delete a dangling mapping");
|
||||
getScimResourceDao().delete(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
Optional<KeycloakId> mapped = tryToMap(resource);
|
||||
if (mapped.isPresent()) {
|
||||
LOGGER.info("[SCIM] Matched");
|
||||
createMapping(mapped.get(), externalId);
|
||||
} else {
|
||||
switch (scimProviderConfiguration.getImportAction()) {
|
||||
case CREATE_LOCAL:
|
||||
LOGGER.info("[SCIM] Create local resource");
|
||||
try {
|
||||
KeycloakId id = createEntity(resource);
|
||||
createMapping(id, externalId);
|
||||
syncRes.increaseAdded();
|
||||
} catch (Exception e) {
|
||||
// TODO ExceptionHandling should we stop and throw ScimPropagationException here ?
|
||||
LOGGER.error(e);
|
||||
}
|
||||
break;
|
||||
case DELETE_REMOTE:
|
||||
LOGGER.info("[SCIM] Delete remote resource");
|
||||
this.scimClient.delete(externalId);
|
||||
syncRes.increaseRemoved();
|
||||
break;
|
||||
case NOTHING:
|
||||
LOGGER.info("[SCIM] Import action set to NOTHING");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// TODO ExceptionHandling should we stop and throw ScimPropagationException here ?
|
||||
LOGGER.error(e);
|
||||
e.printStackTrace();
|
||||
syncRes.increaseFailed();
|
||||
}
|
||||
}
|
||||
} catch (ResponseException e) {
|
||||
// TODO ExceptionHandling should we stop and throw ScimPropagationException here ?
|
||||
LOGGER.error(e);
|
||||
e.printStackTrace();
|
||||
syncRes.increaseFailed();
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract KeycloakId createEntity(S resource) throws ScimPropagationException;
|
||||
|
||||
protected abstract Optional<KeycloakId> tryToMap(S resource) throws ScimPropagationException;
|
||||
protected abstract Optional<KeycloakId> matchKeycloakMappingByScimProperties(S resource) throws InconsistentScimDataException;
|
||||
|
||||
protected abstract boolean entityExists(KeycloakId keycloakId);
|
||||
|
||||
public void sync(SynchronizationResult syncRes) throws ScimPropagationException {
|
||||
public void sync(SynchronizationResult syncRes) throws InconsistentScimDataException, ErrorForScimEndpointException {
|
||||
if (this.scimProviderConfiguration.isSyncImport()) {
|
||||
this.importResources(syncRes);
|
||||
this.pullResourcesFromScim(syncRes);
|
||||
}
|
||||
if (this.scimProviderConfiguration.isSyncRefresh()) {
|
||||
this.refreshResources(syncRes);
|
||||
this.pushResourcesToScim(syncRes);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,8 @@ 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 sh.libre.scim.core.exceptions.UnexpectedScimDataException;
|
||||
import sh.libre.scim.jpa.ScimResourceMapping;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
|
@ -37,7 +38,7 @@ public class GroupScimService extends AbstractScimService<GroupModel, Group> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected Optional<KeycloakId> tryToMap(Group resource) {
|
||||
protected Optional<KeycloakId> matchKeycloakMappingByScimProperties(Group resource) {
|
||||
Set<String> names = new TreeSet<>();
|
||||
resource.getId().ifPresent(names::add);
|
||||
resource.getDisplayName().ifPresent(names::add);
|
||||
|
@ -52,20 +53,20 @@ public class GroupScimService extends AbstractScimService<GroupModel, Group> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected KeycloakId createEntity(Group resource) throws ScimPropagationException {
|
||||
protected KeycloakId createEntity(Group resource) throws UnexpectedScimDataException {
|
||||
String displayName = resource.getDisplayName()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.orElseThrow(() -> new ScimPropagationException("can't create group without name: " + resource));
|
||||
.orElseThrow(() -> new UnexpectedScimDataException("Remote Scim group has empty name, can't create. Resource id = %s".formatted(resource.getId())));
|
||||
GroupModel group = getKeycloakDao().createGroup(displayName);
|
||||
List<Member> groupMembers = resource.getMembers();
|
||||
if (CollectionUtils.isNotEmpty(groupMembers)) {
|
||||
for (Member groupMember : groupMembers) {
|
||||
EntityOnRemoteScimId externalId = groupMember.getValue()
|
||||
.map(EntityOnRemoteScimId::new)
|
||||
.orElseThrow(() -> new ScimPropagationException("can't create group member for group '%s' without id: ".formatted(displayName) + resource));
|
||||
.orElseThrow(() -> new UnexpectedScimDataException("can't create group member for group '%s' without id: ".formatted(displayName) + resource));
|
||||
KeycloakId userId = getScimResourceDao().findUserByExternalId(externalId)
|
||||
.map(ScimResource::getIdAsKeycloakId)
|
||||
.orElseThrow(() -> new ScimPropagationException("can't find mapping for group member %s".formatted(externalId)));
|
||||
.map(ScimResourceMapping::getIdAsKeycloakId)
|
||||
.orElseThrow(() -> new UnexpectedScimDataException("can't find mapping for group member %s".formatted(externalId)));
|
||||
UserModel userModel = getKeycloakDao().getUserById(userId);
|
||||
userModel.joinGroup(group);
|
||||
}
|
||||
|
@ -74,7 +75,7 @@ public class GroupScimService extends AbstractScimService<GroupModel, Group> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected boolean isSkip(GroupModel groupModel) {
|
||||
protected boolean isMarkedToIgnore(GroupModel groupModel) {
|
||||
return BooleanUtils.TRUE.equals(groupModel.getFirstAttribute("scim-skip"));
|
||||
}
|
||||
|
||||
|
@ -84,16 +85,16 @@ public class GroupScimService extends AbstractScimService<GroupModel, Group> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected Group toScimForCreation(GroupModel groupModel) {
|
||||
protected Group scimRequestBodyForCreate(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();
|
||||
Optional<ScimResource> optionalGroupMemberMapping = getScimResourceDao().findUserById(member);
|
||||
Optional<ScimResourceMapping> optionalGroupMemberMapping = getScimResourceDao().findUserById(member);
|
||||
if (optionalGroupMemberMapping.isPresent()) {
|
||||
ScimResource groupMemberMapping = optionalGroupMemberMapping.get();
|
||||
ScimResourceMapping groupMemberMapping = optionalGroupMemberMapping.get();
|
||||
EntityOnRemoteScimId externalIdAsEntityOnRemoteScimId = groupMemberMapping.getExternalIdAsEntityOnRemoteScimId();
|
||||
groupMember.setValue(externalIdAsEntityOnRemoteScimId.asString());
|
||||
URI ref = getUri(ScimResourceType.USER, externalIdAsEntityOnRemoteScimId);
|
||||
|
@ -108,8 +109,8 @@ public class GroupScimService extends AbstractScimService<GroupModel, Group> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected Group toScimForReplace(GroupModel groupModel, EntityOnRemoteScimId externalId) {
|
||||
Group group = toScimForCreation(groupModel);
|
||||
protected Group scimRequestBodyForUpdate(GroupModel groupModel, EntityOnRemoteScimId externalId) {
|
||||
Group group = scimRequestBodyForCreate(groupModel);
|
||||
group.setId(externalId.asString());
|
||||
Meta meta = newMetaLocation(externalId);
|
||||
group.setMeta(meta);
|
||||
|
@ -117,7 +118,7 @@ public class GroupScimService extends AbstractScimService<GroupModel, Group> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected boolean isSkipRefresh(GroupModel resource) {
|
||||
protected boolean shouldIgnoreForScimSynchronization(GroupModel resource) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package sh.libre.scim.core;
|
||||
|
||||
// TODO rename this
|
||||
public record KeycloakId(
|
||||
String asString
|
||||
) {
|
||||
|
|
|
@ -12,6 +12,7 @@ import io.github.resilience4j.retry.RetryConfig;
|
|||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import jakarta.ws.rs.ProcessingException;
|
||||
import org.jboss.logging.Logger;
|
||||
import sh.libre.scim.core.exceptions.ErrorForScimEndpointException;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
|
@ -63,13 +64,14 @@ public class ScimClient<S extends ResourceNode> implements AutoCloseable {
|
|||
return new ScimClient(scimRequestBuilder, scimResourceType);
|
||||
}
|
||||
|
||||
public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws ScimPropagationException {
|
||||
public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws ErrorForScimEndpointException {
|
||||
Optional<String> scimForCreationId = scimForCreation.getId();
|
||||
if (scimForCreationId.isPresent()) {
|
||||
throw new ScimPropagationException(
|
||||
"%s is already created on remote with id %s".formatted(id, scimForCreationId.get())
|
||||
throw new IllegalArgumentException(
|
||||
"User to create should never have an existing id: %s %s".formatted(id, scimForCreationId.get())
|
||||
);
|
||||
}
|
||||
// TODO Exception handling : check that all exceptions are wrapped in server response
|
||||
Retry retry = retryRegistry.retry("create-%s".formatted(id.asString()));
|
||||
ServerResponse<S> response = retry.executeSupplier(() -> scimRequestBuilder
|
||||
.create(getResourceClass(), getScimEndpoint())
|
||||
|
@ -80,15 +82,12 @@ public class ScimClient<S extends ResourceNode> implements AutoCloseable {
|
|||
S resource = response.getResource();
|
||||
return resource.getId()
|
||||
.map(EntityOnRemoteScimId::new)
|
||||
.orElseThrow(() -> new IllegalStateException("created resource does not have id"));
|
||||
.orElseThrow(() -> new ErrorForScimEndpointException("Created SCIM resource does not have id"));
|
||||
}
|
||||
|
||||
private void checkResponseIsSuccess(ServerResponse<S> response) {
|
||||
private void checkResponseIsSuccess(ServerResponse<S> response) throws ErrorForScimEndpointException {
|
||||
if (!response.isSuccess()) {
|
||||
// TODO Exception handling : we should throw a SCIM Progagation exception here
|
||||
LOGGER.warn("[SCIM] Issue on SCIM Server response ");
|
||||
LOGGER.warn(response.getResponseBody());
|
||||
LOGGER.warn(response.getHttpStatus());
|
||||
throw new ErrorForScimEndpointException("Server answered with status " + response.getResponseBody() + ": " + response.getResponseBody());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,8 +99,9 @@ public class ScimClient<S extends ResourceNode> implements AutoCloseable {
|
|||
return scimResourceType.getResourceClass();
|
||||
}
|
||||
|
||||
public void replace(EntityOnRemoteScimId externalId, S scimForReplace) {
|
||||
public void update(EntityOnRemoteScimId externalId, S scimForReplace) throws ErrorForScimEndpointException {
|
||||
Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString()));
|
||||
// TODO Exception handling : check that all exceptions are wrapped in server response
|
||||
ServerResponse<S> response = retry.executeSupplier(() -> scimRequestBuilder
|
||||
.update(getResourceClass(), getScimEndpoint(), externalId.asString())
|
||||
.setResource(scimForReplace)
|
||||
|
@ -110,8 +110,9 @@ public class ScimClient<S extends ResourceNode> implements AutoCloseable {
|
|||
checkResponseIsSuccess(response);
|
||||
}
|
||||
|
||||
public void delete(EntityOnRemoteScimId externalId) {
|
||||
public void delete(EntityOnRemoteScimId externalId) throws ErrorForScimEndpointException {
|
||||
Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString()));
|
||||
// TODO Exception handling : check that all exceptions are wrapped in server response
|
||||
ServerResponse<S> response = retry.executeSupplier(() -> scimRequestBuilder
|
||||
.delete(getResourceClass(), getScimEndpoint(), externalId.asString())
|
||||
.sendRequest()
|
||||
|
|
|
@ -3,6 +3,8 @@ package sh.libre.scim.core;
|
|||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import sh.libre.scim.core.exceptions.ScimExceptionHandler;
|
||||
import sh.libre.scim.core.exceptions.ScimPropagationException;
|
||||
import sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
|
|
@ -13,6 +13,8 @@ import org.keycloak.models.KeycloakSession;
|
|||
import org.keycloak.models.RoleMapperModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import sh.libre.scim.core.exceptions.InconsistentScimDataException;
|
||||
import sh.libre.scim.core.exceptions.UnexpectedScimDataException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
@ -39,7 +41,7 @@ public class UserScimService extends AbstractScimService<UserModel, User> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected Optional<KeycloakId> tryToMap(User resource) {
|
||||
protected Optional<KeycloakId> matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimDataException {
|
||||
Optional<KeycloakId> matchedByUsername = resource.getUserName()
|
||||
.map(getKeycloakDao()::getUserByUsername)
|
||||
.map(this::getId);
|
||||
|
@ -51,9 +53,7 @@ public class UserScimService extends AbstractScimService<UserModel, User> {
|
|||
if (matchedByUsername.isPresent()
|
||||
&& matchedByEmail.isPresent()
|
||||
&& !matchedByUsername.equals(matchedByEmail)) {
|
||||
// TODO Exception Handling : what should we do here ?
|
||||
LOGGER.warnf("found 2 possible users for remote user %s %s", matchedByUsername.get(), matchedByEmail.get());
|
||||
return Optional.empty();
|
||||
throw new InconsistentScimDataException("Found 2 possible users for remote user " + matchedByUsername.get() + " - " + matchedByEmail.get());
|
||||
}
|
||||
if (matchedByUsername.isPresent()) {
|
||||
return matchedByUsername;
|
||||
|
@ -62,21 +62,22 @@ public class UserScimService extends AbstractScimService<UserModel, User> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected KeycloakId createEntity(User resource) throws ScimPropagationException {
|
||||
protected KeycloakId createEntity(User resource) throws UnexpectedScimDataException {
|
||||
String username = resource.getUserName()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.orElseThrow(() -> new ScimPropagationException("can't create user with empty username, resource id = %s".formatted(resource.getId())));
|
||||
.orElseThrow(() -> new UnexpectedScimDataException("Remote Scim user has empty username, can't create. Resource id = %s".formatted(resource.getId())));
|
||||
UserModel user = getKeycloakDao().addUser(username);
|
||||
resource.getEmails().stream()
|
||||
.findFirst()
|
||||
.flatMap(MultiComplexNode::getValue)
|
||||
.ifPresent(user::setEmail);
|
||||
user.setEnabled(resource.isActive().orElseThrow(() -> new ScimPropagationException("can't create user with undefined 'active', resource id = %s".formatted(resource.getId()))));
|
||||
boolean userEnabled = resource.isActive().orElse(false);
|
||||
user.setEnabled(userEnabled);
|
||||
return new KeycloakId(user.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isSkip(UserModel userModel) {
|
||||
protected boolean isMarkedToIgnore(UserModel userModel) {
|
||||
return BooleanUtils.TRUE.equals(userModel.getFirstAttribute("scim-skip"));
|
||||
}
|
||||
|
||||
|
@ -86,7 +87,7 @@ public class UserScimService extends AbstractScimService<UserModel, User> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected User toScimForCreation(UserModel roleMapperModel) {
|
||||
protected User scimRequestBodyForCreate(UserModel roleMapperModel) {
|
||||
String firstAndLastName = String.format("%s %s",
|
||||
StringUtils.defaultString(roleMapperModel.getFirstName()),
|
||||
StringUtils.defaultString(roleMapperModel.getLastName())).trim();
|
||||
|
@ -123,8 +124,8 @@ public class UserScimService extends AbstractScimService<UserModel, User> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected User toScimForReplace(UserModel userModel, EntityOnRemoteScimId externalId) {
|
||||
User user = toScimForCreation(userModel);
|
||||
protected User scimRequestBodyForUpdate(UserModel userModel, EntityOnRemoteScimId externalId) {
|
||||
User user = scimRequestBodyForCreate(userModel);
|
||||
user.setId(externalId.asString());
|
||||
Meta meta = newMetaLocation(externalId);
|
||||
user.setMeta(meta);
|
||||
|
@ -132,7 +133,7 @@ public class UserScimService extends AbstractScimService<UserModel, User> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected boolean isSkipRefresh(UserModel userModel) {
|
||||
protected boolean shouldIgnoreForScimSynchronization(UserModel userModel) {
|
||||
return "admin".equals(userModel.getUsername());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package sh.libre.scim.core.exceptions;
|
||||
|
||||
public class ErrorForScimEndpointException extends ScimPropagationException {
|
||||
|
||||
public ErrorForScimEndpointException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package sh.libre.scim.core.exceptions;
|
||||
|
||||
public class InconsistentScimDataException extends ScimPropagationException {
|
||||
public InconsistentScimDataException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
package sh.libre.scim.core;
|
||||
package sh.libre.scim.core.exceptions;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import sh.libre.scim.core.ScrimProviderConfiguration;
|
||||
|
||||
/**
|
||||
* In charge of dealing with SCIM exceptions by ignoring, logging or rollback transaction according to :
|
|
@ -1,6 +1,6 @@
|
|||
package sh.libre.scim.core;
|
||||
package sh.libre.scim.core.exceptions;
|
||||
|
||||
public class ScimPropagationException extends Exception {
|
||||
public abstract class ScimPropagationException extends Exception {
|
||||
|
||||
public ScimPropagationException(String message) {
|
||||
super(message);
|
|
@ -0,0 +1,7 @@
|
|||
package sh.libre.scim.core.exceptions;
|
||||
|
||||
public class UnexpectedScimDataException extends ScimPropagationException {
|
||||
public UnexpectedScimDataException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -33,7 +33,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
|
||||
private final KeycloakSession session;
|
||||
|
||||
private final KeycloakDao keycloackDao;
|
||||
private final KeycloakDao keycloakDao;
|
||||
|
||||
private final Map<ResourceType, Pattern> patterns = Map.of(
|
||||
ResourceType.USER, Pattern.compile("users/(.+)"),
|
||||
|
@ -45,7 +45,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
|
||||
public ScimEventListenerProvider(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.keycloackDao = new KeycloakDao(session);
|
||||
this.keycloakDao = new KeycloakDao(session);
|
||||
this.dispatcher = new ScimDispatcher(session);
|
||||
}
|
||||
|
||||
|
@ -63,7 +63,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
case UPDATE_EMAIL, UPDATE_PROFILE -> {
|
||||
LOGGER.infof("[SCIM] Propagate User %s - %s", eventType, eventUserId);
|
||||
UserModel user = getUser(eventUserId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
||||
}
|
||||
case DELETE_ACCOUNT -> {
|
||||
LOGGER.infof("[SCIM] Propagate User deletion - %s", eventUserId);
|
||||
|
@ -131,12 +131,12 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
UserModel user = getUser(userId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.create(user));
|
||||
user.getGroupsStream().forEach(group ->
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.update(group)
|
||||
));
|
||||
}
|
||||
case UPDATE -> {
|
||||
UserModel user = getUser(userId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
||||
}
|
||||
case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId));
|
||||
default -> {
|
||||
|
@ -160,7 +160,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
}
|
||||
case UPDATE -> {
|
||||
GroupModel group = getGroup(groupId);
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.replace(group));
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.update(group));
|
||||
}
|
||||
case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId));
|
||||
default -> {
|
||||
|
@ -174,7 +174,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
GroupModel group = getGroup(groupId);
|
||||
group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE);
|
||||
UserModel user = getUser(userId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
||||
}
|
||||
|
||||
private void handleRoleMappingEvent(AdminEvent roleMappingEvent, ScimResourceType type, KeycloakId id) {
|
||||
|
@ -182,14 +182,14 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
switch (type) {
|
||||
case USER -> {
|
||||
UserModel user = getUser(id);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.replace(user));
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
||||
}
|
||||
case GROUP -> {
|
||||
GroupModel group = getGroup(id);
|
||||
session.users()
|
||||
.getGroupMembersStream(session.getContext().getRealm(), group)
|
||||
.forEach(user ->
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.replace(user)
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user)
|
||||
));
|
||||
}
|
||||
default -> {
|
||||
|
@ -204,7 +204,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
// In case of a component deletion
|
||||
if (event.getOperationType() == OperationType.DELETE) {
|
||||
// Check if it was a Scim endpoint configuration, and forward deletion if so
|
||||
// TODO : determine if deleted element is of ScimStorageProvider class and only delete in that case
|
||||
// TODO : determine if deleted element is of ScimStorageProvider class and only refresh in that case
|
||||
dispatcher.refreshActiveScimEndpoints();
|
||||
} else {
|
||||
// In case of CREATE or UPDATE, we can directly use the string representation
|
||||
|
@ -219,11 +219,11 @@ public class ScimEventListenerProvider implements EventListenerProvider {
|
|||
|
||||
|
||||
private UserModel getUser(KeycloakId id) {
|
||||
return keycloackDao.getUserById(id);
|
||||
return keycloakDao.getUserById(id);
|
||||
}
|
||||
|
||||
private GroupModel getGroup(KeycloakId id) {
|
||||
return keycloackDao.getGroupById(id);
|
||||
return keycloakDao.getGroupById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -44,7 +44,7 @@ public class ScimResourceDao {
|
|||
}
|
||||
|
||||
public void create(KeycloakId id, EntityOnRemoteScimId externalId, ScimResourceType type) {
|
||||
ScimResource entity = new ScimResource();
|
||||
ScimResourceMapping entity = new ScimResourceMapping();
|
||||
entity.setType(type.name());
|
||||
entity.setExternalId(externalId.asString());
|
||||
entity.setComponentId(componentId);
|
||||
|
@ -53,16 +53,16 @@ public class ScimResourceDao {
|
|||
entityManager.persist(entity);
|
||||
}
|
||||
|
||||
private TypedQuery<ScimResource> getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) {
|
||||
private TypedQuery<ScimResourceMapping> getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) {
|
||||
return getEntityManager()
|
||||
.createNamedQuery(queryName, ScimResource.class)
|
||||
.createNamedQuery(queryName, ScimResourceMapping.class)
|
||||
.setParameter("type", type.name())
|
||||
.setParameter("realmId", getRealmId())
|
||||
.setParameter("componentId", getComponentId())
|
||||
.setParameter("id", id);
|
||||
}
|
||||
|
||||
public Optional<ScimResource> findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) {
|
||||
public Optional<ScimResourceMapping> findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) {
|
||||
try {
|
||||
return Optional.of(
|
||||
getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult()
|
||||
|
@ -72,7 +72,7 @@ public class ScimResourceDao {
|
|||
}
|
||||
}
|
||||
|
||||
public Optional<ScimResource> findById(KeycloakId keycloakId, ScimResourceType type) {
|
||||
public Optional<ScimResourceMapping> findById(KeycloakId keycloakId, ScimResourceType type) {
|
||||
try {
|
||||
return Optional.of(
|
||||
getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult()
|
||||
|
@ -82,15 +82,15 @@ public class ScimResourceDao {
|
|||
}
|
||||
}
|
||||
|
||||
public Optional<ScimResource> findUserById(KeycloakId id) {
|
||||
public Optional<ScimResourceMapping> findUserById(KeycloakId id) {
|
||||
return findById(id, ScimResourceType.USER);
|
||||
}
|
||||
|
||||
public Optional<ScimResource> findUserByExternalId(EntityOnRemoteScimId externalId) {
|
||||
public Optional<ScimResourceMapping> findUserByExternalId(EntityOnRemoteScimId externalId) {
|
||||
return findByExternalId(externalId, ScimResourceType.USER);
|
||||
}
|
||||
|
||||
public void delete(ScimResource resource) {
|
||||
public void delete(ScimResourceMapping resource) {
|
||||
EntityManager entityManager = getEntityManager();
|
||||
entityManager.remove(resource);
|
||||
}
|
||||
|
|
|
@ -12,12 +12,12 @@ import sh.libre.scim.core.KeycloakId;
|
|||
|
||||
@Entity
|
||||
@IdClass(ScimResourceId.class)
|
||||
@Table(name = "SCIM_RESOURCE")
|
||||
@Table(name = "SCIM_RESOURCE_MAPPING")
|
||||
@NamedQueries({
|
||||
@NamedQuery(name = "findById", query = "from ScimResource where realmId = :realmId and componentId = :componentId and type = :type and id = :id"),
|
||||
@NamedQuery(name = "findByExternalId", query = "from ScimResource where realmId = :realmId and componentId = :componentId and type = :type and externalId = :id")
|
||||
})
|
||||
public class ScimResource {
|
||||
public class ScimResourceMapping {
|
||||
|
||||
@Id
|
||||
@Column(name = "ID", nullable = false)
|
|
@ -9,7 +9,7 @@ public class ScimResourceProvider implements JpaEntityProvider {
|
|||
|
||||
@Override
|
||||
public List<Class<?>> getEntities() {
|
||||
return Collections.singletonList(ScimResource.class);
|
||||
return Collections.singletonList(ScimResourceMapping.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -79,7 +79,7 @@ public class ScimEndpointConfigurationStorageProviderFactory
|
|||
for (GroupModel group : session.groups().getGroupsStream(realm)
|
||||
.filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) {
|
||||
LOGGER.infof("[SCIM] Dirty group: %s", group.getName());
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.replace(group));
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.update(group));
|
||||
group.removeAttribute("scim-dirty");
|
||||
}
|
||||
dispatcher.close();
|
||||
|
|
Loading…
Reference in a new issue