feat: update to keycloak 23 + varrious fixes

This commit is contained in:
Hugo Renard 2024-02-06 12:13:30 +01:00
parent 7163d2a0f0
commit 182824a3b4
Signed by: hougo
GPG key ID: 3A285FD470209C59
12 changed files with 153 additions and 151 deletions

View file

@ -1,13 +1,13 @@
plugins {
id 'java'
id 'com.github.johnrengelman.shadow' version '7.1.2'
id 'com.github.johnrengelman.shadow' version '8.1.1'
}
group = 'sh.libre.scim'
version = '1.0-SNAPSHOT'
description = 'keycloak-scim'
java.sourceCompatibility = JavaVersion.VERSION_11
java.sourceCompatibility = JavaVersion.VERSION_17
repositories {
mavenLocal()
@ -15,20 +15,12 @@ repositories {
}
dependencies {
compileOnly 'org.keycloak:keycloak-core:18.0.0'
compileOnly 'org.keycloak:keycloak-server-spi:18.0.0'
compileOnly 'org.keycloak:keycloak-server-spi-private:18.0.0'
compileOnly 'org.keycloak:keycloak-services:18.0.0'
compileOnly 'org.keycloak:keycloak-model-jpa:18.0.0'
implementation 'io.github.resilience4j:resilience4j-retry:1.7.1'
implementation 'de.captaingoldfish:scim-sdk-common:1.15.3'
implementation files('/home/marcportabella/Documents/totmicro/Repos/keycloak-scim-aws/scim-sdk-client-1.15.4-SNAPSHOT.jar')
//implementation 'de.captaingoldfish:scim-sdk-client:1.15.3'
implementation('org.wildfly.client:wildfly-client-config:1.0.1.Final') {
transitive false
}
implementation('org.jboss.resteasy:resteasy-client:4.7.6.Final') {
transitive false
}
compileOnly 'org.keycloak:keycloak-core:23.0.4'
compileOnly 'org.keycloak:keycloak-server-spi:23.0.4'
compileOnly 'org.keycloak:keycloak-server-spi-private:23.0.4'
compileOnly 'org.keycloak:keycloak-services:23.0.4'
compileOnly 'org.keycloak:keycloak-model-jpa:23.0.4'
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'
implementation 'de.captaingoldfish:scim-sdk-common:1.21.1'
implementation 'de.captaingoldfish:scim-sdk-client:1.21.1'
}

View file

@ -11,7 +11,7 @@ services:
ports:
- 5432:5432
keycloak:
image: quay.io/keycloak/keycloak:18.0.0
image: quay.io/keycloak/keycloak:23.0.3
build: .
command: start-dev
volumes:
@ -23,6 +23,7 @@ services:
KC_DB_PASSWORD: keycloak
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_LOG_LEVEL: INFO,sh.libre.scim:debug,de.captaingoldfish.scim:debug
ports:
- 127.0.0.1:8080:8080
depends_on:

View file

@ -1,33 +0,0 @@
plugins {
id 'java'
id 'com.github.johnrengelman.shadow' version '7.1.2'
}
group = 'sh.libre.scim'
version = '1.0-SNAPSHOT'
description = 'keycloak-scim'
java.sourceCompatibility = JavaVersion.VERSION_11
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
compileOnly 'org.keycloak:keycloak-core:18.0.0'
compileOnly 'org.keycloak:keycloak-server-spi:18.0.0'
compileOnly 'org.keycloak:keycloak-server-spi-private:18.0.0'
compileOnly 'org.keycloak:keycloak-services:18.0.0'
compileOnly 'org.keycloak:keycloak-model-jpa:18.0.0'
implementation 'io.github.resilience4j:resilience4j-retry:1.7.1'
compileOnly 'org.wildfly.client:wildfly-client-config:1.0.1.Final'
compileOnly 'org.jboss.resteasy:resteasy-client:4.7.6.Final'
compileOnly 'org.jboss.resteasy:resteasy-client-api:4.7.6.Final'
}
shadowJar {
archiveClassifier.set('all-legacy')
}

