add group + group sync support

This commit is contained in:
Hugo Renard 2022-02-23 18:14:35 +01:00
parent f22e23742d
commit 50029c254e
Signed by: hougo
GPG key ID: 3A285FD470209C59
6 changed files with 309 additions and 56 deletions

View file

@ -1,10 +1,14 @@
package sh.libre.scim.core;
import java.util.stream.Stream;
import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
import javax.ws.rs.NotFoundException;
import org.jboss.logging.Logger;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RoleMapperModel;
import sh.libre.scim.jpa.ScimResource;
@ -16,14 +20,16 @@ public abstract class Adapter<M extends RoleMapperModel, S extends com.unboundid
protected final String type;
protected final String componentId;
protected final EntityManager em;
protected final KeycloakSession session;
protected String id;
protected String externalId;
public Adapter(String realmId, String componentId, EntityManager em, String type, Logger logger) {
this.realmId = realmId;
public Adapter(KeycloakSession session, String componentId, String type, Logger logger) {
this.session = session;
this.realmId = session.getContext().getRealm().getId();
this.componentId = componentId;
this.em = em;
this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
this.type = type;
this.LOGGER = logger;
}
@ -63,6 +69,10 @@ public abstract class Adapter<M extends RoleMapperModel, S extends com.unboundid
}
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)
@ -92,14 +102,20 @@ public abstract class Adapter<M extends RoleMapperModel, S extends com.unboundid
}
public void deleteMapping() {
this.em.remove(this.toMapping());
var 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 void apply(ScimResource resource);
public abstract Class<S> getResourceClass();
public abstract S toSCIM(Boolean addMeta);
@ -107,4 +123,9 @@ public abstract class Adapter<M extends RoleMapperModel, S extends com.unboundid
public abstract Boolean tryToMap();
public abstract void createEntity();
public abstract Stream<M> getResourceStream();
public abstract Boolean skipRefresh();
}

View file

@ -0,0 +1,184 @@
package sh.libre.scim.core;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.persistence.NoResultException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unboundid.scim2.common.types.GroupResource;
import com.unboundid.scim2.common.types.Member;
import com.unboundid.scim2.common.types.Meta;
import org.jboss.logging.Logger;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.jpa.entities.GroupEntity;
import org.keycloak.models.jpa.entities.UserEntity;
import org.keycloak.models.jpa.entities.UserGroupMembershipEntity;
import org.keycloak.models.utils.KeycloakModelUtils;
public class GroupAdapter extends Adapter<GroupModel, GroupResource> {
private String displayName;
private Set<String> members = new HashSet<String>();
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<GroupResource> getResourceClass() {
return GroupResource.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());
ObjectMapper Obj = new ObjectMapper();
try {
String jsonStr = Obj.writerWithDefaultPrettyPrinter().writeValueAsString(this.members);
LOGGER.info(jsonStr);
} catch (JsonProcessingException e) {
}
}
@Override
public void apply(GroupResource group) {
setExternalId(group.getId());
setDisplayName(group.getDisplayName());
var groupMembers = group.getMembers();
if (groupMembers != null && groupMembers.size() > 0) {
this.members = new HashSet<String>();
for (var groupMember : groupMembers) {
var userMapping = this.query("findByExternalId", groupMember.getValue(), "User")
.getSingleResult();
this.members.add(userMapping.getId());
}
}
}
@Override
public GroupResource toSCIM(Boolean addMeta) {
var group = new GroupResource();
group.setId(externalId);
group.setExternalId(id);
group.setDisplayName(displayName);
if (members.size() > 0) {
var groupMembers = new ArrayList<Member>();
for (var member : members) {
var groupMember = new Member();
try {
var userMapping = this.query("findById", member, "User").getSingleResult();
groupMember.setValue(userMapping.getExternalId());
var ref = new URI(String.format("Users/%s", userMapping.getExternalId()));
groupMember.setRef(ref);
groupMembers.add(groupMember);
} catch (Exception e) {
LOGGER.error(e);
}
}
group.setMembers(groupMembers);
}
if (addMeta) {
var meta = new Meta();
try {
var uri = new URI("Groups/" + externalId);
meta.setLocation(uri);
} catch (URISyntaxException e) {
}
group.setMeta(meta);
}
ObjectMapper Obj = new ObjectMapper();
try {
String jsonStr = Obj.writerWithDefaultPrettyPrinter().writeValueAsString(group);
LOGGER.info(jsonStr);
} catch (JsonProcessingException e) {
}
return group;
}
@Override
public Boolean entityExists() {
if (this.id == null) {
return false;
}
var group = this.em.find(GroupEntity.class, this.id);
if (group != null) {
return true;
}
return false;
}
@Override
public Boolean tryToMap() {
try {
var groupEntity = this.em
.createQuery("select g from GroupEntity g where g.name=:name",
GroupEntity.class)
.setParameter("name", displayName)
.getSingleResult();
setId(groupEntity.getId());
return true;
} catch (Exception e) {
}
return false;
}
@Override
public void createEntity() {
var kcGroup = new GroupEntity();
kcGroup.setId(KeycloakModelUtils.generateId());
kcGroup.setRealm(realmId);
kcGroup.setName(displayName);
kcGroup.setParentId(GroupEntity.TOP_PARENT_ID);
this.em.persist(kcGroup);
this.id = kcGroup.getId();
for (String mId : members) {
try {
var user = this.em.find(UserEntity.class, mId);
if (user == null) {
throw new NoResultException();
}
var membership = new UserGroupMembershipEntity();
membership.setUser(user);
membership.setGroupId(kcGroup.getId());
this.em.persist(membership);
} catch (Exception e) {
LOGGER.warn(e);
}
}
}
@Override
public Stream<GroupModel> getResourceStream() {
return this.session.groups().getGroupsStream(this.session.getContext().getRealm());
}
@Override
public Boolean skipRefresh() {
return false;
}
}

View file

@ -7,7 +7,6 @@ import javax.ws.rs.client.Client;
import com.unboundid.scim2.client.ScimService;
import com.unboundid.scim2.common.ScimResource;
import com.unboundid.scim2.common.exceptions.ScimException;
import com.unboundid.scim2.common.types.UserResource;
import org.jboss.logging.Logger;
import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder;
@ -17,7 +16,6 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RoleMapperModel;
import org.keycloak.storage.user.SynchronizationResult;
import io.github.resilience4j.core.IntervalFunction;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
@ -60,8 +58,8 @@ public class ScimClient {
protected <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> A getAdapter(
Class<A> aClass) {
try {
return aClass.getDeclaredConstructor(String.class, String.class, EntityManager.class)
.newInstance(getRealmId(), this.model.getId(), getEM());
return aClass.getDeclaredConstructor(KeycloakSession.class, String.class)
.newInstance(session, this.model.getId());
} catch (Exception e) {
throw new RuntimeException(e);
}
@ -72,16 +70,18 @@ public class ScimClient {
var adapter = getAdapter(aClass);
adapter.apply(kcModel);
var retry = registry.retry("create-" + adapter.getId());
var spUser = retry.executeSupplier(() -> {
var resource = retry.executeSupplier(() -> {
try {
return scimService.createRequest(adapter.getSCIMEndpoint(), adapter.toSCIM(false))
return scimService.createRequest(adapter.getSCIMEndpoint(),
adapter.toSCIM(false))
.contentType(contentType).invoke();
} catch (ScimException e) {
throw new RuntimeException(e);
}
});
adapter.apply(spUser);
adapter.apply(resource);
adapter.saveMapping();
};
public <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void replace(Class<A> aClass,
@ -129,35 +129,41 @@ public class ScimClient {
}
}
public void refreshUsers(SynchronizationResult syncRes) {
LOGGER.info("Refresh Users");
this.session.users().getUsersStream(this.session.getContext().getRealm()).forEach(kcUser -> {
LOGGER.infof("Reconciling local user %s", kcUser.getId());
if (!kcUser.getUsername().equals("admin")) {
var adapter = getAdapter(UserAdapter.class);
adapter.apply(kcUser);
public <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void refreshResources(
Class<A> aClass,
SynchronizationResult syncRes) {
LOGGER.info("Refresh resources");
getAdapter(aClass).getResourceStream().forEach(resource -> {
var adapter = getAdapter(aClass);
adapter.apply(resource);
LOGGER.infof("Reconciling local resource %s", adapter.getId());
if (!adapter.skipRefresh()) {
var mapping = adapter.getMapping();
if (mapping == null) {
LOGGER.info("Creating it");
this.create(UserAdapter.class, kcUser);
this.create(aClass, resource);
} else {
LOGGER.info("Replacing it");
this.replace(UserAdapter.class, kcUser);
this.replace(aClass, resource);
}
syncRes.increaseUpdated();
}
});
}
public void importUsers(SynchronizationResult syncRes) {
LOGGER.info("Import Users");
public <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void importResources(
Class<A> aClass, SynchronizationResult syncRes) {
LOGGER.info("Import");
try {
var spUsers = scimService.searchRequest("Users").contentType(contentType).invoke(UserResource.class);
for (var spUser : spUsers) {
var adapter = getAdapter(aClass);
var resources = scimService.searchRequest(adapter.getSCIMEndpoint()).contentType(contentType)
.invoke(adapter.getResourceClass());
for (var resource : resources) {
try {
LOGGER.infof("Reconciling remote user %s", spUser.getId());
var adapter = getAdapter(UserAdapter.class);
adapter.apply(spUser);
LOGGER.infof("Reconciling remote resource %s", resource.getId());
adapter = getAdapter(aClass);
adapter.apply(resource);
var mapping = adapter.getMapping();
if (mapping != null) {
@ -173,25 +179,28 @@ public class ScimClient {
var mapped = adapter.tryToMap();
if (mapped) {
LOGGER.info("Matched a user");
LOGGER.info("Matched");
adapter.saveMapping();
} else {
switch (this.model.get("sync-import-action")) {
case "CREATE_LOCAL":
LOGGER.info("Create local user");
LOGGER.info("Create local resource");
adapter.createEntity();
adapter.saveMapping();
syncRes.increaseAdded();
break;
case "DELETE_REMOTE":
LOGGER.info("Delete remote user");
scimService.deleteRequest("Users", spUser.getId()).contentType(contentType)
LOGGER.info("Delete remote resource");
scimService.deleteRequest(adapter.getSCIMEndpoint(), resource.getId())
.contentType(contentType)
.invoke();
syncRes.increaseRemoved();
break;
}
}
} catch (Exception e) {
LOGGER.error(e);
e.printStackTrace();
syncRes.increaseFailed();
}
}
@ -200,12 +209,13 @@ public class ScimClient {
}
}
public void sync(SynchronizationResult syncRes) {
public <M extends RoleMapperModel, S extends ScimResource, A extends Adapter<M, S>> void sync(Class<A> aClass,
SynchronizationResult syncRes) {
if (this.model.get("sync-import", false)) {
this.importUsers(syncRes);
this.importResources(aClass, syncRes);
}
if (this.model.get("sync-refresh", false)) {
this.refreshUsers(syncRes);
this.refreshResources(aClass, syncRes);
}
}

View file

@ -3,20 +3,18 @@ package sh.libre.scim.core;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import javax.persistence.EntityManager;
import java.util.stream.Stream;
import com.unboundid.scim2.common.types.Email;
import com.unboundid.scim2.common.types.Meta;
import com.unboundid.scim2.common.types.UserResource;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.jpa.entities.UserEntity;
import org.keycloak.models.utils.KeycloakModelUtils;
import sh.libre.scim.jpa.ScimResource;
public class UserAdapter extends Adapter<UserModel, UserResource> {
private String username;
@ -24,8 +22,8 @@ public class UserAdapter extends Adapter<UserModel, UserResource> {
private String email;
private Boolean active;
public UserAdapter(String realmId, String componentId, EntityManager em) {
super(realmId, componentId, em, "User", Logger.getLogger(UserAdapter.class));
public UserAdapter(KeycloakSession session, String componentId) {
super(session, componentId, "User", Logger.getLogger(UserAdapter.class));
}
public String getUsername() {
@ -68,6 +66,11 @@ public class UserAdapter extends Adapter<UserModel, UserResource> {
}
}
@Override
public Class<UserResource> getResourceClass() {
return UserResource.class;
}
@Override
public void apply(UserModel user) {
setId(user.getId());
@ -94,12 +97,6 @@ public class UserAdapter extends Adapter<UserModel, UserResource> {
}
}
@Override
public void apply(ScimResource mapping) {
setId(mapping.getId());
setExternalId(mapping.getExternalId());
}
@Override
public UserResource toSCIM(Boolean addMeta) {
var user = new UserResource();
@ -126,7 +123,8 @@ public class UserAdapter extends Adapter<UserModel, UserResource> {
return user;
}
public UserEntity createEntity() {
@Override
public void createEntity() {
var kcUser = new UserEntity();
kcUser.setId(KeycloakModelUtils.generateId());
kcUser.setRealmId(realmId);
@ -134,9 +132,9 @@ public class UserAdapter extends Adapter<UserModel, UserResource> {
kcUser.setEmail(email, false);
this.em.persist(kcUser);
this.id = kcUser.getId();
return kcUser;
}
@Override
public Boolean entityExists() {
if (this.id == null) {
return false;
@ -148,6 +146,7 @@ public class UserAdapter extends Adapter<UserModel, UserResource> {
return false;
}
@Override
public Boolean tryToMap() {
try {
var userEntity = this.em
@ -163,4 +162,14 @@ public class UserAdapter extends Adapter<UserModel, UserResource> {
}
return false;
}
@Override
public Stream<UserModel> getResourceStream() {
return this.session.users().getUsersStream(this.session.getContext().getRealm());
}
@Override
public Boolean skipRefresh() {
return getUsername().equals("admin");
}
}

View file

@ -1,5 +1,8 @@
package sh.libre.scim.event;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jboss.logging.Logger;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
@ -7,9 +10,12 @@ import org.keycloak.events.EventType;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.GroupRepresentation;
import sh.libre.scim.core.GroupAdapter;
import sh.libre.scim.core.ScimDispatcher;
import sh.libre.scim.core.UserAdapter;
@ -48,7 +54,6 @@ public class ScimEventListenerProvider implements EventListenerProvider {
var userId = event.getResourcePath().replace("users/", "");
LOGGER.infof("%s %s", userId, event.getOperationType());
if (event.getOperationType() == OperationType.CREATE) {
// session.getTransactionManager().rollback();
var user = getUser(userId);
dispatcher.run((client) -> client.create(UserAdapter.class, user));
}
@ -60,11 +65,28 @@ public class ScimEventListenerProvider implements EventListenerProvider {
dispatcher.run((client) -> client.delete(UserAdapter.class, userId));
}
}
if (event.getResourceType() == ResourceType.COMPONENT) {
if (event.getOperationType() == OperationType.CREATE
|| (event.getOperationType() == OperationType.UPDATE)) {
LOGGER.infof("%s %s", event.getResourcePath(), event.getOperationType());
// dispatcher.run((client) -> client.syncUsers());
if (event.getResourceType() == ResourceType.GROUP) {
var groupId = event.getResourcePath().replace("groups/", "");
LOGGER.infof("%s %s", event.getResourcePath(), event.getOperationType());
if (event.getOperationType() == OperationType.CREATE) {
var group = getGroup(groupId);
dispatcher.run((client) -> client.create(GroupAdapter.class, group));
}
if (event.getOperationType() == OperationType.UPDATE) {
var group = getGroup(groupId);
dispatcher.run((client) -> client.replace(GroupAdapter.class, group));
}
if (event.getOperationType() == OperationType.DELETE) {
dispatcher.run((client) -> client.delete(GroupAdapter.class, groupId));
}
}
if (event.getResourceType() == ResourceType.GROUP_MEMBERSHIP) {
ObjectMapper obj = new ObjectMapper();
try {
var groupRepresentation = obj.readValue(event.getRepresentation(), GroupRepresentation.class);
var group = getGroup(groupRepresentation.getId());
dispatcher.run((client) -> client.replace(GroupAdapter.class, group));
} catch (JsonProcessingException e) {
}
}
}
@ -72,4 +94,8 @@ public class ScimEventListenerProvider implements EventListenerProvider {
private UserModel getUser(String id) {
return session.users().getUserById(session.getContext().getRealm(), id);
}
private GroupModel getGroup(String id) {
return session.groups().getGroupById(session.getContext().getRealm(), id);
}
}

View file

@ -21,7 +21,9 @@ import org.keycloak.storage.UserStorageProviderModel;
import org.keycloak.storage.user.ImportSynchronization;
import org.keycloak.storage.user.SynchronizationResult;
import sh.libre.scim.core.GroupAdapter;
import sh.libre.scim.core.ScimClient;
import sh.libre.scim.core.UserAdapter;
public class ScimStorageProviderFactory
implements UserStorageProviderFactory<ScimStorageProvider>, ImportSynchronization {
@ -110,7 +112,8 @@ public class ScimStorageProviderFactory
var client = new ScimClient(model, session);
model.setEnabled(false);
realm.updateComponent(model);
client.sync(result);
client.sync(UserAdapter.class, result);
client.sync(GroupAdapter.class, result);
client.close();
model.setEnabled(true);
realm.updateComponent(model);