View file

@ -2,10 +2,9 @@ package sh.libre.scim.core;
import java.util.stream.Stream;
import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.persistence.TypedQuery;
import javax.ws.rs.NotFoundException;
import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.persistence.TypedQuery;
import org.jboss.logging.Logger;
import org.keycloak.connections.jpa.JpaConnectionProvider;
@ -61,7 +60,7 @@ public abstract class Adapter<M extends RoleMapperModel, S extends ResourceNode>
}
public String getSCIMEndpoint() {
return type + "s";
return "/" + type + "s";
}
public ScimResource toMapping() {
@ -95,12 +94,8 @@ public abstract class Adapter<M extends RoleMapperModel, S extends ResourceNode>
if (this.externalId != null) {
return this.query("findByExternalId", externalId).getSingleResult();
}
} catch (NotFoundException e) {
} catch (NoResultException e) {
} catch (Exception e) {
LOGGER.error(e);
}
return null;
}
@ -124,7 +119,7 @@ public abstract class Adapter<M extends RoleMapperModel, S extends ResourceNode>
public abstract Class<S> getResourceClass();
public abstract S toSCIM(Boolean addMeta);
public abstract S toSCIM();
public abstract Boolean entityExists();

View file

@ -2,16 +2,15 @@ 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 jakarta.persistence.NoResultException;
import de.captaingoldfish.scim.sdk.common.resources.Group;
import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member;
import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang3.StringUtils;
import org.jboss.logging.Logger;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
@ -59,44 +58,49 @@ public class GroupAdapter extends Adapter<GroupModel, Group> {
if (groupMembers != null && groupMembers.size() > 0) {
this.members = new HashSet<String>();
for (var groupMember : groupMembers) {
var userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User")
.getSingleResult();
this.members.add(userMapping.getId());
try {
var 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(Boolean addMeta) {
public Group toSCIM() {
var group = new Group();
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();
LOGGER.debug(userMapping.getExternalId());
LOGGER.debug(userMapping.getId());
groupMember.setValue(userMapping.getExternalId());
var ref = new URI(String.format("Users/%s", userMapping.getExternalId()));
groupMember.setRef(ref.toString());
groupMembers.add(groupMember);
} catch (Exception e) {
LOGGER.error(e);
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");
}
}
group.setMembers(groupMembers);
}
if (addMeta) {
var meta = new Meta();
try {
var uri = new URI("Groups/" + externalId);
meta.setLocation(uri.toString());
} catch (URISyntaxException e) {
}
group.setMeta(meta);
var meta = new Meta();
try {
var uri = new URI("Groups/" + externalId);
meta.setLocation(uri.toString());
} catch (URISyntaxException e) {
LOGGER.warn(e);
}
group.setMeta(meta);
return group;
}
@ -114,7 +118,9 @@ public class GroupAdapter extends Adapter<GroupModel, Group> {
@Override
public Boolean tryToMap() {
var group = session.groups().getGroupsStream(realm).filter(x -> x.getName() == displayName).findFirst();
var group = session.groups().getGroupsStream(realm).filter(
x -> StringUtils.equals(x.getName(), externalId) || StringUtils.equals(x.getName(), displayName))
.findFirst();
if (group.isPresent()) {
setId(group.get().getId());
return true;

View file

@ -3,9 +3,9 @@ package sh.libre.scim.core;
import java.util.HashMap;
import java.util.Map;
import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.ws.rs.ProcessingException;
import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.ws.rs.ProcessingException;
import de.captaingoldfish.scim.sdk.client.ScimClientConfig;
import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder;
@ -51,12 +51,11 @@ public class ScimClient {
switch (model.get("auth-mode")) {
case "BEARER":
defaultHeaders.put(HttpHeaders.AUTHORIZATION,
BearerAuthentication(model.get("auth-pass")));
BearerAuthentication());
break;
case "BASIC_AUTH":
defaultHeaders.put(HttpHeaders.AUTHORIZATION,
BasicAuthentication(model.get("auth-user"),
model.get("auth-pass")));
BasicAuthentication());
break;
}
@ -73,10 +72,10 @@ public class ScimClient {
registry = RetryRegistry.of(retryConfig);
}
protected String BasicAuthentication(String username ,String password) {
protected String BasicAuthentication() {
return BasicAuth.builder()
.username(model.get(username))
.password(model.get(password))
.username(model.get("auth-user"))
.password(model.get("auth-pass"))
.build()
.getAuthorizationHeaderValue();
}
@ -92,17 +91,10 @@ public class ScimClient {
.build();
}
protected String BearerAuthentication(String token) {
return "Bearer " + token ;
protected String BearerAuthentication() {
return "Bearer " + model.get("auth-pass") ;
}
protected String genScimUrl(String scimEndpoint,String resourcePath) {
return String.format("%s%s/%s", scimApplicationBaseUrl ,
scimEndpoint,
resourcePath);
}
protected EntityManager getEM() {
return session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
@ -136,8 +128,8 @@ public class ScimClient {
ServerResponse<S> response = retry.executeSupplier(() -> {
try {
return scimRequestBuilder
.create(adapter.getResourceClass(), String.format("/" + adapter.getSCIMEndpoint()))
.setResource(adapter.toSCIM(false))
.create(adapter.getResourceClass(), adapter.getSCIMEndpoint())
.setResource(adapter.toSCIM())
.sendRequest();
} catch ( ResponseException e) {
throw new RuntimeException(e);
@ -166,9 +158,8 @@ public class ScimClient {
ServerResponse<S> response = retry.executeSupplier(() -> {
try {
return scimRequestBuilder
.update(genScimUrl(adapter.getSCIMEndpoint(), adapter.getExternalId()),
adapter.getResourceClass())
.setResource(adapter.toSCIM(false))
.update(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId())
.setResource(adapter.toSCIM())
.sendRequest() ;
} catch ( ResponseException e) {
@ -199,8 +190,7 @@ public class ScimClient {
ServerResponse<S> response = retry.executeSupplier(() -> {
try {
return scimRequestBuilder.delete(genScimUrl(adapter.getSCIMEndpoint(), adapter.getExternalId()),
adapter.getResourceClass())
return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId())
.sendRequest();
} catch (ResponseException e) {
throw new RuntimeException(e);
@ -247,7 +237,7 @@ public class ScimClient {
LOGGER.info("Import");
try {
var adapter = getAdapter(aClass);
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list("url",adapter.getResourceClass()).get().sendRequest();
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getSCIMEndpoint()).get().sendRequest();
ListResponse<S> resourceTypeListResponse = response.getResource();
for (var resource : resourceTypeListResponse.getListedResources()) {
@ -287,9 +277,7 @@ public class ScimClient {
case "DELETE_REMOTE":
LOGGER.info("Delete remote resource");
scimRequestBuilder
.delete(genScimUrl(adapter.getSCIMEndpoint(),
resource.getId().get()),
adapter.getResourceClass())
.delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(), resource.getId().get())
.sendRequest();
syncRes.increaseRemoved();
break;

View file

@ -1,6 +1,8 @@
package sh.libre.scim.core;
import java.util.function.Consumer;
import org.apache.commons.lang3.StringUtils;
import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
@ -21,7 +23,8 @@ public class ScimDispatcher {
public void run(String scope, Consumer<ScimClient> f) {
session.getContext().getRealm().getComponentsStream()
.filter((m) -> {
return ScimStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true)
return StringUtils.equals(ScimStorageProviderFactory.ID, m.getProviderId())
&& m.get("enabled", true)
&& m.get("propagation-" + scope, false);
})
.forEach(m -> runOne(m, f));

View file

@ -3,8 +3,10 @@ package sh.libre.scim.core;
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.stream.Stream;
import de.captaingoldfish.scim.sdk.common.resources.User;
@ -13,8 +15,7 @@ import de.captaingoldfish.scim.sdk.common.resources.complex.Name;
import de.captaingoldfish.scim.sdk.common.resources.multicomplex.PersonRole;
import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang3.StringUtils;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
@ -26,6 +27,8 @@ public class UserAdapter extends Adapter<UserModel, User> {
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));
@ -79,6 +82,22 @@ public class UserAdapter extends Adapter<UserModel, User> {
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;
@ -96,19 +115,17 @@ public class UserAdapter extends Adapter<UserModel, User> {
setDisplayName(displayName);
setEmail(user.getEmail());
setActive(user.isEnabled());
setFirstName(user.getFirstName());
setLastName(user.getLastName());
var rolesSet = new HashSet<String>();
user.getGroupsStream().flatMap(g -> g.getRoleMappingsStream())
.filter((r) -> r.getFirstAttribute("scim").equals("true")).map((r) -> r.getName())
.filter((r) -> StringUtils.equals(r.getFirstAttribute("scim"), "true"))
.map((r) -> r.getName())
.forEach(r -> rolesSet.add(r));
user.getRoleMappingsStream()
.filter((r) -> StringUtils.equals(r.getFirstAttribute("scim"), "true"))
.map((r) -> r.getName())
.forEach(r -> rolesSet.add(r));
user.getRoleMappingsStream().filter((r) -> {
var attr = r.getFirstAttribute("scim");
if (attr == null) {
return false;
}
return attr.equals("true");
}).map((r) -> r.getName()).forEach(r -> rolesSet.add(r));
var roles = new String[rolesSet.size()];
rolesSet.toArray(roles);
setRoles(roles);
@ -127,30 +144,31 @@ public class UserAdapter extends Adapter<UserModel, User> {
}
@Override
public User toSCIM(Boolean addMeta) {
public User toSCIM() {
var 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);
var emails = new ArrayList<Email>();
if (email != null) {
emails.add(
Email.builder().value(getEmail()).build());
Email.builder().value(getEmail()).build());
}
user.setEmails(emails);
user.setActive(active);
if (addMeta) {
var meta = new Meta();
try {
var uri = new URI("Users/" + externalId);
meta.setLocation(uri.toString());
} catch (URISyntaxException e) {
}
user.setMeta(meta);
var meta = new Meta();
try {
var uri = new URI("Users/" + externalId);
meta.setLocation(uri.toString());
} catch (URISyntaxException e) {
LOGGER.warn(e);
}
user.setMeta(meta);
List<PersonRole> roles = new ArrayList<PersonRole>();
for (var r : this.roles) {
var role = new PersonRole();
@ -212,11 +230,13 @@ public class UserAdapter extends Adapter<UserModel, User> {
@Override
public Stream<UserModel> getResourceStream() {
return this.session.users().getUsersStream(this.session.getContext().getRealm());
Map<String, String> params = new HashMap<String, String>() {
};
return this.session.users().searchForUserStream(realm, params);
}
@Override
public Boolean skipRefresh() {
return getUsername().equals("admin");
return StringUtils.equals(getUsername(), "admin");
}
}

View file

@ -1,8 +1,9 @@
package sh.libre.scim.event;
import java.util.HashMap;
import java.util.regex.*;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.jboss.logging.Logger;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
@ -99,7 +100,7 @@ public class ScimEventListenerProvider implements EventListenerProvider {
var groupId = matcher.group(2);
LOGGER.infof("%s %s from %s", event.getOperationType(), userId, groupId);
var group = getGroup(groupId);
dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group));
group.setSingleAttribute("scim-dirty", "true");
var user = getUser(userId);
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user));
}
@ -107,10 +108,10 @@ public class ScimEventListenerProvider implements EventListenerProvider {
var type = matcher.group(1);
var id = matcher.group(2);
LOGGER.infof("%s %s %s roles", event.getOperationType(), type, id);
if (type.equals("users")) {
if (StringUtils.equals(type, "users")) {
var user = getUser(id);
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user));
} else if (type.equals("groups")) {
} else if (StringUtils.equals(type, "groups")) {
var group = getGroup(id);
session.users().getGroupMembersStream(session.getContext().getRealm(), group).forEach(user -> {
dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user));

View file

@ -1,12 +1,12 @@
package sh.libre.scim.jpa;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.NamedQuery;
import javax.persistence.NamedQueries;
import javax.persistence.Table;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.NamedQueries;
import jakarta.persistence.Table;
@Entity
@IdClass(ScimResourceId.class)

View file

@ -68,6 +68,7 @@ public class ScimResourceId implements Serializable {
if (!(other instanceof ScimResourceId))
return false;
var o = (ScimResourceId) other;
// TODO
return (o.id == id &&
o.realmId == realmId &&
o.componentId == componentId &&

View file

@ -3,8 +3,9 @@ package sh.libre.scim.storage;
import java.util.Date;
import java.util.List;
import javax.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MediaType;
import org.apache.commons.lang3.StringUtils;
import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
@ -17,6 +18,7 @@ import org.keycloak.storage.UserStorageProviderFactory;
import org.keycloak.storage.UserStorageProviderModel;
import org.keycloak.storage.user.ImportSynchronization;
import org.keycloak.storage.user.SynchronizationResult;
import org.keycloak.timer.TimerProvider;
import sh.libre.scim.core.GroupAdapter;
import sh.libre.scim.core.ScimDispatcher;
@ -34,6 +36,7 @@ public class ScimStorageProviderFactory
.property()
.name("endpoint")
.type(ProviderConfigProperty.STRING_TYPE)
.required(true)
.label("SCIM 2.0 endpoint")
.helpText("External SCIM 2.0 base " +
"URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)")
@ -70,12 +73,14 @@ public class ScimStorageProviderFactory
.name("propagation-user")
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.label("Enable user propagation")
.helpText("Should operation on users be propagated to this provider ?")
.defaultValue("true")
.add()
.property()
.name("propagation-group")
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.label("Enable group propagation")
.helpText("Should operation on groups be propagated to this provider ?")
.defaultValue("true")
.add()
.property()
@ -127,10 +132,10 @@ public class ScimStorageProviderFactory
var realm = session.realms().getRealm(realmId);
session.getContext().setRealm(realm);
var dispatcher = new ScimDispatcher(session);
if (model.get("propagation-user").equals("true")) {
if (StringUtils.equals(model.get("propagation-user"), "true")) {
dispatcher.runOne(model, (client) -> client.sync(UserAdapter.class, result));
}
if (model.get("propagation-group").equals("true")) {
if (StringUtils.equals(model.get("propagation-group"), "true")) {
dispatcher.runOne(model, (client) -> client.sync(GroupAdapter.class, result));
}
}
@ -147,4 +152,27 @@ public class ScimStorageProviderFactory
return this.sync(sessionFactory, realmId, model);
}
@Override
public void postInit(KeycloakSessionFactory factory) {
var timer = factory.create().getProvider(TimerProvider.class);
timer.scheduleTask(taskSession -> {
for (var realm : taskSession.realms().getRealmsStream().toList()) {
KeycloakModelUtils.runJobInTransaction(factory, new KeycloakSessionTask() {
@Override
public void run(KeycloakSession session) {
session.getContext().setRealm(realm);
var dispatcher = new ScimDispatcher(session);
for (var group : session.groups().getGroupsStream(realm)
.filter(x -> StringUtils.equals(x.getFirstAttribute("scim-dirty"), "true")).toList()) {
LOGGER.debug(group.getName() + " is dirty");
dispatcher.run(ScimDispatcher.SCOPE_GROUP,
(client) -> client.replace(GroupAdapter.class, group));
group.removeAttribute("scim-dirty");
}
}
});
}
}, 30000, "scim-background");
}
}