From 7163d2a0f0967bc0bc6ae5fc16e8120807f684a5 Mon Sep 17 00:00:00 2001 From: Marc Portabella Date: Sun, 22 Jan 2023 12:20:33 +0100 Subject: [PATCH 01/58] chore: switch scim sdk --- auto.sh | 4 + build.gradle | 14 +- legacy-build.gradle | 7 +- src/main/java/sh/libre/scim/core/Adapter.java | 4 +- .../libre/scim/core/BasicAuthentication.java | 23 --- .../libre/scim/core/BearerAuthentication.java | 20 -- .../java/sh/libre/scim/core/GroupAdapter.java | 31 ++-- .../java/sh/libre/scim/core/ScimClient.java | 174 +++++++++++++----- .../java/sh/libre/scim/core/UserAdapter.java | 42 +++-- .../storage/ScimStorageProviderFactory.java | 8 +- 10 files changed, 178 insertions(+), 149 deletions(-) create mode 100755 auto.sh delete mode 100644 src/main/java/sh/libre/scim/core/BasicAuthentication.java delete mode 100644 src/main/java/sh/libre/scim/core/BearerAuthentication.java diff --git a/auto.sh b/auto.sh new file mode 100755 index 0000000..884ba67 --- /dev/null +++ b/auto.sh @@ -0,0 +1,4 @@ +gradle jar shadowjar +scp build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar root@192.168.130.252:/var/www/html/keycloak-scim-1.0-SNAPSHOT-all.jar +scp build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar root@192.168.130.252:/var/www/html/keycloak-scim-aws-1.0-SNAPSHOT-all.jar +k delete pod keycloak-keycloakx-0 -n keycloak diff --git a/build.gradle b/build.gradle index def33a3..35ee773 100644 --- a/build.gradle +++ b/build.gradle @@ -21,20 +21,14 @@ dependencies { 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('com.unboundid.product.scim2:scim2-sdk-client:2.3.7') { - transitive false - } - implementation('com.unboundid.product.scim2:scim2-sdk-common:2.3.7') { - transitive false - } + 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 } - implementation('org.jboss.resteasy:resteasy-client-api:4.7.6.Final') { - transitive false - } - } diff --git a/legacy-build.gradle b/legacy-build.gradle index c9318f4..2f57e15 100644 --- a/legacy-build.gradle +++ b/legacy-build.gradle @@ -21,12 +21,7 @@ dependencies { 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('com.unboundid.product.scim2:scim2-sdk-client:2.3.7') { - transitive false - } - implementation('com.unboundid.product.scim2:scim2-sdk-common:2.3.7') { - transitive false - } + 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' diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java index f7581a1..8faac5a 100644 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ b/src/main/java/sh/libre/scim/core/Adapter.java @@ -14,7 +14,9 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.RoleMapperModel; import sh.libre.scim.jpa.ScimResource; -public abstract class Adapter { +import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; + +public abstract class Adapter { protected final Logger LOGGER; protected final String realmId; diff --git a/src/main/java/sh/libre/scim/core/BasicAuthentication.java b/src/main/java/sh/libre/scim/core/BasicAuthentication.java deleted file mode 100644 index 227681d..0000000 --- a/src/main/java/sh/libre/scim/core/BasicAuthentication.java +++ /dev/null @@ -1,23 +0,0 @@ -package sh.libre.scim.core; - -import java.io.IOException; -import java.util.Base64; - -import javax.ws.rs.client.ClientRequestContext; -import javax.ws.rs.client.ClientRequestFilter; - -public class BasicAuthentication implements ClientRequestFilter { - private final String user; - private final String password; - - BasicAuthentication(String user, String password) { - this.user = user; - this.password = password; - } - - @Override - public void filter(ClientRequestContext requestContext) throws IOException { - var token = Base64.getEncoder().encodeToString((user + ":" + password).getBytes()); - requestContext.getHeaders().add("Authorization", "Basic " + token); - } -} diff --git a/src/main/java/sh/libre/scim/core/BearerAuthentication.java b/src/main/java/sh/libre/scim/core/BearerAuthentication.java deleted file mode 100644 index 2b4133d..0000000 --- a/src/main/java/sh/libre/scim/core/BearerAuthentication.java +++ /dev/null @@ -1,20 +0,0 @@ -package sh.libre.scim.core; - -import java.io.IOException; - -import javax.ws.rs.client.ClientRequestContext; -import javax.ws.rs.client.ClientRequestFilter; - -public class BearerAuthentication implements ClientRequestFilter { - private final String token; - - BearerAuthentication(String token) { - this.token = token; - } - - @Override - public void filter(ClientRequestContext requestContext) throws IOException { - requestContext.getHeaders().add("Authorization", "Bearer " + this.token); - - } -} diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index 33039c5..8680060 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -7,19 +7,16 @@ import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; - import javax.persistence.NoResultException; - -import com.unboundid.scim2.common.types.GroupResource; -import com.unboundid.scim2.common.types.Member; -import com.unboundid.scim2.common.types.Meta; - +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.jboss.logging.Logger; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; -public class GroupAdapter extends Adapter { +public class GroupAdapter extends Adapter { private String displayName; private Set members = new HashSet(); @@ -39,8 +36,8 @@ public class GroupAdapter extends Adapter { } @Override - public Class getResourceClass() { - return GroupResource.class; + public Class getResourceClass() { + return Group.class; } @Override @@ -55,14 +52,14 @@ public class GroupAdapter extends Adapter { } @Override - public void apply(GroupResource group) { - setExternalId(group.getId()); - setDisplayName(group.getDisplayName()); + public void apply(Group group) { + setExternalId(group.getId().get()); + setDisplayName(group.getDisplayName().get()); var groupMembers = group.getMembers(); if (groupMembers != null && groupMembers.size() > 0) { this.members = new HashSet(); for (var groupMember : groupMembers) { - var userMapping = this.query("findByExternalId", groupMember.getValue(), "User") + var userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User") .getSingleResult(); this.members.add(userMapping.getId()); } @@ -70,8 +67,8 @@ public class GroupAdapter extends Adapter { } @Override - public GroupResource toSCIM(Boolean addMeta) { - var group = new GroupResource(); + public Group toSCIM(Boolean addMeta) { + var group = new Group(); group.setId(externalId); group.setExternalId(id); group.setDisplayName(displayName); @@ -83,7 +80,7 @@ public class GroupAdapter extends Adapter { 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); + groupMember.setRef(ref.toString()); groupMembers.add(groupMember); } catch (Exception e) { LOGGER.error(e); @@ -95,7 +92,7 @@ public class GroupAdapter extends Adapter { var meta = new Meta(); try { var uri = new URI("Groups/" + externalId); - meta.setLocation(uri); + meta.setLocation(uri.toString()); } catch (URISyntaxException e) { } group.setMeta(meta); diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 67e15ac..29c5c17 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -1,61 +1,108 @@ 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 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 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; +import de.captaingoldfish.scim.sdk.common.response.ListResponse; import org.jboss.logging.Logger; -import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; 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 com.google.common.net.HttpHeaders; + import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.RetryConfig; import io.github.resilience4j.retry.RetryRegistry; + public class ScimClient { final protected Logger LOGGER = Logger.getLogger(ScimClient.class); - final protected Client client = ResteasyClientBuilder.newClient(); - final protected ScimService scimService; + final protected ScimRequestBuilder scimRequestBuilder; final protected RetryRegistry registry; final protected KeycloakSession session; final protected String contentType; final protected ComponentModel model; + final protected String scimApplicationBaseUrl; + final protected Map defaultHeaders; + final protected Map expectedResponseHeaders; public ScimClient(ComponentModel model, KeycloakSession session) { this.model = model; this.contentType = model.get("content-type"); - this.session = session; - var target = client.target(model.get("endpoint")); + this.scimApplicationBaseUrl = model.get("endpoint"); + this.defaultHeaders = new HashMap<>(); + this.expectedResponseHeaders = new HashMap<>(); + switch (model.get("auth-mode")) { case "BEARER": - target = target.register(new BearerAuthentication(model.get("auth-pass"))); + defaultHeaders.put(HttpHeaders.AUTHORIZATION, + BearerAuthentication(model.get("auth-pass"))); break; case "BASIC_AUTH": - target = target.register(new BasicAuthentication( - model.get("auth-user"), - model.get("auth-pass"))); + defaultHeaders.put(HttpHeaders.AUTHORIZATION, + BasicAuthentication(model.get("auth-user"), + model.get("auth-pass"))); + break; } - scimService = new ScimService(target); + defaultHeaders.put(HttpHeaders.CONTENT_TYPE,contentType); + + scimRequestBuilder = new ScimRequestBuilder(scimApplicationBaseUrl, genScimClientConfig()); RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(10) - .intervalFunction(IntervalFunction.ofExponentialBackoff()) - .retryExceptions(ProcessingException.class) - .build(); + .maxAttempts(10) + .intervalFunction(IntervalFunction.ofExponentialBackoff()) + .retryExceptions(ProcessingException.class) + .build(); + registry = RetryRegistry.of(retryConfig); } + protected String BasicAuthentication(String username ,String password) { + return BasicAuth.builder() + .username(model.get(username)) + .password(model.get(password)) + .build() + .getAuthorizationHeaderValue(); + } + + protected ScimClientConfig genScimClientConfig() { + return ScimClientConfig.builder() + .httpHeaders(defaultHeaders) + .connectTimeout(5) + .requestTimeout(5) + .socketTimeout(5) + .expectedHttpResponseHeaders(expectedResponseHeaders) + .hostnameVerifier((s, sslSession) -> true) + .build(); + } + + protected String BearerAuthentication(String token) { + return "Bearer " + token ; + } + + 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(); } @@ -64,7 +111,7 @@ public class ScimClient { return session.getContext().getRealm().getId(); } - protected > A getAdapter( + protected > A getAdapter( Class aClass) { try { return aClass.getDeclaredConstructor(KeycloakSession.class, String.class) @@ -74,7 +121,7 @@ public class ScimClient { } } - public > void create(Class aClass, + public > void create(Class aClass, M kcModel) { var adapter = getAdapter(aClass); adapter.apply(kcModel); @@ -85,21 +132,28 @@ public class ScimClient { return; } var retry = registry.retry("create-" + adapter.getId()); - var resource = retry.executeSupplier(() -> { + + ServerResponse response = retry.executeSupplier(() -> { try { - return scimService.createRequest(adapter.getSCIMEndpoint(), - adapter.toSCIM(false)) - .contentType(contentType).invoke(); - } catch (ScimException e) { + return scimRequestBuilder + .create(adapter.getResourceClass(), String.format("/" + adapter.getSCIMEndpoint())) + .setResource(adapter.toSCIM(false)) + .sendRequest(); + } catch ( ResponseException e) { throw new RuntimeException(e); } }); - adapter.apply(resource); - adapter.saveMapping(); + if (!response.isSuccess()){ + LOGGER.warn(response.getResponseBody()); + LOGGER.warn(response.getHttpStatus()); + } + + adapter.apply(response.getResource()); + adapter.saveMapping(); }; - public > void replace(Class aClass, + public > void replace(Class aClass, M kcModel) { var adapter = getAdapter(aClass); try { @@ -109,13 +163,22 @@ public class ScimClient { var resource = adapter.query("findById", adapter.getId()).getSingleResult(); adapter.apply(resource); var retry = registry.retry("replace-" + adapter.getId()); - retry.executeSupplier(() -> { + ServerResponse response = retry.executeSupplier(() -> { try { - return scimService.replaceRequest(adapter.toSCIM(true)).contentType(contentType).invoke(); - } catch (ScimException e) { + return scimRequestBuilder + .update(genScimUrl(adapter.getSCIMEndpoint(), adapter.getExternalId()), + adapter.getResourceClass()) + .setResource(adapter.toSCIM(false)) + .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) { @@ -123,30 +186,40 @@ public class ScimClient { } } - public > void delete(Class aClass, + public > void delete(Class aClass, String id) { var adapter = getAdapter(aClass); adapter.setId(id); + try { var resource = adapter.query("findById", adapter.getId()).getSingleResult(); adapter.apply(resource); + var retry = registry.retry("delete-" + id); - retry.executeSupplier(() -> { + + ServerResponse response = retry.executeSupplier(() -> { try { - scimService.deleteRequest(adapter.getSCIMEndpoint(), resource.getExternalId()) - .contentType(contentType).invoke(); - } catch (ScimException e) { + return scimRequestBuilder.delete(genScimUrl(adapter.getSCIMEndpoint(), adapter.getExternalId()), + adapter.getResourceClass()) + .sendRequest(); + } catch (ResponseException e) { throw new RuntimeException(e); } - return ""; }); + + if (!response.isSuccess()){ + LOGGER.warn(response.getResponseBody()); + LOGGER.warn(response.getHttpStatus()); + } + getEM().remove(resource); + } catch (NoResultException e) { LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id); } } - public > void refreshResources( + public > void refreshResources( Class aClass, SynchronizationResult syncRes) { LOGGER.info("Refresh resources"); @@ -169,16 +242,17 @@ public class ScimClient { } - public > void importResources( + public > void importResources( Class aClass, SynchronizationResult syncRes) { LOGGER.info("Import"); try { var adapter = getAdapter(aClass); - var resources = scimService.searchRequest(adapter.getSCIMEndpoint()).contentType(contentType) - .invoke(adapter.getResourceClass()); - for (var resource : resources) { + ServerResponse> response = scimRequestBuilder.list("url",adapter.getResourceClass()).get().sendRequest(); + ListResponse resourceTypeListResponse = response.getResource(); + + for (var resource : resourceTypeListResponse.getListedResources()) { try { - LOGGER.infof("Reconciling remote resource %s", resource.getId()); + LOGGER.infof("Reconciling remote resource %s", resource); adapter = getAdapter(aClass); adapter.apply(resource); @@ -212,9 +286,11 @@ public class ScimClient { break; case "DELETE_REMOTE": LOGGER.info("Delete remote resource"); - scimService.deleteRequest(adapter.getSCIMEndpoint(), resource.getId()) - .contentType(contentType) - .invoke(); + scimRequestBuilder + .delete(genScimUrl(adapter.getSCIMEndpoint(), + resource.getId().get()), + adapter.getResourceClass()) + .sendRequest(); syncRes.increaseRemoved(); break; } @@ -225,12 +301,12 @@ public class ScimClient { syncRes.increaseFailed(); } } - } catch (ScimException e) { + } catch (ResponseException e) { throw new RuntimeException(e); } } - public > void sync(Class aClass, + public > void sync(Class aClass, SynchronizationResult syncRes) { if (this.model.get("sync-import", false)) { this.importResources(aClass, syncRes); @@ -241,6 +317,6 @@ public class ScimClient { } public void close() { - client.close(); + scimRequestBuilder.close(); } } diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index c17ef52..b2283e5 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -7,17 +7,19 @@ import java.util.HashSet; import java.util.List; 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.Role; -import com.unboundid.scim2.common.types.UserResource; +import de.captaingoldfish.scim.sdk.common.resources.User; +import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Email; +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.jboss.logging.Logger; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; -public class UserAdapter extends Adapter { +public class UserAdapter extends Adapter { private String username; private String displayName; @@ -78,8 +80,8 @@ public class UserAdapter extends Adapter { } @Override - public Class getResourceClass() { - return UserResource.class; + public Class getResourceClass() { + return User.class; } @Override @@ -114,27 +116,29 @@ public class UserAdapter extends Adapter { } @Override - public void apply(UserResource user) { - setExternalId(user.getId()); - setUsername(user.getUserName()); - setDisplayName(user.getDisplayName()); - setActive(user.getActive()); + public void apply(User user) { + setExternalId(user.getId().get()); + setUsername(user.getUserName().get()); + setDisplayName(user.getDisplayName().get()); + setActive(user.isActive().get()); if (user.getEmails().size() > 0) { - setEmail(user.getEmails().get(0).getValue()); + setEmail(user.getEmails().get(0).getValue().get()); } } @Override - public UserResource toSCIM(Boolean addMeta) { - var user = new UserResource(); + public User toSCIM(Boolean addMeta) { + var user = new User(); user.setExternalId(id); user.setUserName(username); user.setId(externalId); user.setDisplayName(displayName); + Name name = new Name(); + user.setName(name); var emails = new ArrayList(); if (email != null) { emails.add( - new Email().setPrimary(true).setValue(email)); + Email.builder().value(getEmail()).build()); } user.setEmails(emails); user.setActive(active); @@ -142,14 +146,14 @@ public class UserAdapter extends Adapter { var meta = new Meta(); try { var uri = new URI("Users/" + externalId); - meta.setLocation(uri); + meta.setLocation(uri.toString()); } catch (URISyntaxException e) { } user.setMeta(meta); } - List roles = new ArrayList(); + List roles = new ArrayList(); for (var r : this.roles) { - var role = new Role(); + var role = new PersonRole(); role.setValue(r); roles.add(role); } diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index d961b70..34ea984 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -5,8 +5,6 @@ import java.util.List; import javax.ws.rs.core.MediaType; -import com.unboundid.scim2.client.ScimService; - import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; @@ -24,6 +22,8 @@ import sh.libre.scim.core.GroupAdapter; import sh.libre.scim.core.ScimDispatcher; import sh.libre.scim.core.UserAdapter; +import de.captaingoldfish.scim.sdk.common.constants.HttpHeader; + public class ScimStorageProviderFactory implements UserStorageProviderFactory, ImportSynchronization { final private Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class); @@ -43,8 +43,8 @@ public class ScimStorageProviderFactory .type(ProviderConfigProperty.LIST_TYPE) .label("Endpoint content type") .helpText("Only used when endpoint doesn't support application/scim+json") - .options(MediaType.APPLICATION_JSON.toString(), ScimService.MEDIA_TYPE_SCIM_TYPE.toString()) - .defaultValue(ScimService.MEDIA_TYPE_SCIM_TYPE.toString()) + .options(MediaType.APPLICATION_JSON.toString(), HttpHeader.SCIM_CONTENT_TYPE) + .defaultValue(HttpHeader.SCIM_CONTENT_TYPE) .add() .property() .name("auth-mode") -- GitLab From 182824a3b4504cf1d810aba7669949e0e4c476ab Mon Sep 17 00:00:00 2001 From: Hugo Renard Date: Tue, 6 Feb 2024 12:13:30 +0100 Subject: [PATCH 02/58] feat: update to keycloak 23 + varrious fixes --- build.gradle | 28 +++----- docker-compose.yml | 3 +- legacy-build.gradle | 33 --------- src/main/java/sh/libre/scim/core/Adapter.java | 15 ++-- .../java/sh/libre/scim/core/GroupAdapter.java | 48 +++++++------ .../java/sh/libre/scim/core/ScimClient.java | 46 +++++------- .../sh/libre/scim/core/ScimDispatcher.java | 5 +- .../java/sh/libre/scim/core/UserAdapter.java | 70 ++++++++++++------- .../scim/event/ScimEventListenerProvider.java | 9 +-- .../java/sh/libre/scim/jpa/ScimResource.java | 14 ++-- .../sh/libre/scim/jpa/ScimResourceId.java | 1 + .../storage/ScimStorageProviderFactory.java | 34 ++++++++- 12 files changed, 154 insertions(+), 152 deletions(-) delete mode 100644 legacy-build.gradle diff --git a/build.gradle b/build.gradle index 35ee773..ce8c838 100644 --- a/build.gradle +++ b/build.gradle @@ -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' } diff --git a/docker-compose.yml b/docker-compose.yml index d18aa1e..d119247 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/legacy-build.gradle b/legacy-build.gradle deleted file mode 100644 index 2f57e15..0000000 --- a/legacy-build.gradle +++ /dev/null @@ -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') -} diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java index 8faac5a..d5cbb0d 100644 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ b/src/main/java/sh/libre/scim/core/Adapter.java @@ -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 } public String getSCIMEndpoint() { - return type + "s"; + return "/" + type + "s"; } public ScimResource toMapping() { @@ -95,12 +94,8 @@ public abstract class Adapter 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 public abstract Class getResourceClass(); - public abstract S toSCIM(Boolean addMeta); + public abstract S toSCIM(); public abstract Boolean entityExists(); diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index 8680060..e7edf6d 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -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 { if (groupMembers != null && groupMembers.size() > 0) { this.members = new HashSet(); 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(); 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 { @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; diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 29c5c17..e3b1b72 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -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 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 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 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> response = scimRequestBuilder.list("url",adapter.getResourceClass()).get().sendRequest(); + ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getSCIMEndpoint()).get().sendRequest(); ListResponse 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; diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index f76102c..293106b 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -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 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)); diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index b2283e5..ff85170 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -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 { 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 { 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 getResourceClass() { return User.class; @@ -96,19 +115,17 @@ public class UserAdapter extends Adapter { setDisplayName(displayName); setEmail(user.getEmail()); setActive(user.isEnabled()); + setFirstName(user.getFirstName()); + setLastName(user.getLastName()); var rolesSet = new HashSet(); 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 { } @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(); 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 roles = new ArrayList(); for (var r : this.roles) { var role = new PersonRole(); @@ -212,11 +230,13 @@ public class UserAdapter extends Adapter { @Override public Stream getResourceStream() { - return this.session.users().getUsersStream(this.session.getContext().getRealm()); + Map params = new HashMap() { + }; + return this.session.users().searchForUserStream(realm, params); } @Override public Boolean skipRefresh() { - return getUsername().equals("admin"); + return StringUtils.equals(getUsername(), "admin"); } } diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 3fbad12..24dab78 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -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)); diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java index a9f6958..62dbdf0 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResource.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java @@ -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) diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java index 7775543..1fa0d21 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -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 && diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index 34ea984..265c6c6 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -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"); + } } -- GitLab From 62eff08fcfe4ac93e33df0f78feb34a7f58c750e Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 14:20:29 +0200 Subject: [PATCH 03/58] Factorize "true" string --- src/main/java/sh/libre/scim/core/GroupAdapter.java | 3 ++- src/main/java/sh/libre/scim/core/UserAdapter.java | 7 ++++--- .../libre/scim/event/ScimEventListenerProvider.java | 3 ++- .../scim/storage/ScimStorageProviderFactory.java | 11 ++++++----- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index e7edf6d..5b5b944 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -10,6 +10,7 @@ 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.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.models.GroupModel; @@ -47,7 +48,7 @@ public class GroupAdapter extends Adapter { .getGroupMembersStream(session.getContext().getRealm(), group) .map(x -> x.getId()) .collect(Collectors.toSet()); - this.skip = StringUtils.equals(group.getFirstAttribute("scim-skip"), "true"); + this.skip = BooleanUtils.TRUE.equals(group.getFirstAttribute("scim-skip")); } @Override diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index ff85170..4d40d55 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -15,6 +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.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.models.KeycloakSession; @@ -119,17 +120,17 @@ public class UserAdapter extends Adapter { setLastName(user.getLastName()); var rolesSet = new HashSet(); user.getGroupsStream().flatMap(g -> g.getRoleMappingsStream()) - .filter((r) -> StringUtils.equals(r.getFirstAttribute("scim"), "true")) + .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) .map((r) -> r.getName()) .forEach(r -> rolesSet.add(r)); user.getRoleMappingsStream() - .filter((r) -> StringUtils.equals(r.getFirstAttribute("scim"), "true")) + .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) .map((r) -> r.getName()) .forEach(r -> rolesSet.add(r)); var roles = new String[rolesSet.size()]; rolesSet.toArray(roles); setRoles(roles); - this.skip = StringUtils.equals(user.getFirstAttribute("scim-skip"), "true"); + this.skip = BooleanUtils.TRUE.equals(user.getFirstAttribute("scim-skip")); } @Override diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 24dab78..b64cf49 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -3,6 +3,7 @@ package sh.libre.scim.event; import java.util.HashMap; import java.util.regex.Pattern; +import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.events.Event; @@ -100,7 +101,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); - group.setSingleAttribute("scim-dirty", "true"); + group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); var user = getUser(userId); dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); } diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index 265c6c6..7b6336d 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -5,6 +5,7 @@ import java.util.List; import jakarta.ws.rs.core.MediaType; +import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; @@ -74,14 +75,14 @@ public class ScimStorageProviderFactory .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable user propagation") .helpText("Should operation on users be propagated to this provider ?") - .defaultValue("true") + .defaultValue(BooleanUtils.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") + .defaultValue(BooleanUtils.TRUE) .add() .property() .name("sync-import") @@ -132,10 +133,10 @@ public class ScimStorageProviderFactory var realm = session.realms().getRealm(realmId); session.getContext().setRealm(realm); var dispatcher = new ScimDispatcher(session); - if (StringUtils.equals(model.get("propagation-user"), "true")) { + if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { dispatcher.runOne(model, (client) -> client.sync(UserAdapter.class, result)); } - if (StringUtils.equals(model.get("propagation-group"), "true")) { + if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { dispatcher.runOne(model, (client) -> client.sync(GroupAdapter.class, result)); } } @@ -163,7 +164,7 @@ public class ScimStorageProviderFactory 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()) { + .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) { LOGGER.debug(group.getName() + " is dirty"); dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); -- GitLab From 000b47a58188fe0da3218eed86d00de6e945acf7 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 14:22:31 +0200 Subject: [PATCH 04/58] Fix some strings --- .../libre/scim/storage/ScimStorageProviderFactory.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index 7b6336d..f8df061 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -62,26 +62,26 @@ public class ScimStorageProviderFactory .name("auth-user") .type(ProviderConfigProperty.STRING_TYPE) .label("Auth username") - .helpText("Required for basic authentification.") + .helpText("Required for basic authentication.") .add() .property() .name("auth-pass") .type(ProviderConfigProperty.PASSWORD) .label("Auth password/token") - .helpText("Password or token required for basic or bearer authentification.") + .helpText("Password or token required for basic or bearer authentication.") .add() .property() .name("propagation-user") .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable user propagation") - .helpText("Should operation on users be propagated to this provider ?") + .helpText("Should operation on users be propagated to this provider?") .defaultValue(BooleanUtils.TRUE) .add() .property() .name("propagation-group") .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable group propagation") - .helpText("Should operation on groups be propagated to this provider ?") + .helpText("Should operation on groups be propagated to this provider?") .defaultValue(BooleanUtils.TRUE) .add() .property() @@ -93,7 +93,7 @@ public class ScimStorageProviderFactory .name("sync-import-action") .type(ProviderConfigProperty.LIST_TYPE) .label("Import action") - .helpText("What to do when the user don\'t exists in Keycloak.") + .helpText("What to do when the user doesn't exists in Keycloak.") .options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE") .defaultValue("CREATE_LOCAL") .add() -- GitLab From 2782beafe6baf9ef0035fee9bb8a9be41b3285db Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 14:47:45 +0200 Subject: [PATCH 05/58] Fix some identations and code conventions --- src/main/java/sh/libre/scim/core/Adapter.java | 10 +- .../java/sh/libre/scim/core/GroupAdapter.java | 38 +++--- .../java/sh/libre/scim/core/ScimClient.java | 121 +++++++++--------- .../sh/libre/scim/core/ScimDispatcher.java | 12 +- .../java/sh/libre/scim/core/UserAdapter.java | 32 ++--- .../scim/event/ScimEventListenerProvider.java | 20 +-- .../java/sh/libre/scim/jpa/ScimResource.java | 97 +++++++------- .../libre/scim/jpa/ScimResourceProvider.java | 3 +- .../scim/jpa/ScimResourceProviderFactory.java | 4 +- .../storage/ScimStorageProviderFactory.java | 30 ++--- 10 files changed, 181 insertions(+), 186 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java index d5cbb0d..3c27741 100644 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ b/src/main/java/sh/libre/scim/core/Adapter.java @@ -1,11 +1,9 @@ package sh.libre.scim.core; -import java.util.stream.Stream; - +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; @@ -13,11 +11,11 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.RoleMapperModel; import sh.libre.scim.jpa.ScimResource; -import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; +import java.util.stream.Stream; public abstract class Adapter { - protected final Logger LOGGER; + protected final Logger logger; protected final String realmId; protected final RealmModel realm; protected final String type; @@ -36,7 +34,7 @@ public abstract class Adapter this.componentId = componentId; this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); this.type = type; - this.LOGGER = logger; + this.logger = logger; } public String getId() { diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index 5b5b944..1351fe0 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -1,21 +1,22 @@ package sh.libre.scim.core; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.HashSet; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; -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 de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member; +import jakarta.persistence.NoResultException; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + public class GroupAdapter extends Adapter { private String displayName; @@ -64,7 +65,7 @@ public class GroupAdapter extends Adapter { .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()); + logger.warnf("member %s not found for scim group %s", groupMember.getValue().get(), group.getId().get()); } } } @@ -81,16 +82,16 @@ public class GroupAdapter extends Adapter { var groupMember = new Member(); try { var userMapping = this.query("findById", member, "User").getSingleResult(); - LOGGER.debug(userMapping.getExternalId()); - LOGGER.debug(userMapping.getId()); + 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()); group.addMember(groupMember); } catch (NoResultException e) { - LOGGER.warnf("member %s not found for group %s", member, id); + logger.warnf("member %s not found for group %s", member, id); } catch (URISyntaxException e) { - LOGGER.warnf("bad ref uri"); + logger.warnf("bad ref uri"); } } } @@ -99,7 +100,7 @@ public class GroupAdapter extends Adapter { var uri = new URI("Groups/" + externalId); meta.setLocation(uri.toString()); } catch (URISyntaxException e) { - LOGGER.warn(e); + logger.warn(e); } group.setMeta(meta); return group; @@ -111,16 +112,13 @@ public class GroupAdapter extends Adapter { return false; } var group = session.groups().getGroupById(realm, id); - if (group != null) { - return true; - } - return false; + return group != null; } @Override public Boolean tryToMap() { var group = session.groups().getGroupsStream(realm).filter( - x -> StringUtils.equals(x.getName(), externalId) || StringUtils.equals(x.getName(), displayName)) + x -> StringUtils.equals(x.getName(), externalId) || StringUtils.equals(x.getName(), displayName)) .findFirst(); if (group.isPresent()) { setId(group.get().getId()); @@ -141,7 +139,7 @@ public class GroupAdapter extends Adapter { } user.joinGroup(group); } catch (Exception e) { - LOGGER.warn(e); + logger.warn(e); } } } diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index e3b1b72..08c257c 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -1,12 +1,6 @@ package sh.libre.scim.core; -import java.util.HashMap; -import java.util.Map; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.NoResultException; -import jakarta.ws.rs.ProcessingException; - +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; @@ -14,7 +8,12 @@ import de.captaingoldfish.scim.sdk.client.response.ServerResponse; import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException; import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; import de.captaingoldfish.scim.sdk.common.response.ListResponse; - +import io.github.resilience4j.core.IntervalFunction; +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; @@ -22,23 +21,20 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RoleMapperModel; import org.keycloak.storage.user.SynchronizationResult; -import com.google.common.net.HttpHeaders; - -import io.github.resilience4j.core.IntervalFunction; -import io.github.resilience4j.retry.RetryConfig; -import io.github.resilience4j.retry.RetryRegistry; +import java.util.HashMap; +import java.util.Map; public class ScimClient { - final protected Logger LOGGER = Logger.getLogger(ScimClient.class); - final protected ScimRequestBuilder scimRequestBuilder; - final protected RetryRegistry registry; - final protected KeycloakSession session; - final protected String contentType; - final protected ComponentModel model; - final protected String scimApplicationBaseUrl; - final protected Map defaultHeaders; - final protected Map expectedResponseHeaders; + protected final Logger LOGGER = Logger.getLogger(ScimClient.class); + protected final ScimRequestBuilder scimRequestBuilder; + protected final RetryRegistry registry; + protected final KeycloakSession session; + protected final String contentType; + protected final ComponentModel model; + protected final String scimApplicationBaseUrl; + protected final Map defaultHeaders; + protected final Map expectedResponseHeaders; public ScimClient(ComponentModel model, KeycloakSession session) { this.model = model; @@ -51,48 +47,48 @@ public class ScimClient { switch (model.get("auth-mode")) { case "BEARER": defaultHeaders.put(HttpHeaders.AUTHORIZATION, - BearerAuthentication()); + BearerAuthentication()); break; case "BASIC_AUTH": defaultHeaders.put(HttpHeaders.AUTHORIZATION, - BasicAuthentication()); + BasicAuthentication()); break; } - defaultHeaders.put(HttpHeaders.CONTENT_TYPE,contentType); + defaultHeaders.put(HttpHeaders.CONTENT_TYPE, contentType); scimRequestBuilder = new ScimRequestBuilder(scimApplicationBaseUrl, genScimClientConfig()); RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(10) - .intervalFunction(IntervalFunction.ofExponentialBackoff()) - .retryExceptions(ProcessingException.class) - .build(); + .maxAttempts(10) + .intervalFunction(IntervalFunction.ofExponentialBackoff()) + .retryExceptions(ProcessingException.class) + .build(); registry = RetryRegistry.of(retryConfig); } protected String BasicAuthentication() { - return BasicAuth.builder() - .username(model.get("auth-user")) - .password(model.get("auth-pass")) - .build() - .getAuthorizationHeaderValue(); + return BasicAuth.builder() + .username(model.get("auth-user")) + .password(model.get("auth-pass")) + .build() + .getAuthorizationHeaderValue(); } protected ScimClientConfig genScimClientConfig() { return ScimClientConfig.builder() - .httpHeaders(defaultHeaders) - .connectTimeout(5) - .requestTimeout(5) - .socketTimeout(5) - .expectedHttpResponseHeaders(expectedResponseHeaders) - .hostnameVerifier((s, sslSession) -> true) - .build(); + .httpHeaders(defaultHeaders) + .connectTimeout(5) + .requestTimeout(5) + .socketTimeout(5) + .expectedHttpResponseHeaders(expectedResponseHeaders) + .hostnameVerifier((s, sslSession) -> true) + .build(); } protected String BearerAuthentication() { - return "Bearer " + model.get("auth-pass") ; + return "Bearer " + model.get("auth-pass"); } protected EntityManager getEM() { @@ -114,7 +110,7 @@ public class ScimClient { } public > void create(Class aClass, - M kcModel) { + M kcModel) { var adapter = getAdapter(aClass); adapter.apply(kcModel); if (adapter.skip) @@ -128,25 +124,25 @@ public class ScimClient { ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder - .create(adapter.getResourceClass(), adapter.getSCIMEndpoint()) - .setResource(adapter.toSCIM()) - .sendRequest(); - } catch ( ResponseException e) { + .create(adapter.getResourceClass(), adapter.getSCIMEndpoint()) + .setResource(adapter.toSCIM()) + .sendRequest(); + } catch (ResponseException e) { throw new RuntimeException(e); } }); - if (!response.isSuccess()){ + if (!response.isSuccess()) { LOGGER.warn(response.getResponseBody()); LOGGER.warn(response.getHttpStatus()); } adapter.apply(response.getResource()); adapter.saveMapping(); - }; + } public > void replace(Class aClass, - M kcModel) { + M kcModel) { var adapter = getAdapter(aClass); try { adapter.apply(kcModel); @@ -158,15 +154,14 @@ public class ScimClient { ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder - .update(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId()) - .setResource(adapter.toSCIM()) - .sendRequest() ; - } catch ( ResponseException e) { - + .update(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId()) + .setResource(adapter.toSCIM()) + .sendRequest(); + } catch (ResponseException e) { throw new RuntimeException(e); } }); - if (!response.isSuccess()){ + if (!response.isSuccess()) { LOGGER.warn(response.getResponseBody()); LOGGER.warn(response.getHttpStatus()); } @@ -178,7 +173,7 @@ public class ScimClient { } public > void delete(Class aClass, - String id) { + String id) { var adapter = getAdapter(aClass); adapter.setId(id); @@ -191,13 +186,13 @@ public class ScimClient { ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId()) - .sendRequest(); + .sendRequest(); } catch (ResponseException e) { throw new RuntimeException(e); } }); - if (!response.isSuccess()){ + if (!response.isSuccess()) { LOGGER.warn(response.getResponseBody()); LOGGER.warn(response.getHttpStatus()); } @@ -237,7 +232,7 @@ public class ScimClient { LOGGER.info("Import"); try { var adapter = getAdapter(aClass); - ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getSCIMEndpoint()).get().sendRequest(); + ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getSCIMEndpoint()).get().sendRequest(); ListResponse resourceTypeListResponse = response.getResource(); for (var resource : resourceTypeListResponse.getListedResources()) { @@ -277,8 +272,8 @@ public class ScimClient { case "DELETE_REMOTE": LOGGER.info("Delete remote resource"); scimRequestBuilder - .delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(), resource.getId().get()) - .sendRequest(); + .delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(), resource.getId().get()) + .sendRequest(); syncRes.increaseRemoved(); break; } @@ -295,7 +290,7 @@ public class ScimClient { } public > void sync(Class aClass, - SynchronizationResult syncRes) { + SynchronizationResult syncRes) { if (this.model.get("sync-import", false)) { this.importResources(aClass, syncRes); } diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 293106b..aca3e53 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -1,20 +1,22 @@ 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; - import sh.libre.scim.storage.ScimStorageProviderFactory; +import java.util.function.Consumer; + public class ScimDispatcher { + public static final String SCOPE_USER = "user"; + public static final String SCOPE_GROUP = "group"; - final private KeycloakSession session; - final private Logger LOGGER = Logger.getLogger(ScimDispatcher.class); + private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); + + private final KeycloakSession session; public ScimDispatcher(KeycloakSession session) { this.session = session; diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index 4d40d55..582f2a1 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -1,26 +1,25 @@ 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; -import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Email; +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 de.captaingoldfish.scim.sdk.common.resources.complex.Meta; - 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.stream.Stream; + public class UserAdapter extends Adapter { private String username; @@ -167,7 +166,7 @@ public class UserAdapter extends Adapter { var uri = new URI("Users/" + externalId); meta.setLocation(uri.toString()); } catch (URISyntaxException e) { - LOGGER.warn(e); + logger.warn(e); } user.setMeta(meta); List roles = new ArrayList(); @@ -197,10 +196,7 @@ public class UserAdapter extends Adapter { return false; } var user = session.users().getUserById(realm, id); - if (user != null) { - return true; - } - return false; + return user != null; } @Override @@ -215,7 +211,7 @@ public class UserAdapter extends Adapter { } if ((sameUsernameUser != null && sameEmailUser != null) && (sameUsernameUser.getId() != sameEmailUser.getId())) { - LOGGER.warnf("found 2 possible users for remote user %s %s", username, email); + logger.warnf("found 2 possible users for remote user %s %s", username, email); return false; } if (sameUsernameUser != null) { diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index b64cf49..647bec7 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -1,8 +1,5 @@ package sh.libre.scim.event; -import java.util.HashMap; -import java.util.regex.Pattern; - import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; @@ -15,16 +12,23 @@ import org.keycloak.events.admin.ResourceType; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; - import sh.libre.scim.core.GroupAdapter; import sh.libre.scim.core.ScimDispatcher; import sh.libre.scim.core.UserAdapter; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + public class ScimEventListenerProvider implements EventListenerProvider { - final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class); - ScimDispatcher dispatcher; - KeycloakSession session; - HashMap patterns = new HashMap(); + + private static final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class); + + private final ScimDispatcher dispatcher; + + private final KeycloakSession session; + + private final Map patterns = new HashMap(); public ScimEventListenerProvider(KeycloakSession session) { this.session = session; diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java index 62dbdf0..1de3faa 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResource.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java @@ -4,75 +4,76 @@ 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.NamedQuery; import jakarta.persistence.Table; @Entity @IdClass(ScimResourceId.class) @Table(name = "SCIM_RESOURCE") @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") }) + @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 { - @Id - @Column(name = "ID", nullable = false) - private String id; - @Id - @Column(name = "REALM_ID", nullable = false) - private String realmId; + @Id + @Column(name = "ID", nullable = false) + private String id; - @Id - @Column(name = "COMPONENT_ID", nullable = false) - private String componentId; + @Id + @Column(name = "REALM_ID", nullable = false) + private String realmId; - @Id - @Column(name = "TYPE", nullable = false) - private String type; + @Id + @Column(name = "COMPONENT_ID", nullable = false) + private String componentId; - @Id - @Column(name = "EXTERNAL_ID", nullable = false) - private String externalId; + @Id + @Column(name = "TYPE", nullable = false) + private String type; - public String getId() { - return id; - } + @Id + @Column(name = "EXTERNAL_ID", nullable = false) + private String externalId; - public void setId(String id) { - this.id = id; - } + public String getId() { + return id; + } - public String getRealmId() { - return realmId; - } + public void setId(String id) { + this.id = id; + } - public void setRealmId(String realmId) { - this.realmId = realmId; - } + public String getRealmId() { + return realmId; + } - public String getComponentId() { - return componentId; - } + public void setRealmId(String realmId) { + this.realmId = realmId; + } - public void setComponentId(String componentId) { - this.componentId = componentId; - } + public String getComponentId() { + return componentId; + } - public String getExternalId() { - return externalId; - } + public void setComponentId(String componentId) { + this.componentId = componentId; + } - public void setExternalId(String externalId) { - this.externalId = externalId; - } + public String getExternalId() { + return externalId; + } - public String getType() { - return type; - } + public void setExternalId(String externalId) { + this.externalId = externalId; + } - public void setType(String type) { - this.type = type; - } + public String getType() { + return type; + } + public void setType(String type) { + this.type = type; + } } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java index 5da5880..ab12d4c 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java @@ -1,10 +1,9 @@ package sh.libre.scim.jpa; -import java.util.List; - import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider; import java.util.Collections; +import java.util.List; public class ScimResourceProvider implements JpaEntityProvider { diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java index 682ccbe..6f4162e 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java @@ -7,7 +7,9 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; public class ScimResourceProviderFactory implements JpaEntityProviderFactory { - final static String ID ="scim-resource"; + + static final String ID = "scim-resource"; + @Override public void close() { } diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index f8df061..4362146 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -1,12 +1,8 @@ package sh.libre.scim.storage; -import java.util.Date; -import java.util.List; - +import de.captaingoldfish.scim.sdk.common.constants.HttpHeader; import jakarta.ws.rs.core.MediaType; - import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; @@ -20,20 +16,24 @@ 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; import sh.libre.scim.core.UserAdapter; -import de.captaingoldfish.scim.sdk.common.constants.HttpHeader; +import java.util.Date; +import java.util.List; public class ScimStorageProviderFactory implements UserStorageProviderFactory, ImportSynchronization { - final private Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class); - public final static String ID = "scim"; - protected static final List configMetadata; + + private final Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class); + + public static final String ID = "scim"; + + private static final List CONFIG_METADATA; + static { - configMetadata = ProviderConfigurationBuilder.create() + CONFIG_METADATA = ProviderConfigurationBuilder.create() .property() .name("endpoint") .type(ProviderConfigProperty.STRING_TYPE) @@ -47,7 +47,7 @@ public class ScimStorageProviderFactory .type(ProviderConfigProperty.LIST_TYPE) .label("Endpoint content type") .helpText("Only used when endpoint doesn't support application/scim+json") - .options(MediaType.APPLICATION_JSON.toString(), HttpHeader.SCIM_CONTENT_TYPE) + .options(MediaType.APPLICATION_JSON, HttpHeader.SCIM_CONTENT_TYPE) .defaultValue(HttpHeader.SCIM_CONTENT_TYPE) .add() .property() @@ -118,12 +118,12 @@ public class ScimStorageProviderFactory @Override public List getConfigProperties() { - return configMetadata; + return CONFIG_METADATA; } @Override public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, - UserStorageProviderModel model) { + UserStorageProviderModel model) { LOGGER.info("sync"); var result = new SynchronizationResult(); KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { @@ -149,7 +149,7 @@ public class ScimStorageProviderFactory @Override public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, - UserStorageProviderModel model) { + UserStorageProviderModel model) { return this.sync(sessionFactory, realmId, model); } -- GitLab From 26dc5edba2e5c8df7a2a77010c734dd5853ef615 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 14:49:28 +0200 Subject: [PATCH 06/58] Use diamond operator when possible --- src/main/java/sh/libre/scim/core/GroupAdapter.java | 4 ++-- src/main/java/sh/libre/scim/core/UserAdapter.java | 4 ++-- .../java/sh/libre/scim/event/ScimEventListenerProvider.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index 1351fe0..3221f4b 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -20,7 +20,7 @@ import java.util.stream.Stream; public class GroupAdapter extends Adapter { private String displayName; - private Set members = new HashSet(); + private Set members = new HashSet<>(); public GroupAdapter(KeycloakSession session, String componentId) { super(session, componentId, "Group", Logger.getLogger(GroupAdapter.class)); @@ -58,7 +58,7 @@ public class GroupAdapter extends Adapter { setDisplayName(group.getDisplayName().get()); var groupMembers = group.getMembers(); if (groupMembers != null && groupMembers.size() > 0) { - this.members = new HashSet(); + this.members = new HashSet<>(); for (var groupMember : groupMembers) { try { var userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User") diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index 582f2a1..2b7cfb3 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -169,7 +169,7 @@ public class UserAdapter extends Adapter { logger.warn(e); } user.setMeta(meta); - List roles = new ArrayList(); + List roles = new ArrayList<>(); for (var r : this.roles) { var role = new PersonRole(); role.setValue(r); @@ -227,7 +227,7 @@ public class UserAdapter extends Adapter { @Override public Stream getResourceStream() { - Map params = new HashMap() { + Map params = new HashMap<>() { }; return this.session.users().searchForUserStream(realm, params); } diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 647bec7..7597bdf 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -28,7 +28,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { private final KeycloakSession session; - private final Map patterns = new HashMap(); + private final Map patterns = new HashMap<>(); public ScimEventListenerProvider(KeycloakSession session) { this.session = session; -- GitLab From 33bc30667dd3b469ce84425ebcff87416c09fc18 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 15:20:26 +0200 Subject: [PATCH 07/58] Use switch statements --- .../scim/event/ScimEventListenerProvider.java | 132 ++++++++++-------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 7597bdf..7e8f9ec 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -45,16 +45,20 @@ public class ScimEventListenerProvider implements EventListenerProvider { @Override public void onEvent(Event event) { - if (event.getType() == EventType.REGISTER) { - var user = getUser(event.getUserId()); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); - } - if (event.getType() == EventType.UPDATE_EMAIL || event.getType() == EventType.UPDATE_PROFILE) { - var user = getUser(event.getUserId()); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); - } - if (event.getType() == EventType.DELETE_ACCOUNT) { - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, event.getUserId())); + EventType eventType = event.getType(); + String eventUserId = event.getUserId(); + switch (eventType) { + case REGISTER -> { + var user = getUser(eventUserId); + dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); + } + case UPDATE_EMAIL, UPDATE_PROFILE -> { + var user = getUser(eventUserId); + dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + } + case DELETE_ACCOUNT -> + dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, eventUserId)); + default -> LOGGER.trace("ignore event " + eventType); } } @@ -66,61 +70,67 @@ public class ScimEventListenerProvider implements EventListenerProvider { var matcher = pattern.matcher(event.getResourcePath()); if (!matcher.find()) return; - if (event.getResourceType() == ResourceType.USER) { - var userId = matcher.group(1); - LOGGER.infof("%s %s", userId, event.getOperationType()); - if (event.getOperationType() == OperationType.CREATE) { - var user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); - user.getGroupsStream().forEach(group -> { - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); - }); - } - if (event.getOperationType() == OperationType.UPDATE) { - var user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + switch (event.getResourceType()) { + case USER -> { + var userId = matcher.group(1); + LOGGER.infof("%s %s", userId, event.getOperationType()); + switch (event.getOperationType()) { + case CREATE -> { + var user = getUser(userId); + dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); + user.getGroupsStream().forEach(group -> { + dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); + }); + } + case UPDATE -> { + var user = getUser(userId); + dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + } + case DELETE -> + dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, userId)); + } } - if (event.getOperationType() == OperationType.DELETE) { - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, userId)); + case GROUP -> { + var groupId = matcher.group(1); + LOGGER.infof("group %s %s", groupId, event.getOperationType()); + switch (event.getOperationType()) { + case CREATE -> { + var group = getGroup(groupId); + dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.create(GroupAdapter.class, group)); + } + case UPDATE -> { + var group = getGroup(groupId); + dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); + } + case DELETE -> dispatcher.run(ScimDispatcher.SCOPE_GROUP, + (client) -> client.delete(GroupAdapter.class, groupId)); + } } - } - if (event.getResourceType() == ResourceType.GROUP) { - var groupId = matcher.group(1); - LOGGER.infof("group %s %s", groupId, event.getOperationType()); - if (event.getOperationType() == OperationType.CREATE) { + case GROUP_MEMBERSHIP -> { + var userId = matcher.group(1); + 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.create(GroupAdapter.class, group)); - } - if (event.getOperationType() == OperationType.UPDATE) { - var group = getGroup(groupId); - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); - } - if (event.getOperationType() == OperationType.DELETE) { - dispatcher.run(ScimDispatcher.SCOPE_GROUP, - (client) -> client.delete(GroupAdapter.class, groupId)); - } - } - if (event.getResourceType() == ResourceType.GROUP_MEMBERSHIP) { - var userId = matcher.group(1); - var groupId = matcher.group(2); - LOGGER.infof("%s %s from %s", event.getOperationType(), userId, groupId); - var group = getGroup(groupId); - group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); - var user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); - } - if (event.getResourceType() == ResourceType.REALM_ROLE_MAPPING) { - var type = matcher.group(1); - var id = matcher.group(2); - LOGGER.infof("%s %s %s roles", event.getOperationType(), type, id); - if (StringUtils.equals(type, "users")) { - var user = getUser(id); + group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); + var user = getUser(userId); dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); - } 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)); - }); + } + case REALM_ROLE_MAPPING -> { + var type = matcher.group(1); + var id = matcher.group(2); + LOGGER.infof("%s %s %s roles", event.getOperationType(), type, id); + switch (type) { + case "users" -> { + var user = getUser(id); + dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + } + case "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)); + }); + } + } } } } -- GitLab From 0c9dd3595e60f0154633d9b05099f01bf5215bdf Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 15:29:44 +0200 Subject: [PATCH 08/58] Remove var keyword --- src/main/java/sh/libre/scim/core/Adapter.java | 4 +- .../java/sh/libre/scim/core/GroupAdapter.java | 32 +++++++++------- .../java/sh/libre/scim/core/ScimClient.java | 30 ++++++++------- .../sh/libre/scim/core/ScimDispatcher.java | 2 +- .../java/sh/libre/scim/core/UserAdapter.java | 27 +++++++------- .../scim/event/ScimEventListenerProvider.java | 37 ++++++++++--------- .../sh/libre/scim/jpa/ScimResourceId.java | 2 +- .../storage/ScimStorageProviderFactory.java | 16 ++++---- 8 files changed, 80 insertions(+), 70 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java index 3c27741..01c2ab9 100644 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ b/src/main/java/sh/libre/scim/core/Adapter.java @@ -62,7 +62,7 @@ public abstract class Adapter } public ScimResource toMapping() { - var entity = new ScimResource(); + ScimResource entity = new ScimResource(); entity.setType(type); entity.setId(id); entity.setExternalId(externalId); @@ -102,7 +102,7 @@ public abstract class Adapter } public void deleteMapping() { - var mapping = this.em.merge(toMapping()); + ScimResource mapping = this.em.merge(toMapping()); this.em.remove(mapping); } diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index 3221f4b..aa42531 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -9,10 +9,14 @@ import org.apache.commons.lang3.StringUtils; 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; @@ -56,12 +60,12 @@ public class GroupAdapter extends Adapter { public void apply(Group group) { setExternalId(group.getId().get()); setDisplayName(group.getDisplayName().get()); - var groupMembers = group.getMembers(); + List groupMembers = group.getMembers(); if (groupMembers != null && groupMembers.size() > 0) { this.members = new HashSet<>(); - for (var groupMember : groupMembers) { + for (Member groupMember : groupMembers) { try { - var userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User") + ScimResource userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User") .getSingleResult(); this.members.add(userMapping.getId()); } catch (NoResultException e) { @@ -73,19 +77,19 @@ public class GroupAdapter extends Adapter { @Override public Group toSCIM() { - var group = new Group(); + Group group = new Group(); group.setId(externalId); group.setExternalId(id); group.setDisplayName(displayName); if (members.size() > 0) { - for (var member : members) { - var groupMember = new Member(); + for (String member : members) { + Member groupMember = new Member(); try { - var userMapping = this.query("findById", member, "User").getSingleResult(); + ScimResource 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())); + URI ref = new URI(String.format("Users/%s", userMapping.getExternalId())); groupMember.setRef(ref.toString()); group.addMember(groupMember); } catch (NoResultException e) { @@ -95,9 +99,9 @@ public class GroupAdapter extends Adapter { } } } - var meta = new Meta(); + Meta meta = new Meta(); try { - var uri = new URI("Groups/" + externalId); + URI uri = new URI("Groups/" + externalId); meta.setLocation(uri.toString()); } catch (URISyntaxException e) { logger.warn(e); @@ -111,13 +115,13 @@ public class GroupAdapter extends Adapter { if (this.id == null) { return false; } - var group = session.groups().getGroupById(realm, id); + GroupModel group = session.groups().getGroupById(realm, id); return group != null; } @Override public Boolean tryToMap() { - var group = session.groups().getGroupsStream(realm).filter( + Optional group = session.groups().getGroupsStream(realm).filter( x -> StringUtils.equals(x.getName(), externalId) || StringUtils.equals(x.getName(), displayName)) .findFirst(); if (group.isPresent()) { @@ -129,11 +133,11 @@ public class GroupAdapter extends Adapter { @Override public void createEntity() { - var group = session.groups().createGroup(realm, displayName); + GroupModel group = session.groups().createGroup(realm, displayName); this.id = group.getId(); for (String mId : members) { try { - var user = session.users().getUserById(realm, mId); + UserModel user = session.users().getUserById(realm, mId); if (user == null) { throw new NoResultException(); } diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 08c257c..99f992b 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -9,6 +9,7 @@ import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException; import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; 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; @@ -20,6 +21,7 @@ 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.HashMap; import java.util.Map; @@ -111,7 +113,7 @@ public class ScimClient { public > void create(Class aClass, M kcModel) { - var adapter = getAdapter(aClass); + A adapter = getAdapter(aClass); adapter.apply(kcModel); if (adapter.skip) return; @@ -119,7 +121,7 @@ public class ScimClient { if (adapter.query("findById", adapter.getId()).getResultList().size() != 0) { return; } - var retry = registry.retry("create-" + adapter.getId()); + Retry retry = registry.retry("create-" + adapter.getId()); ServerResponse response = retry.executeSupplier(() -> { try { @@ -143,14 +145,14 @@ public class ScimClient { public > void replace(Class aClass, M kcModel) { - var adapter = getAdapter(aClass); + A adapter = getAdapter(aClass); try { adapter.apply(kcModel); if (adapter.skip) return; - var resource = adapter.query("findById", adapter.getId()).getSingleResult(); + ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult(); adapter.apply(resource); - var retry = registry.retry("replace-" + adapter.getId()); + Retry retry = registry.retry("replace-" + adapter.getId()); ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder @@ -174,14 +176,14 @@ public class ScimClient { public > void delete(Class aClass, String id) { - var adapter = getAdapter(aClass); + A adapter = getAdapter(aClass); adapter.setId(id); try { - var resource = adapter.query("findById", adapter.getId()).getSingleResult(); + ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult(); adapter.apply(resource); - var retry = registry.retry("delete-" + id); + Retry retry = registry.retry("delete-" + id); ServerResponse response = retry.executeSupplier(() -> { try { @@ -209,11 +211,11 @@ public class ScimClient { SynchronizationResult syncRes) { LOGGER.info("Refresh resources"); getAdapter(aClass).getResourceStream().forEach(resource -> { - var adapter = getAdapter(aClass); + A adapter = getAdapter(aClass); adapter.apply(resource); LOGGER.infof("Reconciling local resource %s", adapter.getId()); if (!adapter.skipRefresh()) { - var mapping = adapter.getMapping(); + ScimResource mapping = adapter.getMapping(); if (mapping == null) { LOGGER.info("Creating it"); this.create(aClass, resource); @@ -231,17 +233,17 @@ public class ScimClient { Class aClass, SynchronizationResult syncRes) { LOGGER.info("Import"); try { - var adapter = getAdapter(aClass); + A adapter = getAdapter(aClass); ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getSCIMEndpoint()).get().sendRequest(); ListResponse resourceTypeListResponse = response.getResource(); - for (var resource : resourceTypeListResponse.getListedResources()) { + for (S resource : resourceTypeListResponse.getListedResources()) { try { LOGGER.infof("Reconciling remote resource %s", resource); adapter = getAdapter(aClass); adapter.apply(resource); - var mapping = adapter.getMapping(); + ScimResource mapping = adapter.getMapping(); if (mapping != null) { adapter.apply(mapping); if (adapter.entityExists()) { @@ -253,7 +255,7 @@ public class ScimClient { } } - var mapped = adapter.tryToMap(); + Boolean mapped = adapter.tryToMap(); if (mapped) { LOGGER.info("Matched"); adapter.saveMapping(); diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index aca3e53..3858c1b 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -34,7 +34,7 @@ public class ScimDispatcher { public void runOne(ComponentModel m, Consumer f) { LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType()); - var client = new ScimClient(m, session); + ScimClient client = new ScimClient(m, session); try { f.accept(client); } catch (Exception e) { diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index 2b7cfb3..6f86771 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -18,6 +18,7 @@ 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 { @@ -107,7 +108,7 @@ public class UserAdapter extends Adapter { public void apply(UserModel user) { setId(user.getId()); setUsername(user.getUsername()); - var displayName = String.format("%s %s", StringUtils.defaultString(user.getFirstName()), + String displayName = String.format("%s %s", StringUtils.defaultString(user.getFirstName()), StringUtils.defaultString(user.getLastName())).trim(); if (StringUtils.isEmpty(displayName)) { displayName = user.getUsername(); @@ -117,7 +118,7 @@ public class UserAdapter extends Adapter { setActive(user.isEnabled()); setFirstName(user.getFirstName()); setLastName(user.getLastName()); - var rolesSet = new HashSet(); + Set rolesSet = new HashSet<>(); user.getGroupsStream().flatMap(g -> g.getRoleMappingsStream()) .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) .map((r) -> r.getName()) @@ -126,7 +127,7 @@ public class UserAdapter extends Adapter { .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) .map((r) -> r.getName()) .forEach(r -> rolesSet.add(r)); - var roles = new String[rolesSet.size()]; + String[] roles = new String[rolesSet.size()]; rolesSet.toArray(roles); setRoles(roles); this.skip = BooleanUtils.TRUE.equals(user.getFirstAttribute("scim-skip")); @@ -145,7 +146,7 @@ public class UserAdapter extends Adapter { @Override public User toSCIM() { - var user = new User(); + User user = new User(); user.setExternalId(id); user.setUserName(username); user.setId(externalId); @@ -154,26 +155,26 @@ public class UserAdapter extends Adapter { name.setFamilyName(lastName); name.setGivenName(firstName); user.setName(name); - var emails = new ArrayList(); + List emails = new ArrayList<>(); if (email != null) { emails.add( Email.builder().value(getEmail()).build()); } user.setEmails(emails); user.setActive(active); - var meta = new Meta(); + Meta meta = new Meta(); try { - var uri = new URI("Users/" + externalId); + URI uri = new URI("Users/" + externalId); meta.setLocation(uri.toString()); } catch (URISyntaxException e) { logger.warn(e); } user.setMeta(meta); List roles = new ArrayList<>(); - for (var r : this.roles) { - var role = new PersonRole(); - role.setValue(r); - roles.add(role); + for (String role : this.roles) { + PersonRole personRole = new PersonRole(); + personRole.setValue(role); + roles.add(personRole); } user.setRoles(roles); return user; @@ -184,7 +185,7 @@ public class UserAdapter extends Adapter { if (StringUtils.isEmpty(username)) { throw new Exception("can't create user with empty username"); } - var user = session.users().addUser(realm, username); + UserModel user = session.users().addUser(realm, username); user.setEmail(email); user.setEnabled(active); this.id = user.getId(); @@ -195,7 +196,7 @@ public class UserAdapter extends Adapter { if (this.id == null) { return false; } - var user = session.users().getUserById(realm, id); + UserModel user = session.users().getUserById(realm, id); return user != null; } diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 7e8f9ec..f490dd5 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -18,6 +18,7 @@ import sh.libre.scim.core.UserAdapter; import java.util.HashMap; import java.util.Map; +import java.util.regex.Matcher; import java.util.regex.Pattern; public class ScimEventListenerProvider implements EventListenerProvider { @@ -49,11 +50,11 @@ public class ScimEventListenerProvider implements EventListenerProvider { String eventUserId = event.getUserId(); switch (eventType) { case REGISTER -> { - var user = getUser(eventUserId); + UserModel user = getUser(eventUserId); dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); } case UPDATE_EMAIL, UPDATE_PROFILE -> { - var user = getUser(eventUserId); + UserModel user = getUser(eventUserId); dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); } case DELETE_ACCOUNT -> @@ -64,26 +65,26 @@ public class ScimEventListenerProvider implements EventListenerProvider { @Override public void onEvent(AdminEvent event, boolean includeRepresentation) { - var pattern = patterns.get(event.getResourceType()); + Pattern pattern = patterns.get(event.getResourceType()); if (pattern == null) return; - var matcher = pattern.matcher(event.getResourcePath()); + Matcher matcher = pattern.matcher(event.getResourcePath()); if (!matcher.find()) return; switch (event.getResourceType()) { case USER -> { - var userId = matcher.group(1); + String userId = matcher.group(1); LOGGER.infof("%s %s", userId, event.getOperationType()); switch (event.getOperationType()) { case CREATE -> { - var user = getUser(userId); + UserModel user = getUser(userId); dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); user.getGroupsStream().forEach(group -> { dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); }); } case UPDATE -> { - var user = getUser(userId); + UserModel user = getUser(userId); dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); } case DELETE -> @@ -91,15 +92,15 @@ public class ScimEventListenerProvider implements EventListenerProvider { } } case GROUP -> { - var groupId = matcher.group(1); + String groupId = matcher.group(1); LOGGER.infof("group %s %s", groupId, event.getOperationType()); switch (event.getOperationType()) { case CREATE -> { - var group = getGroup(groupId); + GroupModel group = getGroup(groupId); dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.create(GroupAdapter.class, group)); } case UPDATE -> { - var group = getGroup(groupId); + GroupModel group = getGroup(groupId); dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); } case DELETE -> dispatcher.run(ScimDispatcher.SCOPE_GROUP, @@ -107,25 +108,25 @@ public class ScimEventListenerProvider implements EventListenerProvider { } } case GROUP_MEMBERSHIP -> { - var userId = matcher.group(1); - var groupId = matcher.group(2); + String userId = matcher.group(1); + String groupId = matcher.group(2); LOGGER.infof("%s %s from %s", event.getOperationType(), userId, groupId); - var group = getGroup(groupId); + GroupModel group = getGroup(groupId); group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); - var user = getUser(userId); + UserModel user = getUser(userId); dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); } case REALM_ROLE_MAPPING -> { - var type = matcher.group(1); - var id = matcher.group(2); + String type = matcher.group(1); + String id = matcher.group(2); LOGGER.infof("%s %s %s roles", event.getOperationType(), type, id); switch (type) { case "users" -> { - var user = getUser(id); + UserModel user = getUser(id); dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); } case "groups" -> { - var group = getGroup(id); + GroupModel group = getGroup(id); session.users().getGroupMembersStream(session.getContext().getRealm(), group).forEach(user -> { dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); }); diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java index 1fa0d21..1df1992 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -67,7 +67,7 @@ public class ScimResourceId implements Serializable { return true; if (!(other instanceof ScimResourceId)) return false; - var o = (ScimResourceId) other; + ScimResourceId o = (ScimResourceId) other; // TODO return (o.id == id && o.realmId == realmId && diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index 4362146..61eae54 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -5,9 +5,11 @@ import jakarta.ws.rs.core.MediaType; import org.apache.commons.lang3.BooleanUtils; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; +import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionTask; +import org.keycloak.models.RealmModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; @@ -125,14 +127,14 @@ public class ScimStorageProviderFactory public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) { LOGGER.info("sync"); - var result = new SynchronizationResult(); + SynchronizationResult result = new SynchronizationResult(); KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { @Override public void run(KeycloakSession session) { - var realm = session.realms().getRealm(realmId); + RealmModel realm = session.realms().getRealm(realmId); session.getContext().setRealm(realm); - var dispatcher = new ScimDispatcher(session); + ScimDispatcher dispatcher = new ScimDispatcher(session); if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { dispatcher.runOne(model, (client) -> client.sync(UserAdapter.class, result)); } @@ -155,15 +157,15 @@ public class ScimStorageProviderFactory @Override public void postInit(KeycloakSessionFactory factory) { - var timer = factory.create().getProvider(TimerProvider.class); + TimerProvider timer = factory.create().getProvider(TimerProvider.class); timer.scheduleTask(taskSession -> { - for (var realm : taskSession.realms().getRealmsStream().toList()) { + for (RealmModel 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) + ScimDispatcher dispatcher = new ScimDispatcher(session); + for (GroupModel group : session.groups().getGroupsStream(realm) .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) { LOGGER.debug(group.getName() + " is dirty"); dispatcher.run(ScimDispatcher.SCOPE_GROUP, -- GitLab From 7d42a4e1d2a811a93659c48e526ed540c51596d7 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 15:34:22 +0200 Subject: [PATCH 09/58] Remove pointless use of StringUtils --- src/main/java/sh/libre/scim/core/GroupAdapter.java | 5 +++-- src/main/java/sh/libre/scim/core/ScimDispatcher.java | 2 +- src/main/java/sh/libre/scim/core/UserAdapter.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index aa42531..31b25b5 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -121,8 +121,9 @@ public class GroupAdapter extends Adapter { @Override public Boolean tryToMap() { - Optional group = session.groups().getGroupsStream(realm).filter( - x -> StringUtils.equals(x.getName(), externalId) || StringUtils.equals(x.getName(), displayName)) + Set names = Set.of(externalId, displayName); + Optional group = session.groups().getGroupsStream(realm) + .filter(groupModel -> names.contains(groupModel.getName())) .findFirst(); if (group.isPresent()) { setId(group.get().getId()); diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 3858c1b..4b2beed 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -25,7 +25,7 @@ public class ScimDispatcher { public void run(String scope, Consumer f) { session.getContext().getRealm().getComponentsStream() .filter((m) -> { - return StringUtils.equals(ScimStorageProviderFactory.ID, m.getProviderId()) + return ScimStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true) && m.get("propagation-" + scope, false); }) diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index 6f86771..8d527f8 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -235,6 +235,6 @@ public class UserAdapter extends Adapter { @Override public Boolean skipRefresh() { - return StringUtils.equals(getUsername(), "admin"); + return "admin".equals(getUsername()); } } -- GitLab From 5c2b20d4e00f04527bcb19adbd93e7c0fa416e02 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 15:35:51 +0200 Subject: [PATCH 10/58] Remove useless anonymous subclass creation --- src/main/java/sh/libre/scim/core/UserAdapter.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index 8d527f8..76ecb34 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -228,8 +228,7 @@ public class UserAdapter extends Adapter { @Override public Stream getResourceStream() { - Map params = new HashMap<>() { - }; + Map params = new HashMap<>(); return this.session.users().searchForUserStream(realm, params); } -- GitLab From 208659d42e659ab213053cf9796cd232eb7b3171 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 15:38:47 +0200 Subject: [PATCH 11/58] Remove string comparaison using '==' --- src/main/java/sh/libre/scim/core/UserAdapter.java | 3 ++- src/main/java/sh/libre/scim/jpa/ScimResourceId.java | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index 76ecb34..ca0abf8 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -18,6 +18,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Stream; @@ -211,7 +212,7 @@ public class UserAdapter extends Adapter { sameEmailUser = session.users().getUserByEmail(realm, email); } if ((sameUsernameUser != null && sameEmailUser != null) - && (sameUsernameUser.getId() != sameEmailUser.getId())) { + && (!StringUtils.equals(sameUsernameUser.getId(), sameEmailUser.getId()))) { logger.warnf("found 2 possible users for remote user %s %s", username, email); return false; } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java index 1df1992..99bce76 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -1,5 +1,7 @@ package sh.libre.scim.jpa; +import org.apache.commons.lang3.StringUtils; + import java.io.Serializable; import java.util.Objects; @@ -69,11 +71,11 @@ public class ScimResourceId implements Serializable { return false; ScimResourceId o = (ScimResourceId) other; // TODO - return (o.id == id && - o.realmId == realmId && - o.componentId == componentId && - o.type == type && - o.externalId == externalId); + return (StringUtils.equals(o.id, id) && + StringUtils.equals(o.realmId, realmId) && + StringUtils.equals(o.componentId, componentId) && + StringUtils.equals(o.type, type) && + StringUtils.equals(o.externalId, externalId)); } @Override -- GitLab From c008818c773d9f3de21d7f33b6181128084c9339 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 16:09:59 +0200 Subject: [PATCH 12/58] Type constant --- .../java/sh/libre/scim/storage/ScimStorageProviderFactory.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index 61eae54..f805cd6 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -22,6 +22,7 @@ import sh.libre.scim.core.GroupAdapter; import sh.libre.scim.core.ScimDispatcher; import sh.libre.scim.core.UserAdapter; +import java.time.Duration; import java.util.Date; import java.util.List; @@ -176,6 +177,6 @@ public class ScimStorageProviderFactory }); } - }, 30000, "scim-background"); + }, Duration.ofSeconds(30).toMillis(), "scim-background"); } } -- GitLab From 84f1af036dba1125220ef640383d2fb93921c456 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 16:11:37 +0200 Subject: [PATCH 13/58] Clean imports --- src/main/java/sh/libre/scim/core/GroupAdapter.java | 1 - src/main/java/sh/libre/scim/core/ScimDispatcher.java | 1 - src/main/java/sh/libre/scim/core/UserAdapter.java | 1 - .../java/sh/libre/scim/event/ScimEventListenerProvider.java | 2 -- 4 files changed, 5 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index 31b25b5..6da5c07 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -5,7 +5,6 @@ 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.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 4b2beed..98437c1 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -1,6 +1,5 @@ package sh.libre.scim.core; -import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index ca0abf8..3546751 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -18,7 +18,6 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.stream.Stream; diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index f490dd5..0180de0 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -1,13 +1,11 @@ package sh.libre.scim.event; import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; 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; -- GitLab From b6d3c20fe17b2ad57491c64ba97db1ed3f4a31e2 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 16:40:47 +0200 Subject: [PATCH 14/58] Split constructor code from factory code of ScimClient --- .../java/sh/libre/scim/core/ScimClient.java | 98 +++++++++---------- .../sh/libre/scim/core/ScimDispatcher.java | 2 +- 2 files changed, 49 insertions(+), 51 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 99f992b..bb3655a 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -23,74 +23,72 @@ 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.Map; public class ScimClient { - protected final Logger LOGGER = Logger.getLogger(ScimClient.class); - protected final ScimRequestBuilder scimRequestBuilder; - protected final RetryRegistry registry; - protected final KeycloakSession session; - protected final String contentType; - protected final ComponentModel model; - protected final String scimApplicationBaseUrl; - protected final Map defaultHeaders; - protected final Map expectedResponseHeaders; - - public ScimClient(ComponentModel model, KeycloakSession session) { - this.model = model; - this.contentType = model.get("content-type"); - this.session = session; - this.scimApplicationBaseUrl = model.get("endpoint"); - this.defaultHeaders = new HashMap<>(); - this.expectedResponseHeaders = new HashMap<>(); - - switch (model.get("auth-mode")) { - case "BEARER": - defaultHeaders.put(HttpHeaders.AUTHORIZATION, - BearerAuthentication()); - break; - case "BASIC_AUTH": - defaultHeaders.put(HttpHeaders.AUTHORIZATION, - BasicAuthentication()); - break; - } - defaultHeaders.put(HttpHeaders.CONTENT_TYPE, contentType); + private static final Logger LOGGER = Logger.getLogger(ScimClient.class); - scimRequestBuilder = new ScimRequestBuilder(scimApplicationBaseUrl, genScimClientConfig()); + private final ScimRequestBuilder scimRequestBuilder; - RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(10) - .intervalFunction(IntervalFunction.ofExponentialBackoff()) - .retryExceptions(ProcessingException.class) - .build(); + private final RetryRegistry registry; - registry = RetryRegistry.of(retryConfig); - } + private final KeycloakSession session; - protected String BasicAuthentication() { - return BasicAuth.builder() - .username(model.get("auth-user")) - .password(model.get("auth-pass")) - .build() - .getAuthorizationHeaderValue(); + 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; } - protected ScimClientConfig genScimClientConfig() { - return ScimClientConfig.builder() - .httpHeaders(defaultHeaders) + 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")) + .build(); + yield basicAuth.getAuthorizationHeaderValue(); + } + default -> throw new IllegalArgumentException("authMode " + authMode + " is not supported"); + }; + + Map httpHeaders = new HashMap<>(); + httpHeaders.put(HttpHeaders.AUTHORIZATION, authorizationHeaderValue); + httpHeaders.put(HttpHeaders.CONTENT_TYPE, model.get("content-type")); + + ScimClientConfig scimClientConfig = ScimClientConfig.builder() + .httpHeaders(httpHeaders) .connectTimeout(5) .requestTimeout(5) .socketTimeout(5) - .expectedHttpResponseHeaders(expectedResponseHeaders) + .expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful? .hostnameVerifier((s, sslSession) -> true) .build(); - } - protected String BearerAuthentication() { - return "Bearer " + model.get("auth-pass"); + 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); } protected EntityManager getEM() { diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 98437c1..bf7757d 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -33,7 +33,7 @@ public class ScimDispatcher { public void runOne(ComponentModel m, Consumer f) { LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType()); - ScimClient client = new ScimClient(m, session); + ScimClient client = ScimClient.newScimClient(m, session); try { f.accept(client); } catch (Exception e) { -- GitLab From 2ed6f1a16dcecabfa870ef6aaa687edacc2475a4 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 17:00:22 +0200 Subject: [PATCH 15/58] Improve some method names --- src/main/java/sh/libre/scim/core/Adapter.java | 4 ++-- .../java/sh/libre/scim/core/GroupAdapter.java | 2 +- .../java/sh/libre/scim/core/ScimClient.java | 18 +++++++++--------- .../java/sh/libre/scim/core/UserAdapter.java | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java index 01c2ab9..e976212 100644 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ b/src/main/java/sh/libre/scim/core/Adapter.java @@ -57,7 +57,7 @@ public abstract class Adapter } } - public String getSCIMEndpoint() { + public String getScimEndpoint() { return "/" + type + "s"; } @@ -117,7 +117,7 @@ public abstract class Adapter public abstract Class getResourceClass(); - public abstract S toSCIM(); + public abstract S toScim(); public abstract Boolean entityExists(); diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index 6da5c07..ef6609e 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -75,7 +75,7 @@ public class GroupAdapter extends Adapter { } @Override - public Group toSCIM() { + public Group toScim() { Group group = new Group(); group.setId(externalId); group.setExternalId(id); diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index bb3655a..33b98c4 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -91,7 +91,7 @@ public class ScimClient { return new ScimClient(scimRequestBuilder, retryRegistry, session, model); } - protected EntityManager getEM() { + protected EntityManager getEntityManager() { return session.getProvider(JpaConnectionProvider.class).getEntityManager(); } @@ -124,8 +124,8 @@ public class ScimClient { ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder - .create(adapter.getResourceClass(), adapter.getSCIMEndpoint()) - .setResource(adapter.toSCIM()) + .create(adapter.getResourceClass(), adapter.getScimEndpoint()) + .setResource(adapter.toScim()) .sendRequest(); } catch (ResponseException e) { throw new RuntimeException(e); @@ -154,8 +154,8 @@ public class ScimClient { ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder - .update(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId()) - .setResource(adapter.toSCIM()) + .update(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) + .setResource(adapter.toScim()) .sendRequest(); } catch (ResponseException e) { throw new RuntimeException(e); @@ -185,7 +185,7 @@ public class ScimClient { ServerResponse response = retry.executeSupplier(() -> { try { - return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(), adapter.getExternalId()) + return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) .sendRequest(); } catch (ResponseException e) { throw new RuntimeException(e); @@ -197,7 +197,7 @@ public class ScimClient { LOGGER.warn(response.getHttpStatus()); } - getEM().remove(resource); + getEntityManager().remove(resource); } catch (NoResultException e) { LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id); @@ -232,7 +232,7 @@ public class ScimClient { LOGGER.info("Import"); try { A adapter = getAdapter(aClass); - ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getSCIMEndpoint()).get().sendRequest(); + ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); ListResponse resourceTypeListResponse = response.getResource(); for (S resource : resourceTypeListResponse.getListedResources()) { @@ -272,7 +272,7 @@ public class ScimClient { case "DELETE_REMOTE": LOGGER.info("Delete remote resource"); scimRequestBuilder - .delete(adapter.getResourceClass(), adapter.getSCIMEndpoint(), resource.getId().get()) + .delete(adapter.getResourceClass(), adapter.getScimEndpoint(), resource.getId().get()) .sendRequest(); syncRes.increaseRemoved(); break; diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index 3546751..d4bd9ac 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -145,7 +145,7 @@ public class UserAdapter extends Adapter { } @Override - public User toSCIM() { + public User toScim() { User user = new User(); user.setExternalId(id); user.setUserName(username); -- GitLab From f2464bbc004872e00899af5ed7de3042aca4f899 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Wed, 12 Jun 2024 17:40:01 +0200 Subject: [PATCH 16/58] Rename variable --- .../java/sh/libre/scim/core/ScimClient.java | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 33b98c4..84afe5d 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -99,19 +99,19 @@ public class ScimClient { return session.getContext().getRealm().getId(); } - protected > A getAdapter( - Class aClass) { + protected > A newAdapter( + Class adapterClass) { try { - return aClass.getDeclaredConstructor(KeycloakSession.class, String.class) + return adapterClass.getDeclaredConstructor(KeycloakSession.class, String.class) .newInstance(session, this.model.getId()); } catch (Exception e) { throw new RuntimeException(e); } } - public > void create(Class aClass, + public > void create(Class adapterClass, M kcModel) { - A adapter = getAdapter(aClass); + A adapter = newAdapter(adapterClass); adapter.apply(kcModel); if (adapter.skip) return; @@ -141,9 +141,9 @@ public class ScimClient { adapter.saveMapping(); } - public > void replace(Class aClass, + public > void replace(Class adapterClass, M kcModel) { - A adapter = getAdapter(aClass); + A adapter = newAdapter(adapterClass); try { adapter.apply(kcModel); if (adapter.skip) @@ -172,9 +172,9 @@ public class ScimClient { } } - public > void delete(Class aClass, + public > void delete(Class adapterClass, String id) { - A adapter = getAdapter(aClass); + A adapter = newAdapter(adapterClass); adapter.setId(id); try { @@ -205,21 +205,21 @@ public class ScimClient { } public > void refreshResources( - Class aClass, + Class adapterClass, SynchronizationResult syncRes) { LOGGER.info("Refresh resources"); - getAdapter(aClass).getResourceStream().forEach(resource -> { - A adapter = getAdapter(aClass); + 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(aClass, resource); + this.create(adapterClass, resource); } else { LOGGER.info("Replacing it"); - this.replace(aClass, resource); + this.replace(adapterClass, resource); } syncRes.increaseUpdated(); } @@ -228,17 +228,17 @@ public class ScimClient { } public > void importResources( - Class aClass, SynchronizationResult syncRes) { + Class adapterClass, SynchronizationResult syncRes) { LOGGER.info("Import"); try { - A adapter = getAdapter(aClass); + A adapter = newAdapter(adapterClass); ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); ListResponse resourceTypeListResponse = response.getResource(); for (S resource : resourceTypeListResponse.getListedResources()) { try { LOGGER.infof("Reconciling remote resource %s", resource); - adapter = getAdapter(aClass); + adapter = newAdapter(adapterClass); adapter.apply(resource); ScimResource mapping = adapter.getMapping(); -- GitLab From 10ff5787f0655280ac2471b5f40858eeb4328a53 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Mon, 17 Jun 2024 10:11:16 +0200 Subject: [PATCH 17/58] Check emptyness not size --- src/main/java/sh/libre/scim/core/GroupAdapter.java | 5 +++-- src/main/java/sh/libre/scim/core/ScimClient.java | 2 +- src/main/java/sh/libre/scim/core/UserAdapter.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index ef6609e..375b643 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -4,6 +4,7 @@ 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; @@ -60,7 +61,7 @@ public class GroupAdapter extends Adapter { setExternalId(group.getId().get()); setDisplayName(group.getDisplayName().get()); List groupMembers = group.getMembers(); - if (groupMembers != null && groupMembers.size() > 0) { + if (CollectionUtils.isNotEmpty(groupMembers)) { this.members = new HashSet<>(); for (Member groupMember : groupMembers) { try { @@ -80,7 +81,7 @@ public class GroupAdapter extends Adapter { group.setId(externalId); group.setExternalId(id); group.setDisplayName(displayName); - if (members.size() > 0) { + if (!members.isEmpty()) { for (String member : members) { Member groupMember = new Member(); try { diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 84afe5d..4782b1e 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -116,7 +116,7 @@ public class ScimClient { if (adapter.skip) return; // If mapping exist then it was created by import so skip. - if (adapter.query("findById", adapter.getId()).getResultList().size() != 0) { + if (!adapter.query("findById", adapter.getId()).getResultList().isEmpty()) { return; } Retry retry = registry.retry("create-" + adapter.getId()); diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index d4bd9ac..1de86b3 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -139,7 +139,7 @@ public class UserAdapter extends Adapter { setUsername(user.getUserName().get()); setDisplayName(user.getDisplayName().get()); setActive(user.isActive().get()); - if (user.getEmails().size() > 0) { + if (!user.getEmails().isEmpty()) { setEmail(user.getEmails().get(0).getValue().get()); } } -- GitLab From db3de0d933b19819d60cc73a8730e9944ac32395 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Mon, 17 Jun 2024 10:34:45 +0200 Subject: [PATCH 18/58] Use try with resource --- src/main/java/sh/libre/scim/core/ScimClient.java | 3 ++- src/main/java/sh/libre/scim/core/ScimDispatcher.java | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 4782b1e..5831bf3 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -28,7 +28,7 @@ import java.util.HashMap; import java.util.Map; -public class ScimClient { +public class ScimClient implements AutoCloseable { private static final Logger LOGGER = Logger.getLogger(ScimClient.class); @@ -299,6 +299,7 @@ public class ScimClient { } } + @Override public void close() { scimRequestBuilder.close(); } diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index bf7757d..df4e220 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -33,13 +33,10 @@ public class ScimDispatcher { public void runOne(ComponentModel m, Consumer f) { LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType()); - ScimClient client = ScimClient.newScimClient(m, session); - try { + try (ScimClient client = ScimClient.newScimClient(m, session)) { f.accept(client); } catch (Exception e) { LOGGER.error(e); - } finally { - client.close(); } } } -- GitLab From 676cddc0d901f06ca31afadb7b5acb2cb3576828 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Mon, 17 Jun 2024 10:45:31 +0200 Subject: [PATCH 19/58] Move field init to initializer, does not depend on constructor parameters --- .../libre/scim/event/ScimEventListenerProvider.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 0180de0..a36dc57 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -27,15 +27,16 @@ public class ScimEventListenerProvider implements EventListenerProvider { private final KeycloakSession session; - private final Map patterns = new HashMap<>(); + private final Map patterns = Map.of( + ResourceType.USER, Pattern.compile("users/(.+)"), + ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"), + ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)"), + ResourceType.REALM_ROLE_MAPPING, Pattern.compile("^(.+)/(.+)/role-mappings") + ); public ScimEventListenerProvider(KeycloakSession session) { this.session = session; dispatcher = new ScimDispatcher(session); - patterns.put(ResourceType.USER, Pattern.compile("users/(.+)")); - patterns.put(ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?")); - patterns.put(ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)")); - patterns.put(ResourceType.REALM_ROLE_MAPPING, Pattern.compile("^(.+)/(.+)/role-mappings")); } @Override -- GitLab From 04f011b5bfa3c15b0d4ba84b7003c0b83f6fe72c Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Mon, 17 Jun 2024 14:38:42 +0200 Subject: [PATCH 20/58] Remove pointless if --- .../java/sh/libre/scim/core/GroupAdapter.java | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java index 375b643..b87bf30 100644 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ b/src/main/java/sh/libre/scim/core/GroupAdapter.java @@ -81,22 +81,20 @@ public class GroupAdapter extends Adapter { group.setId(externalId); group.setExternalId(id); group.setDisplayName(displayName); - if (!members.isEmpty()) { - 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"); - } + 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(); -- GitLab From 154bb3498071d7a2db9116ed3c2dc94f14a7174b Mon Sep 17 00:00:00 2001 From: Alex Morel <2834-amorel@users.noreply.gitlab.nuiton.org> Date: Mon, 17 Jun 2024 12:56:09 +0000 Subject: [PATCH 21/58] Update .gitlab-ci.yml to wire sonar --- .gitlab-ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b7932a6..281c4c6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,20 @@ +include: + - project: men/action24/ci-templates + file: sonarqube.yml + +variables: + A24_SONAR_PROJECT_KEY: com.amorel.scimkeycloak + SONAR_HOST_URL: https://qa.codelutin.com + +stages: + - package + - test + - sonar + package: image: name: gradle:jdk11 + stage: package script: - gradle jar shadowjar - gradle -b legacy-build.gradle shadowjar -- GitLab From 3ef6c81e981654fd2c706f6c0388dd71088aff64 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Mon, 17 Jun 2024 15:27:14 +0200 Subject: [PATCH 22/58] Wire local sonar and extract method for findById --- build.gradle | 1 + src/main/java/sh/libre/scim/core/Adapter.java | 6 ++++++ src/main/java/sh/libre/scim/core/ScimClient.java | 11 ++++++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index ce8c838..34a263c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ plugins { id 'java' id 'com.github.johnrengelman.shadow' version '8.1.1' + id "org.sonarqube" version "5.0.0.4638" } group = 'sh.libre.scim' diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java index e976212..607a7e1 100644 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ b/src/main/java/sh/libre/scim/core/Adapter.java @@ -13,6 +13,12 @@ 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 The Keycloack {@link RoleMapperModel} (e.g. GroupModel, UserModel) + * @param the SCIM {@link ResourceNode} (e.g. Group, User) + */ public abstract class Adapter { protected final Logger logger; diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 5831bf3..071251b 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -14,6 +14,7 @@ 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; @@ -91,6 +92,10 @@ public class ScimClient implements AutoCloseable { return new ScimClient(scimRequestBuilder, retryRegistry, session, model); } + private static > TypedQuery findById(A adapter) { + return adapter.query("findById", adapter.getId()); + } + protected EntityManager getEntityManager() { return session.getProvider(JpaConnectionProvider.class).getEntityManager(); } @@ -116,7 +121,7 @@ public class ScimClient implements AutoCloseable { if (adapter.skip) return; // If mapping exist then it was created by import so skip. - if (!adapter.query("findById", adapter.getId()).getResultList().isEmpty()) { + if (!findById(adapter).getResultList().isEmpty()) { return; } Retry retry = registry.retry("create-" + adapter.getId()); @@ -148,7 +153,7 @@ public class ScimClient implements AutoCloseable { adapter.apply(kcModel); if (adapter.skip) return; - ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult(); + ScimResource resource = findById(adapter).getSingleResult(); adapter.apply(resource); Retry retry = registry.retry("replace-" + adapter.getId()); ServerResponse response = retry.executeSupplier(() -> { @@ -178,7 +183,7 @@ public class ScimClient implements AutoCloseable { adapter.setId(id); try { - ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult(); + ScimResource resource = findById(adapter).getSingleResult(); adapter.apply(resource); Retry retry = registry.retry("delete-" + id); -- GitLab From 9a45e9c30f51fc388564d32a73962548390a7345 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Mon, 17 Jun 2024 16:35:12 +0200 Subject: [PATCH 23/58] Simplfy code by splitting UserScimClient and GroupSclient - basic UserScimClient --- .../scim/core/ScrimProviderConfiguration.java | 77 +++++ .../sh/libre/scim/core/UserScimClient.java | 279 ++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java create mode 100644 src/main/java/sh/libre/scim/core/UserScimClient.java diff --git a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java new file mode 100644 index 0000000..c689385 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java @@ -0,0 +1,77 @@ +package sh.libre.scim.core; + +import de.captaingoldfish.scim.sdk.client.http.BasicAuth; +import org.keycloak.component.ComponentModel; + +public class ScrimProviderConfiguration { + + private final String endPoint; + private final String id; + private final String contentType; + private final String authorizationHeaderValue; + private final ImportAction importAction; + private final boolean syncImport; + private final boolean syncRefresh; + + public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) { + AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get("auth-mode")); + authorizationHeaderValue = switch (authMode) { + case BEARER -> "Bearer " + scimProviderConfiguration.get("auth-pass"); + case BASIC_AUTH -> { + BasicAuth basicAuth = BasicAuth.builder() + .username(scimProviderConfiguration.get("auth-user")) + .password(scimProviderConfiguration.get("auth-pass")) + .build(); + yield basicAuth.getAuthorizationHeaderValue(); + } + default -> + throw new IllegalArgumentException("authMode " + scimProviderConfiguration + " is not supported"); + }; + contentType = scimProviderConfiguration.get("content-type"); + endPoint = scimProviderConfiguration.get("endpoint"); + id = scimProviderConfiguration.getId(); + importAction = ImportAction.valueOf(scimProviderConfiguration.get("sync-import-action")); + syncImport = scimProviderConfiguration.get("sync-import", false); + syncRefresh = scimProviderConfiguration.get("sync-refresh", false); + } + + public boolean isSyncRefresh() { + return syncRefresh; + } + + public boolean isSyncImport() { + return syncImport; + } + + public String getContentType() { + return contentType; + } + + public String getAuthorizationHeaderValue() { + return authorizationHeaderValue; + } + + public String getId() { + return id; + } + + public ImportAction getImportAction() { + return importAction; + } + + public String getEndPoint() { + return endPoint; + } + + public enum AuthMode { + BEARER, BASIC_AUTH, NONE + } + + public enum EndpointContentType { + JSON, SCIM_JSON + } + + public enum ImportAction { + CREATE_LOCAL, DELETE_REMOTE, NOTHING + } +} diff --git a/src/main/java/sh/libre/scim/core/UserScimClient.java b/src/main/java/sh/libre/scim/core/UserScimClient.java new file mode 100644 index 0000000..789d852 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/UserScimClient.java @@ -0,0 +1,279 @@ +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 AutoCloseable { + + 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 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); + } + + 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 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(); + } + + 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 response = retry.executeSupplier(() -> { + try { + return scimRequestBuilder + .update(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) + .setResource(adapter.toScim()) + .sendRequest(); + } catch (ResponseException e) { + throw new RuntimeException(e); + } + }); + if (!response.isSuccess()) { + LOGGER.warn(response.getResponseBody()); + LOGGER.warn(response.getHttpStatus()); + } + } catch (NoResultException e) { + LOGGER.warnf("failed to replace resource %s, scim mapping not found", adapter.getId()); + } catch (Exception e) { + LOGGER.error(e); + } + } + + public 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 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> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); + ListResponse 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); + } + } + + public void sync(SynchronizationResult syncRes) { + if (this.scimProviderConfiguration.isSyncImport()) { + this.importResources(syncRes); + } + if (this.scimProviderConfiguration.isSyncRefresh()) { + this.refreshResources(syncRes); + } + } + + + @Override + public void close() { + scimRequestBuilder.close(); + } +} -- GitLab From 5976031ac6824576b8502040093b4f673d5e1157 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 18 Jun 2024 15:34:31 +0200 Subject: [PATCH 24/58] ScimDispatcher simplification : split User and Group client class to simplify usage --- .../sh/libre/scim/core/GroupScimClient.java | 37 +++++++++++++ .../libre/scim/core/ScimClientInterface.java | 42 ++++++++++++++ .../sh/libre/scim/core/ScimDispatcher.java | 55 +++++++++++++------ .../sh/libre/scim/core/UserScimClient.java | 6 +- .../scim/event/ScimEventListenerProvider.java | 32 +++++------ .../storage/ScimStorageProviderFactory.java | 7 +-- 6 files changed, 139 insertions(+), 40 deletions(-) create mode 100644 src/main/java/sh/libre/scim/core/GroupScimClient.java create mode 100644 src/main/java/sh/libre/scim/core/ScimClientInterface.java diff --git a/src/main/java/sh/libre/scim/core/GroupScimClient.java b/src/main/java/sh/libre/scim/core/GroupScimClient.java new file mode 100644 index 0000000..1483d3b --- /dev/null +++ b/src/main/java/sh/libre/scim/core/GroupScimClient.java @@ -0,0 +1,37 @@ +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 { + 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 void close() throws Exception { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java new file mode 100644 index 0000000..1affb82 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimClientInterface.java @@ -0,0 +1,42 @@ +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 server defined in a {@link sh.libre.scim.storage.ScimStorageProvider}. + * + * @param the keycloack model to synchronize (e.g. UserModel or GroupModel) + */ +public interface ScimClientInterface extends AutoCloseable { + + /** + * Propagates the creation of the given keycloack model to a SCIM server. + * + * @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 server. + * + * @param resource the resource creation to propagate (e.g. a UserModel) + */ + void replace(M resource); + + /** + * Propagates the deletion of an element to a SCIM server. + * + * @param id the deleted resource's id to propagate (e.g. id of a UserModel) + */ + void delete(String id); + + /** + * Synchronizes resources between SCIM server and keycloack, according to configuration. + * + * @param result the synchronization result to update for indicating triggered operations (e.g. user deletions) + */ + void sync(SynchronizationResult result); +} diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index df4e220..dcbe886 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -6,13 +6,13 @@ import org.keycloak.models.KeycloakSession; import sh.libre.scim.storage.ScimStorageProviderFactory; import java.util.function.Consumer; +import java.util.stream.Stream; +/** + * In charge of sending SCIM Request to all registered SCIM servers. + */ public class ScimDispatcher { - public static final String SCOPE_USER = "user"; - - public static final String SCOPE_GROUP = "group"; - private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); private final KeycloakSession session; @@ -21,22 +21,45 @@ public class ScimDispatcher { this.session = session; } - public void run(String scope, Consumer f) { - session.getContext().getRealm().getComponentsStream() - .filter((m) -> { - return ScimStorageProviderFactory.ID.equals(m.getProviderId()) - && m.get("enabled", true) - && m.get("propagation-" + scope, false); - }) - .forEach(m -> runOne(m, f)); + public void dispatchUserModificationToAll(Consumer operationToDispatch) { + getAllSCIMServer(Scope.USER).forEach(scimServer -> dispatchUserModificationToOne(scimServer, operationToDispatch)); } - public void runOne(ComponentModel m, Consumer f) { - LOGGER.infof("%s %s %s %s", m.getId(), m.getName(), m.getProviderId(), m.getProviderType()); - try (ScimClient client = ScimClient.newScimClient(m, session)) { - f.accept(client); + public void dispatchUserModificationToOne(ComponentModel scimServer, Consumer operationToDispatch) { + LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType()); + try (UserScimClient client = UserScimClient.newUserScimClient(scimServer, session)) { + operationToDispatch.accept(client); } catch (Exception e) { LOGGER.error(e); } } + + public void dispatchGroupModificationToAll(Consumer operationToDispatch) { + getAllSCIMServer(Scope.GROUP).forEach(scimServer -> dispatchGroupModificationToOne(scimServer, operationToDispatch)); + } + + public void dispatchGroupModificationToOne(ComponentModel scimServer, Consumer operationToDispatch) { + LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType()); + try (GroupScimClient client = GroupScimClient.newGroupScimClient(scimServer, session)) { + operationToDispatch.accept(client); + } catch (Exception e) { + LOGGER.error(e); + } + } + + /** + * @param scope The {@link Scope} to consider (User or Group) + * @return all enabled registered SCIM Servers with propagation enabled for the given scope + */ + private Stream getAllSCIMServer(Scope scope) { + // TODO : we could initiative this list once and invalidate it when configuration changes + return session.getContext().getRealm().getComponentsStream() + .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId()) + && m.get("enabled", true) + && m.get("propagation-" + scope.name(), false)); + } + + public enum Scope { + USER, GROUP + } } diff --git a/src/main/java/sh/libre/scim/core/UserScimClient.java b/src/main/java/sh/libre/scim/core/UserScimClient.java index 789d852..4f257a0 100644 --- a/src/main/java/sh/libre/scim/core/UserScimClient.java +++ b/src/main/java/sh/libre/scim/core/UserScimClient.java @@ -26,7 +26,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; -public class UserScimClient implements AutoCloseable { +public class UserScimClient implements ScimClientInterface { private static final Logger LOGGER = Logger.getLogger(UserScimClient.class); @@ -86,6 +86,7 @@ public class UserScimClient implements AutoCloseable { 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); @@ -116,6 +117,7 @@ public class UserScimClient implements AutoCloseable { adapter.saveMapping(); } + @Override public void replace(UserModel userModel) { UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); try { @@ -146,6 +148,7 @@ public class UserScimClient implements AutoCloseable { } } + @Override public void delete(String id) { UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId()); adapter.setId(id); @@ -262,6 +265,7 @@ public class UserScimClient implements AutoCloseable { } } + @Override public void sync(SynchronizationResult syncRes) { if (this.scimProviderConfiguration.isSyncImport()) { this.importResources(syncRes); diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index a36dc57..737e081 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -10,11 +10,8 @@ import org.keycloak.events.admin.ResourceType; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; -import sh.libre.scim.core.GroupAdapter; import sh.libre.scim.core.ScimDispatcher; -import sh.libre.scim.core.UserAdapter; -import java.util.HashMap; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,14 +47,13 @@ public class ScimEventListenerProvider implements EventListenerProvider { switch (eventType) { case REGISTER -> { UserModel user = getUser(eventUserId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.create(user)); } case UPDATE_EMAIL, UPDATE_PROFILE -> { UserModel user = getUser(eventUserId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } - case DELETE_ACCOUNT -> - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, eventUserId)); + case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId)); default -> LOGGER.trace("ignore event " + eventType); } } @@ -77,17 +73,16 @@ public class ScimEventListenerProvider implements EventListenerProvider { switch (event.getOperationType()) { case CREATE -> { UserModel user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.create(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.create(user)); user.getGroupsStream().forEach(group -> { - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); + dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); }); } case UPDATE -> { UserModel user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } - case DELETE -> - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.delete(UserAdapter.class, userId)); + case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId)); } } case GROUP -> { @@ -96,14 +91,13 @@ public class ScimEventListenerProvider implements EventListenerProvider { switch (event.getOperationType()) { case CREATE -> { GroupModel group = getGroup(groupId); - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.create(GroupAdapter.class, group)); + dispatcher.dispatchGroupModificationToAll(client -> client.create(group)); } case UPDATE -> { GroupModel group = getGroup(groupId); - dispatcher.run(ScimDispatcher.SCOPE_GROUP, (client) -> client.replace(GroupAdapter.class, group)); + dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); } - case DELETE -> dispatcher.run(ScimDispatcher.SCOPE_GROUP, - (client) -> client.delete(GroupAdapter.class, groupId)); + case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId)); } } case GROUP_MEMBERSHIP -> { @@ -113,7 +107,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { GroupModel group = getGroup(groupId); group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); UserModel user = getUser(userId); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } case REALM_ROLE_MAPPING -> { String type = matcher.group(1); @@ -122,12 +116,12 @@ public class ScimEventListenerProvider implements EventListenerProvider { switch (type) { case "users" -> { UserModel user = getUser(id); - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } case "groups" -> { GroupModel group = getGroup(id); session.users().getGroupMembersStream(session.getContext().getRealm(), group).forEach(user -> { - dispatcher.run(ScimDispatcher.SCOPE_USER, (client) -> client.replace(UserAdapter.class, user)); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); }); } } diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index f805cd6..e5f4b36 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -137,10 +137,10 @@ public class ScimStorageProviderFactory session.getContext().setRealm(realm); ScimDispatcher dispatcher = new ScimDispatcher(session); if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { - dispatcher.runOne(model, (client) -> client.sync(UserAdapter.class, result)); + dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); } if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { - dispatcher.runOne(model, (client) -> client.sync(GroupAdapter.class, result)); + dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); } } @@ -169,8 +169,7 @@ public class ScimStorageProviderFactory for (GroupModel group : session.groups().getGroupsStream(realm) .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) { LOGGER.debug(group.getName() + " is dirty"); - dispatcher.run(ScimDispatcher.SCOPE_GROUP, - (client) -> client.replace(GroupAdapter.class, group)); + dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); group.removeAttribute("scim-dirty"); } } -- GitLab From 764767185e5682b630214ab68216c582baa41abf Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 18 Jun 2024 16:07:54 +0200 Subject: [PATCH 25/58] Simply ScimEventListener code --- .../scim/event/ScimEventListenerProvider.java | 150 ++++++++++++------ .../ScimEventListenerProviderFactory.java | 12 +- 2 files changed, 109 insertions(+), 53 deletions(-) diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 737e081..ef68c55 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -16,6 +16,11 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * An {@link java.util.EventListener} in charge of reaction to Keycloak models + * modification (e.g. User creation, Group deletion, membership modifications...) + * by propagating it to all registered SCIM servers. + */ public class ScimEventListenerProvider implements EventListenerProvider { private static final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class); @@ -36,14 +41,13 @@ public class ScimEventListenerProvider implements EventListenerProvider { dispatcher = new ScimDispatcher(session); } - @Override - public void close() { - } @Override public void onEvent(Event event) { + // React to User-related event : creation, deletion, update EventType eventType = event.getType(); String eventUserId = event.getUserId(); + LOGGER.infof("[SCIM] Propagate User Event %s - %s", eventType, eventUserId); switch (eventType) { case REGISTER -> { UserModel user = getUser(eventUserId); @@ -54,81 +58,123 @@ public class ScimEventListenerProvider implements EventListenerProvider { dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId)); - default -> LOGGER.trace("ignore event " + eventType); + default -> { + // No other event has to be propagated to SCIM Servers + } } } @Override public void onEvent(AdminEvent event, boolean includeRepresentation) { + // Step 1: check if event is relevant for propagation through SCIM Pattern pattern = patterns.get(event.getResourceType()); if (pattern == null) return; Matcher matcher = pattern.matcher(event.getResourcePath()); if (!matcher.find()) return; + + // Step 2: propagate event (if needed) according to its resource type switch (event.getResourceType()) { case USER -> { String userId = matcher.group(1); - LOGGER.infof("%s %s", userId, event.getOperationType()); - switch (event.getOperationType()) { - case CREATE -> { - UserModel user = getUser(userId); - dispatcher.dispatchUserModificationToAll(client -> client.create(user)); - user.getGroupsStream().forEach(group -> { - dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); - }); - } - case UPDATE -> { - UserModel user = getUser(userId); - dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); - } - case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId)); - } + handleUserEvent(event, userId); } case GROUP -> { String groupId = matcher.group(1); - LOGGER.infof("group %s %s", groupId, event.getOperationType()); - switch (event.getOperationType()) { - case CREATE -> { - GroupModel group = getGroup(groupId); - dispatcher.dispatchGroupModificationToAll(client -> client.create(group)); - } - case UPDATE -> { - GroupModel group = getGroup(groupId); - dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); - } - case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId)); - } + handleGroupEvent(event, groupId); } case GROUP_MEMBERSHIP -> { String userId = matcher.group(1); String groupId = matcher.group(2); - LOGGER.infof("%s %s from %s", event.getOperationType(), userId, groupId); - GroupModel group = getGroup(groupId); - group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); - UserModel user = getUser(userId); - dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); + handleGroupMemberShipEvent(event, userId, groupId); } case REALM_ROLE_MAPPING -> { String type = matcher.group(1); String id = matcher.group(2); - LOGGER.infof("%s %s %s roles", event.getOperationType(), type, id); - switch (type) { - case "users" -> { - UserModel user = getUser(id); - dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); - } - case "groups" -> { - GroupModel group = getGroup(id); - session.users().getGroupMembersStream(session.getContext().getRealm(), group).forEach(user -> { - dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); - }); - } - } + handleRoleMappingEvent(event, type, id); + } + default -> { + // No other resource modification has to be propagated to SCIM Servers + } + } + } + + private void handleUserEvent(AdminEvent userEvent, String userId) { + LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId); + switch (userEvent.getOperationType()) { + case CREATE -> { + UserModel user = getUser(userId); + dispatcher.dispatchUserModificationToAll(client -> client.create(user)); + user.getGroupsStream().forEach(group -> + dispatcher.dispatchGroupModificationToAll(client -> client.replace(group) + )); + } + case UPDATE -> { + UserModel user = getUser(userId); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); + } + case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId)); + default -> { + // ACTION userEvent are not relevant, nothing to do + } + } + } + + /** + * Propagating the given group-related event to SCIM servers. + * + * @param event the event to propagate + * @param groupId event target's id + */ + private void handleGroupEvent(AdminEvent event, String groupId) { + LOGGER.infof("[SCIM] Propagate Group %s - %s", event.getOperationType(), groupId); + switch (event.getOperationType()) { + case CREATE -> { + GroupModel group = getGroup(groupId); + dispatcher.dispatchGroupModificationToAll(client -> client.create(group)); + } + case UPDATE -> { + GroupModel group = getGroup(groupId); + dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); + } + case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId)); + default -> { + // ACTION event are not relevant, nothing to do } } } + private void handleGroupMemberShipEvent(AdminEvent groupMemberShipEvent, String userId, String groupId) { + LOGGER.infof("[SCIM] Propagate GroupMemberShip %s - User %s Group %s", groupMemberShipEvent.getOperationType(), userId, groupId); + GroupModel group = getGroup(groupId); + group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); + UserModel user = getUser(userId); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); + } + + private void handleRoleMappingEvent(AdminEvent roleMappingEvent, String type, String id) { + LOGGER.infof("[SCIM] Propagate RoleMapping %s - %s %s", roleMappingEvent.getOperationType(), type, id); + switch (type) { + case "users" -> { + UserModel user = getUser(id); + dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); + } + case "groups" -> { + GroupModel group = getGroup(id); + session.users() + .getGroupMembersStream(session.getContext().getRealm(), group) + .forEach(user -> + dispatcher.dispatchUserModificationToAll(client -> client.replace(user) + )); + } + default -> { + // No other type is relevant for propagation + } + } + } + + private UserModel getUser(String id) { return session.users().getUserById(session.getContext().getRealm(), id); } @@ -136,4 +182,10 @@ public class ScimEventListenerProvider implements EventListenerProvider { private GroupModel getGroup(String id) { return session.groups().getGroupById(session.getContext().getRealm(), id); } + + @Override + public void close() { + // Nothing to close here + } + } diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java index debe59b..c7b437a 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java @@ -13,20 +13,24 @@ public class ScimEventListenerProviderFactory implements EventListenerProviderFa return new ScimEventListenerProvider(session); } + @Override + public String getId() { + return "scim"; + } + @Override public void init(Scope config) { + // Nothing to initialize } @Override public void postInit(KeycloakSessionFactory factory) { + // Nothing to initialize } @Override public void close() { + // Nothing to close } - @Override - public String getId() { - return "scim"; - } } -- GitLab From f00130d37af527c2ac0c717f25805de8b8eb0f6b Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 18 Jun 2024 16:49:24 +0200 Subject: [PATCH 26/58] Simplify ScimStorageProvider --- .../libre/scim/core/ScimClientInterface.java | 10 +- .../sh/libre/scim/core/ScimDispatcher.java | 27 +-- .../scim/core/ScrimProviderConfiguration.java | 33 ++-- .../scim/event/ScimEventListenerProvider.java | 8 +- .../scim/storage/ScimStorageProvider.java | 9 - .../storage/ScimStorageProviderFactory.java | 164 +++++++++--------- 6 files changed, 125 insertions(+), 126 deletions(-) delete mode 100644 src/main/java/sh/libre/scim/storage/ScimStorageProvider.java diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java index 1affb82..d28f289 100644 --- a/src/main/java/sh/libre/scim/core/ScimClientInterface.java +++ b/src/main/java/sh/libre/scim/core/ScimClientInterface.java @@ -5,14 +5,14 @@ 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 server defined in a {@link sh.libre.scim.storage.ScimStorageProvider}. + * 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 the keycloack model to synchronize (e.g. UserModel or GroupModel) */ public interface ScimClientInterface extends AutoCloseable { /** - * Propagates the creation of the given keycloack model to a SCIM server. + * Propagates the creation of the given keycloack model to a Scim endpoint. * * @param resource the created resource to propagate (e.g. a new UserModel) */ @@ -20,21 +20,21 @@ public interface ScimClientInterface extends AutoClos void create(M resource); /** - * Propagates the update of the given keycloack model to a SCIM server. + * 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 server. + * 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 server and keycloack, according to configuration. + * 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) */ diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index dcbe886..3434911 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -9,7 +9,7 @@ import java.util.function.Consumer; import java.util.stream.Stream; /** - * In charge of sending SCIM Request to all registered SCIM servers. + * In charge of sending SCIM Request to all registered Scim endpoints. */ public class ScimDispatcher { @@ -22,12 +22,12 @@ public class ScimDispatcher { } public void dispatchUserModificationToAll(Consumer operationToDispatch) { - getAllSCIMServer(Scope.USER).forEach(scimServer -> dispatchUserModificationToOne(scimServer, operationToDispatch)); + getAllSCIMServer(Scope.USER).forEach(scimServerConfiguration -> dispatchUserModificationToOne(scimServerConfiguration, operationToDispatch)); } - public void dispatchUserModificationToOne(ComponentModel scimServer, Consumer operationToDispatch) { - LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType()); - try (UserScimClient client = UserScimClient.newUserScimClient(scimServer, session)) { + public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { + LOGGER.infof("%s %s %s %s", scimServerConfiguration.getId(), scimServerConfiguration.getName(), scimServerConfiguration.getProviderId(), scimServerConfiguration.getProviderType()); + try (UserScimClient client = UserScimClient.newUserScimClient(scimServerConfiguration, session)) { operationToDispatch.accept(client); } catch (Exception e) { LOGGER.error(e); @@ -35,12 +35,12 @@ public class ScimDispatcher { } public void dispatchGroupModificationToAll(Consumer operationToDispatch) { - getAllSCIMServer(Scope.GROUP).forEach(scimServer -> dispatchGroupModificationToOne(scimServer, operationToDispatch)); + getAllSCIMServer(Scope.GROUP).forEach(scimServerConfiguration -> dispatchGroupModificationToOne(scimServerConfiguration, operationToDispatch)); } - public void dispatchGroupModificationToOne(ComponentModel scimServer, Consumer operationToDispatch) { - LOGGER.infof("%s %s %s %s", scimServer.getId(), scimServer.getName(), scimServer.getProviderId(), scimServer.getProviderType()); - try (GroupScimClient client = GroupScimClient.newGroupScimClient(scimServer, session)) { + public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { + LOGGER.infof("%s %s %s %s", scimServerConfiguration.getId(), scimServerConfiguration.getName(), scimServerConfiguration.getProviderId(), scimServerConfiguration.getProviderType()); + try (GroupScimClient client = GroupScimClient.newGroupScimClient(scimServerConfiguration, session)) { operationToDispatch.accept(client); } catch (Exception e) { LOGGER.error(e); @@ -49,14 +49,19 @@ public class ScimDispatcher { /** * @param scope The {@link Scope} to consider (User or Group) - * @return all enabled registered SCIM Servers with propagation enabled for the given scope + * @return all enabled registered Scim endpoints with propagation enabled for the given scope */ private Stream getAllSCIMServer(Scope scope) { // TODO : we could initiative this list once and invalidate it when configuration changes + + String propagationConfKey = switch (scope) { + case GROUP -> ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP; + case USER -> ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER; + }; return session.getContext().getRealm().getComponentsStream() .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true) - && m.get("propagation-" + scope.name(), false)); + && m.get(propagationConfKey, false)); } public enum Scope { diff --git a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java index c689385..cec792d 100644 --- a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java +++ b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java @@ -4,6 +4,17 @@ import de.captaingoldfish.scim.sdk.client.http.BasicAuth; import org.keycloak.component.ComponentModel; public class ScrimProviderConfiguration { + // Configuration keys : also used in Admin Console page + public static final String CONF_KEY_AUTH_MODE = "auth-mode"; + public static final String CONF_KEY_AUTH_PASSWORD = "auth-pass"; + public static final String CONF_KEY_AUTH_USER = "auth-user"; + public static final String CONF_KEY_CONTENT_TYPE = "content-type"; + public static final String CONF_KEY_ENDPOINT = "endpoint"; + public static final String CONF_KEY_SYNC_IMPORT_ACTION = "sync-import-action"; + public static final String CONF_KEY_SYNC_IMPORT = "sync-import"; + public static final String CONF_KEY_SYNC_REFRESH = "sync-refresh"; + public static final String CONF_KEY_PROPAGATION_USER = "propagation-user"; + public static final String CONF_KEY_PROPAGATION_GROUP = "propagation-group"; private final String endPoint; private final String id; @@ -14,25 +25,25 @@ public class ScrimProviderConfiguration { private final boolean syncRefresh; public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) { - AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get("auth-mode")); + AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE)); authorizationHeaderValue = switch (authMode) { - case BEARER -> "Bearer " + scimProviderConfiguration.get("auth-pass"); + case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD); case BASIC_AUTH -> { BasicAuth basicAuth = BasicAuth.builder() - .username(scimProviderConfiguration.get("auth-user")) - .password(scimProviderConfiguration.get("auth-pass")) + .username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER)) + .password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD)) .build(); yield basicAuth.getAuthorizationHeaderValue(); } default -> throw new IllegalArgumentException("authMode " + scimProviderConfiguration + " is not supported"); }; - contentType = scimProviderConfiguration.get("content-type"); - endPoint = scimProviderConfiguration.get("endpoint"); + contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE); + endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT); id = scimProviderConfiguration.getId(); - importAction = ImportAction.valueOf(scimProviderConfiguration.get("sync-import-action")); - syncImport = scimProviderConfiguration.get("sync-import", false); - syncRefresh = scimProviderConfiguration.get("sync-refresh", false); + importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); + syncImport = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); + syncRefresh = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); } public boolean isSyncRefresh() { @@ -67,10 +78,6 @@ public class ScrimProviderConfiguration { BEARER, BASIC_AUTH, NONE } - public enum EndpointContentType { - JSON, SCIM_JSON - } - public enum ImportAction { CREATE_LOCAL, DELETE_REMOTE, NOTHING } diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index ef68c55..abfc3da 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -19,7 +19,7 @@ import java.util.regex.Pattern; /** * An {@link java.util.EventListener} in charge of reaction to Keycloak models * modification (e.g. User creation, Group deletion, membership modifications...) - * by propagating it to all registered SCIM servers. + * by propagating it to all registered Scim endpoints. */ public class ScimEventListenerProvider implements EventListenerProvider { @@ -59,7 +59,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { } case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId)); default -> { - // No other event has to be propagated to SCIM Servers + // No other event has to be propagated to Scim endpoints } } } @@ -95,7 +95,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { handleRoleMappingEvent(event, type, id); } default -> { - // No other resource modification has to be propagated to SCIM Servers + // No other resource modification has to be propagated to Scim endpoints } } } @@ -122,7 +122,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { } /** - * Propagating the given group-related event to SCIM servers. + * Propagating the given group-related event to Scim endpoints. * * @param event the event to propagate * @param groupId event target's id diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java b/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java deleted file mode 100644 index 1949606..0000000 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package sh.libre.scim.storage; - -import org.keycloak.storage.UserStorageProvider; - -public class ScimStorageProvider implements UserStorageProvider { - @Override - public void close() { - } -} diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index e5f4b36..62b931b 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -8,45 +8,96 @@ import org.keycloak.component.ComponentModel; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.KeycloakSessionTask; import org.keycloak.models.RealmModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.storage.UserStorageProvider; 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; -import sh.libre.scim.core.UserAdapter; +import sh.libre.scim.core.ScrimProviderConfiguration; import java.time.Duration; import java.util.Date; import java.util.List; public class ScimStorageProviderFactory - implements UserStorageProviderFactory, ImportSynchronization { + implements UserStorageProviderFactory, ImportSynchronization { + public static final String ID = "scim"; + private final Logger logger = Logger.getLogger(ScimStorageProviderFactory.class); - private final Logger LOGGER = Logger.getLogger(ScimStorageProviderFactory.class); + @Override + public String getId() { + return ID; + } - public static final String ID = "scim"; - private static final List CONFIG_METADATA; + @Override + public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, + UserStorageProviderModel model) { + // TODO if this should be kept here, better document prupose & usage + logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s", realmId); + SynchronizationResult result = new SynchronizationResult(); + KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + ScimDispatcher dispatcher = new ScimDispatcher(session); + if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { + dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); + } + if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { + dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); + } + }); + return result; + } - static { - CONFIG_METADATA = ProviderConfigurationBuilder.create() + @Override + public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, + UserStorageProviderModel model) { + return this.sync(sessionFactory, realmId, model); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // TODO : find a better way to handle scim dirty (use a QUEUE for SCIM queries ?) + try (KeycloakSession keycloakSession = factory.create()) { + TimerProvider timer = keycloakSession.getProvider(TimerProvider.class); + timer.scheduleTask(taskSession -> { + for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { + KeycloakModelUtils.runJobInTransaction(factory, session -> { + session.getContext().setRealm(realm); + ScimDispatcher dispatcher = new ScimDispatcher(session); + 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)); + group.removeAttribute("scim-dirty"); + } + }); + } + }, Duration.ofSeconds(30).toMillis(), "scim-background"); + } + } + + @Override + public List getConfigProperties() { + // These Config Properties will be use to generate configuration page in Admin Console + return ProviderConfigurationBuilder.create() .property() - .name("endpoint") + .name(ScrimProviderConfiguration.CONF_KEY_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)") + "URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)") .add() .property() - .name("content-type") + .name(ScrimProviderConfiguration.CONF_KEY_CONTENT_TYPE) .type(ProviderConfigProperty.LIST_TYPE) .label("Endpoint content type") .helpText("Only used when endpoint doesn't support application/scim+json") @@ -54,7 +105,7 @@ public class ScimStorageProviderFactory .defaultValue(HttpHeader.SCIM_CONTENT_TYPE) .add() .property() - .name("auth-mode") + .name(ScrimProviderConfiguration.CONF_KEY_AUTH_MODE) .type(ProviderConfigProperty.LIST_TYPE) .label("Auth mode") .helpText("Select the authorization mode") @@ -62,38 +113,38 @@ public class ScimStorageProviderFactory .defaultValue("NONE") .add() .property() - .name("auth-user") + .name(ScrimProviderConfiguration.CONF_KEY_AUTH_USER) .type(ProviderConfigProperty.STRING_TYPE) .label("Auth username") .helpText("Required for basic authentication.") .add() .property() - .name("auth-pass") + .name(ScrimProviderConfiguration.CONF_KEY_AUTH_PASSWORD) .type(ProviderConfigProperty.PASSWORD) .label("Auth password/token") .helpText("Password or token required for basic or bearer authentication.") .add() .property() - .name("propagation-user") + .name(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable user propagation") .helpText("Should operation on users be propagated to this provider?") .defaultValue(BooleanUtils.TRUE) .add() .property() - .name("propagation-group") + .name(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable group propagation") .helpText("Should operation on groups be propagated to this provider?") .defaultValue(BooleanUtils.TRUE) .add() .property() - .name("sync-import") + .name(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable import during sync") .add() .property() - .name("sync-import-action") + .name(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT_ACTION) .type(ProviderConfigProperty.LIST_TYPE) .label("Import action") .helpText("What to do when the user doesn't exists in Keycloak.") @@ -101,81 +152,26 @@ public class ScimStorageProviderFactory .defaultValue("CREATE_LOCAL") .add() .property() - .name("sync-refresh") + .name(ScrimProviderConfiguration.CONF_KEY_SYNC_REFRESH) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable refresh during sync") .add() .build(); } + @Override public ScimStorageProvider create(KeycloakSession session, ComponentModel model) { - LOGGER.info("create"); return new ScimStorageProvider(); } - @Override - public String getId() { - return ID; - } - - @Override - public List getConfigProperties() { - return CONFIG_METADATA; - } - - @Override - public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, - UserStorageProviderModel model) { - LOGGER.info("sync"); - SynchronizationResult result = new SynchronizationResult(); - KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { - - @Override - public void run(KeycloakSession session) { - RealmModel realm = session.realms().getRealm(realmId); - session.getContext().setRealm(realm); - ScimDispatcher dispatcher = new ScimDispatcher(session); - if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { - dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); - } - if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { - dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); - } - } - - }); - - return result; - - } - - @Override - public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, - UserStorageProviderModel model) { - return this.sync(sessionFactory, realmId, model); - } - - @Override - public void postInit(KeycloakSessionFactory factory) { - TimerProvider timer = factory.create().getProvider(TimerProvider.class); - timer.scheduleTask(taskSession -> { - for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { - KeycloakModelUtils.runJobInTransaction(factory, new KeycloakSessionTask() { - @Override - public void run(KeycloakSession session) { - session.getContext().setRealm(realm); - ScimDispatcher dispatcher = new ScimDispatcher(session); - for (GroupModel group : session.groups().getGroupsStream(realm) - .filter(x -> BooleanUtils.TRUE.equals(x.getFirstAttribute("scim-dirty"))).toList()) { - LOGGER.debug(group.getName() + " is dirty"); - dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); - group.removeAttribute("scim-dirty"); - } - } - - }); - } - }, Duration.ofSeconds(30).toMillis(), "scim-background"); + /** + * Empty implementation : we used this {@link ScimStorageProviderFactory} to generate Admin Console page. + */ + public static final class ScimStorageProvider implements UserStorageProvider { + @Override + public void close() { + // Nothing to close here + } } } -- GitLab From 633291d401e9b9a4eeb7097fadfb6030701f2f8a Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 18 Jun 2024 17:36:15 +0200 Subject: [PATCH 27/58] Avoid recreating ScimClients at every change propagation : code refactoring --- .../sh/libre/scim/core/GroupScimClient.java | 5 + .../libre/scim/core/ScimClientInterface.java | 5 + .../sh/libre/scim/core/ScimDispatcher.java | 99 ++++++++++++------- .../sh/libre/scim/core/UserScimClient.java | 5 + 4 files changed, 80 insertions(+), 34 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupScimClient.java b/src/main/java/sh/libre/scim/core/GroupScimClient.java index 1483d3b..b4c911d 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimClient.java +++ b/src/main/java/sh/libre/scim/core/GroupScimClient.java @@ -30,6 +30,11 @@ public class GroupScimClient implements ScimClientInterface { throw new UnsupportedOperationException(); } + @Override + public ScrimProviderConfiguration getConfiguration() { + throw new UnsupportedOperationException(); + } + @Override public void close() throws Exception { throw new UnsupportedOperationException(); diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java index d28f289..5316bf5 100644 --- a/src/main/java/sh/libre/scim/core/ScimClientInterface.java +++ b/src/main/java/sh/libre/scim/core/ScimClientInterface.java @@ -39,4 +39,9 @@ public interface ScimClientInterface extends AutoClos * @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(); } diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 3434911..9501586 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -5,66 +5,97 @@ import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; import sh.libre.scim.storage.ScimStorageProviderFactory; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import java.util.function.Consumer; -import java.util.stream.Stream; /** * In charge of sending SCIM Request to all registered Scim endpoints. */ public class ScimDispatcher { - private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); + private static final Logger logger = Logger.getLogger(ScimDispatcher.class); private final KeycloakSession session; + private final List userScimClients = new ArrayList<>(); + private final List groupScimClients = new ArrayList<>(); public ScimDispatcher(KeycloakSession session) { this.session = session; + refreshActiveScimEndpoints(); } - public void dispatchUserModificationToAll(Consumer operationToDispatch) { - getAllSCIMServer(Scope.USER).forEach(scimServerConfiguration -> dispatchUserModificationToOne(scimServerConfiguration, operationToDispatch)); - } + /** + * Lists all active ScimStorageProviderFactory and create new ScimClients for each of them + */ + public void refreshActiveScimEndpoints() { + try { + // Step 1: close existing clients + for (GroupScimClient c : groupScimClients) { + c.close(); + } + for (UserScimClient c : userScimClients) { + c.close(); + } - public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { - LOGGER.infof("%s %s %s %s", scimServerConfiguration.getId(), scimServerConfiguration.getName(), scimServerConfiguration.getProviderId(), scimServerConfiguration.getProviderType()); - try (UserScimClient client = UserScimClient.newUserScimClient(scimServerConfiguration, session)) { - operationToDispatch.accept(client); + // 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 -> { + // 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 (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { + UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session); + userScimClients.add(userScimClient); + } + }); } catch (Exception e) { - LOGGER.error(e); + logger.error("[SCIM] Error while refreshing scim clients ", e); + // TODO : how to handle exception here ? } } + public void dispatchUserModificationToAll(Consumer operationToDispatch) { + // TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes + refreshActiveScimEndpoints(); + userScimClients.forEach(operationToDispatch); + } + public void dispatchGroupModificationToAll(Consumer operationToDispatch) { - getAllSCIMServer(Scope.GROUP).forEach(scimServerConfiguration -> dispatchGroupModificationToOne(scimServerConfiguration, operationToDispatch)); + // TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes + refreshActiveScimEndpoints(); + groupScimClients.forEach(operationToDispatch); } - public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { - LOGGER.infof("%s %s %s %s", scimServerConfiguration.getId(), scimServerConfiguration.getName(), scimServerConfiguration.getProviderId(), scimServerConfiguration.getProviderType()); - try (GroupScimClient client = GroupScimClient.newGroupScimClient(scimServerConfiguration, session)) { - operationToDispatch.accept(client); - } catch (Exception e) { - LOGGER.error(e); + public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { + // TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes + refreshActiveScimEndpoints(); + + // Scim client should already have been created + Optional matchingClient = userScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + if (matchingClient.isPresent()) { + operationToDispatch.accept(matchingClient.get()); + } else { + logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); } } - /** - * @param scope The {@link Scope} to consider (User or Group) - * @return all enabled registered Scim endpoints with propagation enabled for the given scope - */ - private Stream getAllSCIMServer(Scope scope) { - // TODO : we could initiative this list once and invalidate it when configuration changes - String propagationConfKey = switch (scope) { - case GROUP -> ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP; - case USER -> ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER; - }; - return session.getContext().getRealm().getComponentsStream() - .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId()) - && m.get("enabled", true) - && m.get(propagationConfKey, false)); - } + public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { + // TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes + refreshActiveScimEndpoints(); - public enum Scope { - USER, GROUP + // Scim client should already have been created + Optional matchingClient = groupScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + if (matchingClient.isPresent()) { + operationToDispatch.accept(matchingClient.get()); + } else { + logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); + } } } diff --git a/src/main/java/sh/libre/scim/core/UserScimClient.java b/src/main/java/sh/libre/scim/core/UserScimClient.java index 4f257a0..8d2f252 100644 --- a/src/main/java/sh/libre/scim/core/UserScimClient.java +++ b/src/main/java/sh/libre/scim/core/UserScimClient.java @@ -275,6 +275,11 @@ public class UserScimClient implements ScimClientInterface { } } + @Override + public ScrimProviderConfiguration getConfiguration() { + return this.scimProviderConfiguration; + } + @Override public void close() { -- GitLab From 2ded3f723698e6c3c3f4771cd233abd8599a91a4 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Thu, 20 Jun 2024 16:00:14 +0200 Subject: [PATCH 28/58] Update SCIM clients when configuration changes and improve ScimDispatcher lifecycle --- .../sh/libre/scim/core/GroupScimClient.java | 2 +- .../sh/libre/scim/core/ScimDispatcher.java | 74 ++++++++++++++----- .../scim/core/ScrimProviderConfiguration.java | 42 ++++++----- .../scim/event/ScimEventListenerProvider.java | 48 ++++++++++-- .../scim/jpa/ScimResourceProviderFactory.java | 13 +++- .../storage/ScimStorageProviderFactory.java | 11 ++- 6 files changed, 137 insertions(+), 53 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupScimClient.java b/src/main/java/sh/libre/scim/core/GroupScimClient.java index b4c911d..c1f09e1 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimClient.java +++ b/src/main/java/sh/libre/scim/core/GroupScimClient.java @@ -37,6 +37,6 @@ public class GroupScimClient implements ScimClientInterface { @Override public void close() throws Exception { - throw new UnsupportedOperationException(); + } } diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 9501586..b9070fb 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -6,7 +6,9 @@ import org.keycloak.models.KeycloakSession; import sh.libre.scim.storage.ScimStorageProviderFactory; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Consumer; @@ -17,13 +19,21 @@ public class ScimDispatcher { private static final Logger logger = Logger.getLogger(ScimDispatcher.class); + private static final Map sessionToScimDispatcher = new LinkedHashMap<>(); private final KeycloakSession session; + private boolean clientsInitialized = false; private final List userScimClients = new ArrayList<>(); private final List groupScimClients = new ArrayList<>(); - public ScimDispatcher(KeycloakSession session) { + + public static ScimDispatcher createForSession(KeycloakSession session) { + // Only create a scim dispatcher if there is none already created for session + sessionToScimDispatcher.computeIfAbsent(session, ScimDispatcher::new); + return sessionToScimDispatcher.get(session); + } + + private ScimDispatcher(KeycloakSession session) { this.session = session; - refreshActiveScimEndpoints(); } /** @@ -35,23 +45,30 @@ public class ScimDispatcher { for (GroupScimClient c : groupScimClients) { c.close(); } + groupScimClients.clear(); for (UserScimClient c : userScimClients) { c.close(); } + userScimClients.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 -> { - // 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 (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { - UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session); - userScimClients.add(userScimClient); + 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 (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { + UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session); + userScimClients.add(userScimClient); + } + } catch (Exception e) { + logger.warnf("[SCIM] Invalid Endpoint configuration %s : %s", scimEndpoint.getId(), e.getMessage()); + // TODO is it ok to log and try to create the other clients ? } }); } catch (Exception e) { @@ -61,25 +78,24 @@ public class ScimDispatcher { } public void dispatchUserModificationToAll(Consumer operationToDispatch) { - // TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes - refreshActiveScimEndpoints(); + initializeClientsIfNeeded(); userScimClients.forEach(operationToDispatch); + logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimClients.size()); } public void dispatchGroupModificationToAll(Consumer operationToDispatch) { - // TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes - refreshActiveScimEndpoints(); + initializeClientsIfNeeded(); groupScimClients.forEach(operationToDispatch); + logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimClients.size()); } public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { - // TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes - refreshActiveScimEndpoints(); - + initializeClientsIfNeeded(); // Scim client should already have been created Optional matchingClient = userScimClients.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()); } else { logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); } @@ -87,15 +103,33 @@ public class ScimDispatcher { public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { - // TODO should not be required to launch a refresh here : we should refresh clients only if an endpoint configuration changes - refreshActiveScimEndpoints(); - + initializeClientsIfNeeded(); // Scim client should already have been created Optional matchingClient = groupScimClients.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()); } else { logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); } } + + public void close() throws Exception { + sessionToScimDispatcher.remove(session); + for (GroupScimClient c : groupScimClients) { + c.close(); + } + for (UserScimClient c : userScimClients) { + c.close(); + } + groupScimClients.clear(); + userScimClients.clear(); + } + + private void initializeClientsIfNeeded() { + if (!clientsInitialized) { + clientsInitialized = true; + refreshActiveScimEndpoints(); + } + } } diff --git a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java index cec792d..6cd5431 100644 --- a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java +++ b/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java @@ -25,25 +25,29 @@ public class ScrimProviderConfiguration { private final boolean syncRefresh; public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) { - AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE)); - authorizationHeaderValue = switch (authMode) { - case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD); - case BASIC_AUTH -> { - BasicAuth basicAuth = BasicAuth.builder() - .username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER)) - .password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD)) - .build(); - yield basicAuth.getAuthorizationHeaderValue(); - } - default -> - throw new IllegalArgumentException("authMode " + scimProviderConfiguration + " is not supported"); - }; - contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE); - endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT); - id = scimProviderConfiguration.getId(); - importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); - syncImport = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); - syncRefresh = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); + try { + AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE)); + + authorizationHeaderValue = switch (authMode) { + case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD); + case BASIC_AUTH -> { + BasicAuth basicAuth = BasicAuth.builder() + .username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER)) + .password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD)) + .build(); + yield basicAuth.getAuthorizationHeaderValue(); + } + case NONE -> ""; + }; + contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE, ""); + endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT, ""); + id = scimProviderConfiguration.getId(); + importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); + syncImport = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); + syncRefresh = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("authMode '" + scimProviderConfiguration.get(CONF_KEY_AUTH_MODE) + "' is not supported"); + } } public boolean isSyncRefresh() { diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index abfc3da..6463853 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -6,6 +6,7 @@ import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; 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; @@ -33,12 +34,13 @@ public class ScimEventListenerProvider implements EventListenerProvider { ResourceType.USER, Pattern.compile("users/(.+)"), ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"), ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)"), - ResourceType.REALM_ROLE_MAPPING, Pattern.compile("^(.+)/(.+)/role-mappings") + ResourceType.REALM_ROLE_MAPPING, Pattern.compile("^(.+)/(.+)/role-mappings"), + ResourceType.COMPONENT, Pattern.compile("components/(.+)") ); public ScimEventListenerProvider(KeycloakSession session) { this.session = session; - dispatcher = new ScimDispatcher(session); + this.dispatcher = ScimDispatcher.createForSession(session); } @@ -47,23 +49,28 @@ public class ScimEventListenerProvider implements EventListenerProvider { // React to User-related event : creation, deletion, update EventType eventType = event.getType(); String eventUserId = event.getUserId(); - LOGGER.infof("[SCIM] Propagate User Event %s - %s", eventType, eventUserId); switch (eventType) { case REGISTER -> { + LOGGER.infof("[SCIM] Propagate User Registration - %s", eventUserId); UserModel user = getUser(eventUserId); dispatcher.dispatchUserModificationToAll(client -> client.create(user)); } case UPDATE_EMAIL, UPDATE_PROFILE -> { + LOGGER.infof("[SCIM] Propagate User %s - %s", eventType, eventUserId); UserModel user = getUser(eventUserId); dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } - case DELETE_ACCOUNT -> dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId)); + case DELETE_ACCOUNT -> { + LOGGER.infof("[SCIM] Propagate User deletion - %s", eventUserId); + dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId)); + } default -> { // No other event has to be propagated to Scim endpoints } } } + @Override public void onEvent(AdminEvent event, boolean includeRepresentation) { // Step 1: check if event is relevant for propagation through SCIM @@ -74,6 +81,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { if (!matcher.find()) return; + // Step 2: propagate event (if needed) according to its resource type switch (event.getResourceType()) { case USER -> { @@ -94,12 +102,18 @@ public class ScimEventListenerProvider implements EventListenerProvider { String id = matcher.group(2); handleRoleMappingEvent(event, type, id); } + case COMPONENT -> { + String id = matcher.group(1); + handleScimEndpointConfigurationEvent(event, id); + + } default -> { // No other resource modification has to be propagated to Scim endpoints } } } + private void handleUserEvent(AdminEvent userEvent, String userId) { LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId); switch (userEvent.getOperationType()) { @@ -174,6 +188,25 @@ public class ScimEventListenerProvider implements EventListenerProvider { } } + private void handleScimEndpointConfigurationEvent(AdminEvent event, String id) { + LOGGER.infof("[SCIM] SCIM Endpoint configuration %s - %s ", event.getOperationType(), id); + + // 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 + dispatcher.refreshActiveScimEndpoints(); + } else { + // In case of CREATE or UPDATE, we can directly use the string representation + // to check if it defines a SCIM endpoint (faster) + if (event.getRepresentation() != null + && event.getRepresentation().contains("\"providerId\":\"scim\"")) { + dispatcher.refreshActiveScimEndpoints(); + } + } + + } + private UserModel getUser(String id) { return session.users().getUserById(session.getContext().getRealm(), id); @@ -185,7 +218,12 @@ public class ScimEventListenerProvider implements EventListenerProvider { @Override public void close() { - // Nothing to close here + try { + dispatcher.close(); + } catch (Exception e) { + LOGGER.error("Error while closing dispatcher", e); + } } + } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java index 6f4162e..7f3cc32 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java @@ -10,10 +10,6 @@ public class ScimResourceProviderFactory implements JpaEntityProviderFactory { static final String ID = "scim-resource"; - @Override - public void close() { - } - @Override public JpaEntityProvider create(KeycloakSession session) { return new ScimResourceProvider(); @@ -26,9 +22,18 @@ public class ScimResourceProviderFactory implements JpaEntityProviderFactory { @Override public void init(Scope scope) { + // Nothing to initialise } @Override public void postInit(KeycloakSessionFactory sessionFactory) { + // Nothing to do + } + + + @Override + public void close() { + // Nothing to close } + } diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java index 62b931b..920b830 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java @@ -25,6 +25,9 @@ import java.time.Duration; import java.util.Date; import java.util.List; +/** + * Allows to register Scim endpoints through Admin console, using the provided config properties. + */ public class ScimStorageProviderFactory implements UserStorageProviderFactory, ImportSynchronization { public static final String ID = "scim"; @@ -39,13 +42,13 @@ public class ScimStorageProviderFactory @Override public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) { - // TODO if this should be kept here, better document prupose & usage - logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s", realmId); + // TODO if this should be kept here, better document purpose & usage + logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getId()); SynchronizationResult result = new SynchronizationResult(); KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { RealmModel realm = session.realms().getRealm(realmId); session.getContext().setRealm(realm); - ScimDispatcher dispatcher = new ScimDispatcher(session); + ScimDispatcher dispatcher = ScimDispatcher.createForSession(session); if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); } @@ -71,7 +74,7 @@ public class ScimStorageProviderFactory for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { KeycloakModelUtils.runJobInTransaction(factory, session -> { session.getContext().setRealm(realm); - ScimDispatcher dispatcher = new ScimDispatcher(session); + ScimDispatcher dispatcher = ScimDispatcher.createForSession(session); 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()); -- GitLab From f81001503d4c1f07cf7c1125b1f7e46d7060d3cb Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Thu, 20 Jun 2024 17:17:38 +0200 Subject: [PATCH 29/58] Remove Adapter and refactor ScimClient --- .../libre/scim/core/AbstractScimService.java | 230 +++++++++++++ src/main/java/sh/libre/scim/core/Adapter.java | 137 -------- .../libre/scim/core/EntityOnRemoteScimId.java | 6 + .../java/sh/libre/scim/core/GroupAdapter.java | 160 --------- .../sh/libre/scim/core/GroupScimClient.java | 42 --- .../sh/libre/scim/core/GroupScimService.java | 127 ++++++++ .../java/sh/libre/scim/core/KeycloakDao.java | 74 +++++ .../java/sh/libre/scim/core/KeycloakId.java | 7 + .../java/sh/libre/scim/core/ScimClient.java | 303 ++++-------------- .../libre/scim/core/ScimClientInterface.java | 47 --- .../sh/libre/scim/core/ScimDispatcher.java | 57 ++-- .../sh/libre/scim/core/ScimResourceType.java | 29 ++ .../java/sh/libre/scim/core/UserAdapter.java | 239 -------------- .../sh/libre/scim/core/UserScimClient.java | 288 ----------------- .../sh/libre/scim/core/UserScimService.java | 145 +++++++++ .../java/sh/libre/scim/jpa/ScimResource.java | 10 + .../sh/libre/scim/jpa/ScimResourceDao.java | 97 ++++++ 17 files changed, 819 insertions(+), 1179 deletions(-) create mode 100644 src/main/java/sh/libre/scim/core/AbstractScimService.java delete mode 100644 src/main/java/sh/libre/scim/core/Adapter.java create mode 100644 src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java delete mode 100644 src/main/java/sh/libre/scim/core/GroupAdapter.java delete mode 100644 src/main/java/sh/libre/scim/core/GroupScimClient.java create mode 100644 src/main/java/sh/libre/scim/core/GroupScimService.java create mode 100644 src/main/java/sh/libre/scim/core/KeycloakDao.java create mode 100644 src/main/java/sh/libre/scim/core/KeycloakId.java delete mode 100644 src/main/java/sh/libre/scim/core/ScimClientInterface.java create mode 100644 src/main/java/sh/libre/scim/core/ScimResourceType.java delete mode 100644 src/main/java/sh/libre/scim/core/UserAdapter.java delete mode 100644 src/main/java/sh/libre/scim/core/UserScimClient.java create mode 100644 src/main/java/sh/libre/scim/core/UserScimService.java create mode 100644 src/main/java/sh/libre/scim/jpa/ScimResourceDao.java diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java new file mode 100644 index 0000000..11d6276 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -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 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 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 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 getResourceStream(); + + public void importResources(SynchronizationResult syncRes) { + LOGGER.info("Import"); + ScimClient 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 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 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; + } +} diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java deleted file mode 100644 index 607a7e1..0000000 --- a/src/main/java/sh/libre/scim/core/Adapter.java +++ /dev/null @@ -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 The Keycloack {@link RoleMapperModel} (e.g. GroupModel, UserModel) - * @param the SCIM {@link ResourceNode} (e.g. Group, User) - */ -public abstract class Adapter { - - 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 query(String query, String id) { - return query(query, id, type); - } - - public TypedQuery 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 getResourceClass(); - - public abstract S toScim(); - - public abstract Boolean entityExists(); - - public abstract Boolean tryToMap(); - - public abstract void createEntity() throws Exception; - - public abstract Stream getResourceStream(); - - public abstract Boolean skipRefresh(); -} diff --git a/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java b/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java new file mode 100644 index 0000000..3249608 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java @@ -0,0 +1,6 @@ +package sh.libre.scim.core; + +public record EntityOnRemoteScimId( + String asString +) { +} diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java deleted file mode 100644 index b87bf30..0000000 --- a/src/main/java/sh/libre/scim/core/GroupAdapter.java +++ /dev/null @@ -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 { - - private String displayName; - private Set 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 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 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 names = Set.of(externalId, displayName); - Optional 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 getResourceStream() { - return this.session.groups().getGroupsStream(this.session.getContext().getRealm()); - } - - @Override - public Boolean skipRefresh() { - return false; - } - -} diff --git a/src/main/java/sh/libre/scim/core/GroupScimClient.java b/src/main/java/sh/libre/scim/core/GroupScimClient.java deleted file mode 100644 index c1f09e1..0000000 --- a/src/main/java/sh/libre/scim/core/GroupScimClient.java +++ /dev/null @@ -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 { - 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 { - - } -} diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java new file mode 100644 index 0000000..0648f10 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/GroupScimService.java @@ -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 { + private final Logger logger = Logger.getLogger(GroupScimService.class); + + public GroupScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP); + } + + @Override + protected Stream getResourceStream() { + return getKeycloakDao().getGroupsStream(); + } + + @Override + protected boolean entityExists(KeycloakId keycloakId) { + return getKeycloakDao().groupExists(keycloakId); + } + + @Override + protected Optional tryToMap(Group resource) { + String externalId = resource.getId().get(); + String displayName = resource.getDisplayName().get(); + Set names = Set.of(externalId, displayName); + Optional 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 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 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; + } +} diff --git a/src/main/java/sh/libre/scim/core/KeycloakDao.java b/src/main/java/sh/libre/scim/core/KeycloakDao.java new file mode 100644 index 0000000..1062e03 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/KeycloakDao.java @@ -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 getGroupsStream() { + return getKeycloakSession().groups().getGroupsStream(getRealm()); + } + + public GroupModel createGroup(String displayName) { + return getKeycloakSession().groups().createGroup(getRealm(), displayName); + } + + public Set getGroupMembers(GroupModel groupModel) { + return getKeycloakSession().users() + .getGroupMembersStream(getRealm(), groupModel) + .map(UserModel::getId) + .map(KeycloakId::new) + .collect(Collectors.toSet()); + } + + public Stream 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); + } +} diff --git a/src/main/java/sh/libre/scim/core/KeycloakId.java b/src/main/java/sh/libre/scim/core/KeycloakId.java new file mode 100644 index 0000000..f35817d --- /dev/null +++ b/src/main/java/sh/libre/scim/core/KeycloakId.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core; + +public record KeycloakId( + String asString +) { + +} diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 071251b..ecbdb8d 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -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 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 KeycloakSession session; + private final ScimResourceType scimResourceType; - private final ComponentModel model; + { + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(10) + .intervalFunction(IntervalFunction.ofExponentialBackoff()) + .retryExceptions(ProcessingException.class) + .build(); + retryRegistry = RetryRegistry.of(retryConfig); + } - private ScimClient(ScimRequestBuilder scimRequestBuilder, RetryRegistry registry, KeycloakSession session, ComponentModel model) { + private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType) { this.scimRequestBuilder = scimRequestBuilder; - this.registry = registry; - this.session = session; - this.model = model; + this.scimResourceType = scimResourceType; } - 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")) - .build(); - yield basicAuth.getAuthorizationHeaderValue(); - } - default -> throw new IllegalArgumentException("authMode " + authMode + " is not supported"); - }; - + public static ScimClient open(ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { + String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); Map 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); - } - - private static > TypedQuery 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(); + return new ScimClient(scimRequestBuilder, scimResourceType); } - protected > A newAdapter( - Class adapterClass) { - try { - return adapterClass.getDeclaredConstructor(KeycloakSession.class, String.class) - .newInstance(session, this.model.getId()); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - public > void create(Class 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 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 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(); } - public > void replace(Class adapterClass, - M kcModel) { - A adapter = newAdapter(adapterClass); - try { - adapter.apply(kcModel); - if (adapter.skip) - return; - ScimResource resource = findById(adapter).getSingleResult(); - adapter.apply(resource); - Retry retry = registry.retry("replace-" + adapter.getId()); - ServerResponse 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); - } + private String getScimEndpoint() { + return scimResourceType.getEndpoint(); } - public > void delete(Class 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); - - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId()) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); - - if (!response.isSuccess()) { - LOGGER.warn(response.getResponseBody()); - LOGGER.warn(response.getHttpStatus()); - } - - getEntityManager().remove(resource); - - } catch (NoResultException e) { - LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id); - } + private Class getResourceClass() { + return scimResourceType.getResourceClass(); } - public > void refreshResources( - Class 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 void replace(EntityOnRemoteScimId externalId, ResourceNode scimForReplace) { + Retry retry = retryRegistry.retry("replace-" + scimForReplace.getId().get()); + ServerResponse response = retry.executeSupplier(() -> { + try { + return scimRequestBuilder + .update(getResourceClass(), getScimEndpoint(), externalId.asString()) + .setResource(scimForReplace) + .sendRequest(); + } catch (ResponseException e) { + throw new RuntimeException(e); } }); - + checkResponseIsSuccess(response); } - public > void importResources( - Class adapterClass, SynchronizationResult syncRes) { - LOGGER.info("Import"); - try { - A adapter = newAdapter(adapterClass); - ServerResponse> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); - ListResponse 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(); - } + public void delete(EntityOnRemoteScimId externalId) { + Retry retry = retryRegistry.retry("delete-" + externalId.asString()); + ServerResponse response = retry.executeSupplier(() -> { + try { + return scimRequestBuilder.delete(getResourceClass(), getScimEndpoint(), externalId.asString()) + .sendRequest(); + } catch (ResponseException e) { + throw new RuntimeException(e); } - } catch (ResponseException e) { - throw new RuntimeException(e); - } - } - - public > void sync(Class 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 listResources() { + ServerResponse> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get().sendRequest(); + ListResponse resourceTypeListResponse = response.getResource(); + return resourceTypeListResponse.getListedResources(); + } } diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java deleted file mode 100644 index 5316bf5..0000000 --- a/src/main/java/sh/libre/scim/core/ScimClientInterface.java +++ /dev/null @@ -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 the keycloack model to synchronize (e.g. UserModel or GroupModel) - */ -public interface ScimClientInterface 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(); -} diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index b9070fb..142d3ff 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -22,8 +22,8 @@ public class ScimDispatcher { private static final Map sessionToScimDispatcher = new LinkedHashMap<>(); private final KeycloakSession session; private boolean clientsInitialized = false; - private final List userScimClients = new ArrayList<>(); - private final List groupScimClients = new ArrayList<>(); + private final List userScimServices = new ArrayList<>(); + private final List 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 operationToDispatch) { + public void dispatchUserModificationToAll(Consumer 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 operationToDispatch) { + public void dispatchGroupModificationToAll(Consumer 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 operationToDispatch) { + public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { initializeClientsIfNeeded(); // Scim client should already have been created - Optional matchingClient = userScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + Optional 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 operationToDispatch) { + public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { initializeClientsIfNeeded(); // Scim client should already have been created - Optional matchingClient = groupScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst(); + Optional 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() { diff --git a/src/main/java/sh/libre/scim/core/ScimResourceType.java b/src/main/java/sh/libre/scim/core/ScimResourceType.java new file mode 100644 index 0000000..23df9f8 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimResourceType.java @@ -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 resourceClass; + + ScimResourceType(String endpoint, Class resourceClass) { + this.endpoint = endpoint; + this.resourceClass = resourceClass; + } + + public String getEndpoint() { + return endpoint; + } + + public Class getResourceClass() { + return (Class) resourceClass; + } +} diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java deleted file mode 100644 index 1de86b3..0000000 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ /dev/null @@ -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 { - - 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 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 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 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 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 getResourceStream() { - Map params = new HashMap<>(); - return this.session.users().searchForUserStream(realm, params); - } - - @Override - public Boolean skipRefresh() { - return "admin".equals(getUsername()); - } -} diff --git a/src/main/java/sh/libre/scim/core/UserScimClient.java b/src/main/java/sh/libre/scim/core/UserScimClient.java deleted file mode 100644 index 8d2f252..0000000 --- a/src/main/java/sh/libre/scim/core/UserScimClient.java +++ /dev/null @@ -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 { - - 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 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 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 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 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> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest(); - ListResponse 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(); - } -} diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/UserScimService.java new file mode 100644 index 0000000..9a65849 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/UserScimService.java @@ -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 { + private final Logger logger = Logger.getLogger(UserScimService.class); + + public UserScimService( + KeycloakSession keycloakSession, + ScrimProviderConfiguration scimProviderConfiguration) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER); + } + + @Override + protected Stream getResourceStream() { + return getKeycloakDao().getUsersStream(); + } + + @Override + protected boolean entityExists(KeycloakId keycloakId) { + return getKeycloakDao().userExists(keycloakId); + } + + @Override + protected Optional 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 groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream); + Stream roleModels = roleMapperModel.getRoleMappingsStream(); + Stream allRoleModels = Stream.concat(groupRoleModels, roleModels); + List 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 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()); + } +} diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java index 1de3faa..5536a6d 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResource.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java @@ -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); + } } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java new file mode 100644 index 0000000..7d96b28 --- /dev/null +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -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 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 findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) { + try { + return Optional.of( + getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult() + ); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + public Optional findById(KeycloakId keycloakId, ScimResourceType type) { + try { + return Optional.of( + getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult() + ); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + public Optional findUserById(KeycloakId id) { + return findById(id, ScimResourceType.USER); + } + + public Optional findUserByExternalId(EntityOnRemoteScimId externalId) { + return findByExternalId(externalId, ScimResourceType.USER); + } + + public void delete(ScimResource resource) { + EntityManager entityManager = getEntityManager(); + entityManager.remove(resource); + } +} -- GitLab From 2e992dad4459444622f1fbf02e33469f0c5f6405 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Thu, 20 Jun 2024 17:36:46 +0200 Subject: [PATCH 30/58] Refactor Scim Event listener --- .../libre/scim/core/AbstractScimService.java | 8 ---- .../java/sh/libre/scim/core/KeycloakDao.java | 7 +++ .../scim/event/ScimEventListenerProvider.java | 46 +++++++++++-------- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 11d6276..e32f178 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -89,14 +89,6 @@ public abstract class AbstractScimService getGroupsStream() { return getKeycloakSession().groups().getGroupsStream(getRealm()); } @@ -71,4 +76,6 @@ public class KeycloakDao { public UserModel addUser(String username) { return getKeycloakSession().users().addUser(getRealm(), username); } + + } diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 6463853..0a57d5a 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -11,7 +11,10 @@ import org.keycloak.events.admin.ResourceType; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; +import sh.libre.scim.core.KeycloakDao; +import sh.libre.scim.core.KeycloakId; import sh.libre.scim.core.ScimDispatcher; +import sh.libre.scim.core.ScimResourceType; import java.util.Map; import java.util.regex.Matcher; @@ -30,6 +33,8 @@ public class ScimEventListenerProvider implements EventListenerProvider { private final KeycloakSession session; + private final KeycloakDao keycloackDao; + private final Map patterns = Map.of( ResourceType.USER, Pattern.compile("users/(.+)"), ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"), @@ -40,15 +45,15 @@ public class ScimEventListenerProvider implements EventListenerProvider { public ScimEventListenerProvider(KeycloakSession session) { this.session = session; + this.keycloackDao = new KeycloakDao(session); this.dispatcher = ScimDispatcher.createForSession(session); } - @Override public void onEvent(Event event) { // React to User-related event : creation, deletion, update EventType eventType = event.getType(); - String eventUserId = event.getUserId(); + KeycloakId eventUserId = new KeycloakId(event.getUserId()); switch (eventType) { case REGISTER -> { LOGGER.infof("[SCIM] Propagate User Registration - %s", eventUserId); @@ -85,21 +90,26 @@ public class ScimEventListenerProvider implements EventListenerProvider { // Step 2: propagate event (if needed) according to its resource type switch (event.getResourceType()) { case USER -> { - String userId = matcher.group(1); + KeycloakId userId = new KeycloakId(matcher.group(1)); handleUserEvent(event, userId); } case GROUP -> { - String groupId = matcher.group(1); + KeycloakId groupId = new KeycloakId(matcher.group(1)); handleGroupEvent(event, groupId); } case GROUP_MEMBERSHIP -> { - String userId = matcher.group(1); - String groupId = matcher.group(2); + KeycloakId userId = new KeycloakId(matcher.group(1)); + KeycloakId groupId = new KeycloakId(matcher.group(2)); handleGroupMemberShipEvent(event, userId, groupId); } case REALM_ROLE_MAPPING -> { - String type = matcher.group(1); - String id = matcher.group(2); + String rawResourceType = matcher.group(1); + ScimResourceType type = switch (rawResourceType) { + case "users" -> ScimResourceType.USER; + case "groups" -> ScimResourceType.GROUP; + default -> throw new IllegalArgumentException("Unsuported resource type : " + rawResourceType); + }; + KeycloakId id = new KeycloakId(matcher.group(2)); handleRoleMappingEvent(event, type, id); } case COMPONENT -> { @@ -114,7 +124,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { } - private void handleUserEvent(AdminEvent userEvent, String userId) { + private void handleUserEvent(AdminEvent userEvent, KeycloakId userId) { LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId); switch (userEvent.getOperationType()) { case CREATE -> { @@ -141,7 +151,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { * @param event the event to propagate * @param groupId event target's id */ - private void handleGroupEvent(AdminEvent event, String groupId) { + private void handleGroupEvent(AdminEvent event, KeycloakId groupId) { LOGGER.infof("[SCIM] Propagate Group %s - %s", event.getOperationType(), groupId); switch (event.getOperationType()) { case CREATE -> { @@ -159,7 +169,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { } } - private void handleGroupMemberShipEvent(AdminEvent groupMemberShipEvent, String userId, String groupId) { + private void handleGroupMemberShipEvent(AdminEvent groupMemberShipEvent, KeycloakId userId, KeycloakId groupId) { LOGGER.infof("[SCIM] Propagate GroupMemberShip %s - User %s Group %s", groupMemberShipEvent.getOperationType(), userId, groupId); GroupModel group = getGroup(groupId); group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); @@ -167,14 +177,14 @@ public class ScimEventListenerProvider implements EventListenerProvider { dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } - private void handleRoleMappingEvent(AdminEvent roleMappingEvent, String type, String id) { + private void handleRoleMappingEvent(AdminEvent roleMappingEvent, ScimResourceType type, KeycloakId id) { LOGGER.infof("[SCIM] Propagate RoleMapping %s - %s %s", roleMappingEvent.getOperationType(), type, id); switch (type) { - case "users" -> { + case USER -> { UserModel user = getUser(id); dispatcher.dispatchUserModificationToAll(client -> client.replace(user)); } - case "groups" -> { + case GROUP -> { GroupModel group = getGroup(id); session.users() .getGroupMembersStream(session.getContext().getRealm(), group) @@ -208,12 +218,12 @@ public class ScimEventListenerProvider implements EventListenerProvider { } - private UserModel getUser(String id) { - return session.users().getUserById(session.getContext().getRealm(), id); + private UserModel getUser(KeycloakId id) { + return keycloackDao.getUserById(id); } - private GroupModel getGroup(String id) { - return session.groups().getGroupById(session.getContext().getRealm(), id); + private GroupModel getGroup(KeycloakId id) { + return keycloackDao.getGroupById(id); } @Override -- GitLab From bee8d1f39bdaf1a9d2bf76442a3204955262a089 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 21 Jun 2024 09:45:57 +0200 Subject: [PATCH 31/58] Renaming ScimStorageProviderFactory --- .../java/sh/libre/scim/core/ScimDispatcher.java | 6 +++--- .../scim/event/ScimEventListenerProvider.java | 4 ++-- ...ointConfigurationStorageProviderFactory.java} | 16 ++++++++-------- ...g.keycloak.storage.UserStorageProviderFactory | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) rename src/main/java/sh/libre/scim/storage/{ScimStorageProviderFactory.java => ScimEndpointConfigurationStorageProviderFactory.java} (90%) diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 142d3ff..7db4e62 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -3,7 +3,7 @@ package sh.libre.scim.core; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; -import sh.libre.scim.storage.ScimStorageProviderFactory; +import sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -53,7 +53,7 @@ public class ScimDispatcher { // Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory) session.getContext().getRealm().getComponentsStream() - .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId()) + .filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true)) .forEach(scimEndpointConfigurationRaw -> { ScrimProviderConfiguration scrimProviderConfiguration = new ScrimProviderConfiguration(scimEndpointConfigurationRaw); @@ -115,7 +115,7 @@ public class ScimDispatcher { } } - public void close() throws Exception { + public void close() { sessionToScimDispatcher.remove(session); for (GroupScimService c : groupScimServices) { c.close(); diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 0a57d5a..1fe84ad 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -21,8 +21,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * An {@link java.util.EventListener} in charge of reaction to Keycloak models - * modification (e.g. User creation, Group deletion, membership modifications...) + * An Event listener reacting to Keycloak models modification + * (e.g. User creation, Group deletion, membership modifications, endpoint configuration change...) * by propagating it to all registered Scim endpoints. */ public class ScimEventListenerProvider implements EventListenerProvider { diff --git a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java similarity index 90% rename from src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java rename to src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java index 920b830..bac304e 100644 --- a/src/main/java/sh/libre/scim/storage/ScimStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java @@ -26,12 +26,12 @@ import java.util.Date; import java.util.List; /** - * Allows to register Scim endpoints through Admin console, using the provided config properties. + * Allows to register and configure Scim endpoints through Admin console, using the provided config properties. */ -public class ScimStorageProviderFactory - implements UserStorageProviderFactory, ImportSynchronization { +public class ScimEndpointConfigurationStorageProviderFactory + implements UserStorageProviderFactory, ImportSynchronization { public static final String ID = "scim"; - private final Logger logger = Logger.getLogger(ScimStorageProviderFactory.class); + private final Logger logger = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class); @Override public String getId() { @@ -164,14 +164,14 @@ public class ScimStorageProviderFactory @Override - public ScimStorageProvider create(KeycloakSession session, ComponentModel model) { - return new ScimStorageProvider(); + public ScimEndpointConfigurationStorageProvider create(KeycloakSession session, ComponentModel model) { + return new ScimEndpointConfigurationStorageProvider(); } /** - * Empty implementation : we used this {@link ScimStorageProviderFactory} to generate Admin Console page. + * Empty implementation : we used this {@link ScimEndpointConfigurationStorageProviderFactory} to generate Admin Console page. */ - public static final class ScimStorageProvider implements UserStorageProvider { + public static final class ScimEndpointConfigurationStorageProvider implements UserStorageProvider { @Override public void close() { // Nothing to close here diff --git a/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory index 255bdda..23371dd 100644 --- a/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory +++ b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -1 +1 @@ -sh.libre.scim.storage.ScimStorageProviderFactory +sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory -- GitLab From 0b24401def6e3e68e3eff1f748fd61f7144c8580 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Fri, 21 Jun 2024 10:45:08 +0200 Subject: [PATCH 32/58] Fix NPE when defining retry name for creation --- .../sh/libre/scim/core/AbstractScimService.java | 2 +- src/main/java/sh/libre/scim/core/ScimClient.java | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index e32f178..53a6738 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -45,7 +45,7 @@ public abstract class AbstractScimService implements AutoCloseable { return new ScimClient(scimRequestBuilder, scimResourceType); } - public EntityOnRemoteScimId create(ResourceNode scimForCreation) { - Retry retry = retryRegistry.retry("create-" + scimForCreation.getId().get()); + public EntityOnRemoteScimId create(KeycloakId id, ResourceNode scimForCreation) { + if (scimForCreation.getId().isPresent()) { + throw new IllegalArgumentException( + "%s is already created on remote with id %s".formatted(id, scimForCreation.getId().get()) + ); + } + Retry retry = retryRegistry.retry("create-%s".formatted(id.asString())); ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder @@ -98,7 +103,7 @@ public class ScimClient implements AutoCloseable { } public void replace(EntityOnRemoteScimId externalId, ResourceNode scimForReplace) { - Retry retry = retryRegistry.retry("replace-" + scimForReplace.getId().get()); + Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder @@ -113,7 +118,7 @@ public class ScimClient implements AutoCloseable { } public void delete(EntityOnRemoteScimId externalId) { - Retry retry = retryRegistry.retry("delete-" + externalId.asString()); + Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString())); ServerResponse response = retry.executeSupplier(() -> { try { return scimRequestBuilder.delete(getResourceClass(), getScimEndpoint(), externalId.asString()) -- GitLab From c793c138a3ff51c79188f9803a824994eb35db79 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Fri, 21 Jun 2024 10:45:48 +0200 Subject: [PATCH 33/58] Fix concurrent modification exception with dispatcher caching keycloak-1 | 2024-06-21 08:32:21,335 ERROR [org.keycloak.services.error.KeycloakErrorHandler] (executor-thread-9) Uncaught server error: java.util.ConcurrentModificationException keycloak-1 | at java.base/java.util.HashMap.computeIfAbsent(HashMap.java:1221) keycloak-1 | at sh.libre.scim.core.ScimDispatcher.createForSession(ScimDispatcher.java:31) keycloak-1 | at sh.libre.scim.event.ScimEventListenerProvider.(ScimEventListenerProvider.java:49) keycloak-1 | at sh.libre.scim.event.ScimEventListenerProviderFactory.create(ScimEventListenerProviderFactory.java:13) keycloak-1 | at sh.libre.scim.event.ScimEventListenerProviderFactory.create(ScimEventListenerProviderFactory.java:9) keycloak-1 | at org.keycloak.services.DefaultKeycloakSession.getProvider(DefaultKeycloakSession.java:195) --- src/main/java/sh/libre/scim/core/ScimDispatcher.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 7db4e62..c46740d 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -10,6 +10,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; /** @@ -19,7 +20,7 @@ public class ScimDispatcher { private static final Logger logger = Logger.getLogger(ScimDispatcher.class); - private static final Map sessionToScimDispatcher = new LinkedHashMap<>(); + private static final Map sessionToScimDispatcher = new ConcurrentHashMap<>(); private final KeycloakSession session; private boolean clientsInitialized = false; private final List userScimServices = new ArrayList<>(); -- GitLab From 3009edf10172eec43009d4c694392d0e64c9c17c Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Fri, 21 Jun 2024 11:02:29 +0200 Subject: [PATCH 34/58] Remove pointless exception encapsulation --- .../java/sh/libre/scim/core/ScimClient.java | 42 +++++++------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 1846056..ac635bc 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -70,16 +70,11 @@ public class ScimClient implements AutoCloseable { ); } Retry retry = retryRegistry.retry("create-%s".formatted(id.asString())); - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder - .create(getResourceClass(), getScimEndpoint()) - .setResource(scimForCreation) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .create(getResourceClass(), getScimEndpoint()) + .setResource(scimForCreation) + .sendRequest() + ); checkResponseIsSuccess(response); S resource = response.getResource(); return resource.getId() @@ -104,29 +99,20 @@ public class ScimClient implements AutoCloseable { public void replace(EntityOnRemoteScimId externalId, ResourceNode scimForReplace) { Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder - .update(getResourceClass(), getScimEndpoint(), externalId.asString()) - .setResource(scimForReplace) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .update(getResourceClass(), getScimEndpoint(), externalId.asString()) + .setResource(scimForReplace) + .sendRequest() + ); checkResponseIsSuccess(response); } public void delete(EntityOnRemoteScimId externalId) { Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString())); - ServerResponse response = retry.executeSupplier(() -> { - try { - return scimRequestBuilder.delete(getResourceClass(), getScimEndpoint(), externalId.asString()) - .sendRequest(); - } catch (ResponseException e) { - throw new RuntimeException(e); - } - }); + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .delete(getResourceClass(), getScimEndpoint(), externalId.asString()) + .sendRequest() + ); checkResponseIsSuccess(response); } -- GitLab From 1e46d584c816aba9c0a33fffad860b3aed3993a3 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Fri, 21 Jun 2024 11:53:07 +0200 Subject: [PATCH 35/58] Type "type" column in Hibernate --- src/main/java/sh/libre/scim/jpa/ScimResource.java | 7 ++++--- src/main/java/sh/libre/scim/jpa/ScimResourceDao.java | 4 ++-- src/main/java/sh/libre/scim/jpa/ScimResourceId.java | 11 ++++++----- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java index 5536a6d..cacc92e 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResource.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java @@ -9,6 +9,7 @@ import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; import sh.libre.scim.core.EntityOnRemoteScimId; import sh.libre.scim.core.KeycloakId; +import sh.libre.scim.core.ScimResourceType; @Entity @IdClass(ScimResourceId.class) @@ -33,7 +34,7 @@ public class ScimResource { @Id @Column(name = "TYPE", nullable = false) - private String type; + private ScimResourceType type; @Id @Column(name = "EXTERNAL_ID", nullable = false) @@ -71,11 +72,11 @@ public class ScimResource { this.externalId = externalId; } - public String getType() { + public ScimResourceType getType() { return type; } - public void setType(String type) { + public void setType(ScimResourceType type) { this.type = type; } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java index 7d96b28..02a473c 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -45,7 +45,7 @@ public class ScimResourceDao { public void create(KeycloakId id, EntityOnRemoteScimId externalId, ScimResourceType type) { ScimResource entity = new ScimResource(); - entity.setType(type.name()); + entity.setType(type); entity.setExternalId(externalId.asString()); entity.setComponentId(componentId); entity.setRealmId(realmId); @@ -56,7 +56,7 @@ public class ScimResourceDao { private TypedQuery getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) { return getEntityManager() .createNamedQuery(queryName, ScimResource.class) - .setParameter("type", type.name()) + .setParameter("type", type) .setParameter("realmId", getRealmId()) .setParameter("componentId", getComponentId()) .setParameter("id", id); diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java index 99bce76..bfb4424 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -1,6 +1,7 @@ package sh.libre.scim.jpa; import org.apache.commons.lang3.StringUtils; +import sh.libre.scim.core.ScimResourceType; import java.io.Serializable; import java.util.Objects; @@ -9,13 +10,13 @@ public class ScimResourceId implements Serializable { private String id; private String realmId; private String componentId; - private String type; + private ScimResourceType type; private String externalId; public ScimResourceId() { } - public ScimResourceId(String id, String realmId, String componentId, String type, String externalId) { + public ScimResourceId(String id, String realmId, String componentId, ScimResourceType type, String externalId) { this.setId(id); this.setRealmId(realmId); this.setComponentId(componentId); @@ -47,11 +48,11 @@ public class ScimResourceId implements Serializable { this.componentId = componentId; } - public String getType() { + public ScimResourceType getType() { return type; } - public void setType(String type) { + public void setType(ScimResourceType type) { this.type = type; } @@ -74,7 +75,7 @@ public class ScimResourceId implements Serializable { return (StringUtils.equals(o.id, id) && StringUtils.equals(o.realmId, realmId) && StringUtils.equals(o.componentId, componentId) && - StringUtils.equals(o.type, type) && + Objects.equals(o.type, type) && StringUtils.equals(o.externalId, externalId)); } -- GitLab From 55730a8021eb865f3644c3867a9d648d3a3306e3 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 21 Jun 2024 11:58:56 +0200 Subject: [PATCH 36/58] Remove static ScimDispatcher map --- .../java/sh/libre/scim/core/ScimDispatcher.java | 13 +------------ .../libre/scim/event/ScimEventListenerProvider.java | 2 +- ...EndpointConfigurationStorageProviderFactory.java | 6 ++++-- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index c46740d..bf06dbc 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -6,11 +6,8 @@ import org.keycloak.models.KeycloakSession; import sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; /** @@ -20,20 +17,13 @@ public class ScimDispatcher { private static final Logger logger = Logger.getLogger(ScimDispatcher.class); - private static final Map sessionToScimDispatcher = new ConcurrentHashMap<>(); private final KeycloakSession session; private boolean clientsInitialized = false; private final List userScimServices = new ArrayList<>(); private final List groupScimServices = new ArrayList<>(); - public static ScimDispatcher createForSession(KeycloakSession session) { - // Only create a scim dispatcher if there is none already created for session - sessionToScimDispatcher.computeIfAbsent(session, ScimDispatcher::new); - return sessionToScimDispatcher.get(session); - } - - private ScimDispatcher(KeycloakSession session) { + public ScimDispatcher(KeycloakSession session) { this.session = session; } @@ -117,7 +107,6 @@ public class ScimDispatcher { } public void close() { - sessionToScimDispatcher.remove(session); for (GroupScimService c : groupScimServices) { c.close(); } diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 1fe84ad..e9635d4 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -46,7 +46,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { public ScimEventListenerProvider(KeycloakSession session) { this.session = session; this.keycloackDao = new KeycloakDao(session); - this.dispatcher = ScimDispatcher.createForSession(session); + this.dispatcher = new ScimDispatcher(session); } @Override diff --git a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java index bac304e..748e18b 100644 --- a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java @@ -48,13 +48,14 @@ public class ScimEndpointConfigurationStorageProviderFactory KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { RealmModel realm = session.realms().getRealm(realmId); session.getContext().setRealm(realm); - ScimDispatcher dispatcher = ScimDispatcher.createForSession(session); + ScimDispatcher dispatcher = new ScimDispatcher(session); if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); } if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); } + dispatcher.close(); }); return result; } @@ -74,13 +75,14 @@ public class ScimEndpointConfigurationStorageProviderFactory for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { KeycloakModelUtils.runJobInTransaction(factory, session -> { session.getContext().setRealm(realm); - ScimDispatcher dispatcher = ScimDispatcher.createForSession(session); + ScimDispatcher dispatcher = new ScimDispatcher(session); 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)); group.removeAttribute("scim-dirty"); } + dispatcher.close(); }); } }, Duration.ofSeconds(30).toMillis(), "scim-background"); -- GitLab From 080cc8c2737045e47ec722280553ab878355b1ad Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 21 Jun 2024 14:11:10 +0200 Subject: [PATCH 37/58] Revert "Type "type" column in Hibernate" This reverts commit 0bf5aa1a76aeece12b71b14bedc672850671a794. --- src/main/java/sh/libre/scim/jpa/ScimResource.java | 7 +++---- src/main/java/sh/libre/scim/jpa/ScimResourceDao.java | 4 ++-- src/main/java/sh/libre/scim/jpa/ScimResourceId.java | 11 +++++------ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java index cacc92e..5536a6d 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResource.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java @@ -9,7 +9,6 @@ import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; import sh.libre.scim.core.EntityOnRemoteScimId; import sh.libre.scim.core.KeycloakId; -import sh.libre.scim.core.ScimResourceType; @Entity @IdClass(ScimResourceId.class) @@ -34,7 +33,7 @@ public class ScimResource { @Id @Column(name = "TYPE", nullable = false) - private ScimResourceType type; + private String type; @Id @Column(name = "EXTERNAL_ID", nullable = false) @@ -72,11 +71,11 @@ public class ScimResource { this.externalId = externalId; } - public ScimResourceType getType() { + public String getType() { return type; } - public void setType(ScimResourceType type) { + public void setType(String type) { this.type = type; } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java index 02a473c..7d96b28 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -45,7 +45,7 @@ public class ScimResourceDao { public void create(KeycloakId id, EntityOnRemoteScimId externalId, ScimResourceType type) { ScimResource entity = new ScimResource(); - entity.setType(type); + entity.setType(type.name()); entity.setExternalId(externalId.asString()); entity.setComponentId(componentId); entity.setRealmId(realmId); @@ -56,7 +56,7 @@ public class ScimResourceDao { private TypedQuery getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) { return getEntityManager() .createNamedQuery(queryName, ScimResource.class) - .setParameter("type", type) + .setParameter("type", type.name()) .setParameter("realmId", getRealmId()) .setParameter("componentId", getComponentId()) .setParameter("id", id); diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java index bfb4424..99bce76 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -1,7 +1,6 @@ package sh.libre.scim.jpa; import org.apache.commons.lang3.StringUtils; -import sh.libre.scim.core.ScimResourceType; import java.io.Serializable; import java.util.Objects; @@ -10,13 +9,13 @@ public class ScimResourceId implements Serializable { private String id; private String realmId; private String componentId; - private ScimResourceType type; + private String type; private String externalId; public ScimResourceId() { } - public ScimResourceId(String id, String realmId, String componentId, ScimResourceType type, String externalId) { + public ScimResourceId(String id, String realmId, String componentId, String type, String externalId) { this.setId(id); this.setRealmId(realmId); this.setComponentId(componentId); @@ -48,11 +47,11 @@ public class ScimResourceId implements Serializable { this.componentId = componentId; } - public ScimResourceType getType() { + public String getType() { return type; } - public void setType(ScimResourceType type) { + public void setType(String type) { this.type = type; } @@ -75,7 +74,7 @@ public class ScimResourceId implements Serializable { return (StringUtils.equals(o.id, id) && StringUtils.equals(o.realmId, realmId) && StringUtils.equals(o.componentId, componentId) && - Objects.equals(o.type, type) && + StringUtils.equals(o.type, type) && StringUtils.equals(o.externalId, externalId)); } -- GitLab From 5aa9410bacd57031fd6b573d3eca09d8a0412cb4 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 21 Jun 2024 14:22:04 +0200 Subject: [PATCH 38/58] Improve logging --- src/main/java/sh/libre/scim/core/AbstractScimService.java | 4 ++-- src/main/java/sh/libre/scim/core/ScimDispatcher.java | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 53a6738..1bad0d8 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -78,7 +78,7 @@ public abstract class AbstractScimService operationToDispatch) { initializeClientsIfNeeded(); userScimServices.forEach(operationToDispatch); - logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimServices.size()); + logger.infof("[SCIM] User operation dispatched to %d SCIM server", userScimServices.size()); } public void dispatchGroupModificationToAll(Consumer operationToDispatch) { initializeClientsIfNeeded(); groupScimServices.forEach(operationToDispatch); - logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimServices.size()); + logger.infof("[SCIM] Group operation dispatched to %d SCIM server", groupScimServices.size()); } public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { @@ -87,7 +87,7 @@ public class ScimDispatcher { Optional 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()); + logger.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); } else { logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); } @@ -100,7 +100,7 @@ public class ScimDispatcher { Optional 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()); + logger.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); } else { logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); } -- GitLab From 81d9f5424ca7f7c579480161d13a782284337714 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 21 Jun 2024 15:25:47 +0200 Subject: [PATCH 39/58] Rollback on exception --- src/main/java/sh/libre/scim/core/AbstractScimService.java | 4 ++-- src/main/java/sh/libre/scim/core/ScimClient.java | 6 +++--- .../java/sh/libre/scim/core/ScimPropagationException.java | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 src/main/java/sh/libre/scim/core/ScimPropagationException.java diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 1bad0d8..4e42351 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -44,12 +44,12 @@ public abstract class AbstractScimService implements AutoCloseable { return new ScimClient(scimRequestBuilder, scimResourceType); } - public EntityOnRemoteScimId create(KeycloakId id, ResourceNode scimForCreation) { + public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) { if (scimForCreation.getId().isPresent()) { throw new IllegalArgumentException( "%s is already created on remote with id %s".formatted(id, scimForCreation.getId().get()) @@ -97,8 +96,9 @@ public class ScimClient implements AutoCloseable { return scimResourceType.getResourceClass(); } - public void replace(EntityOnRemoteScimId externalId, ResourceNode scimForReplace) { + public void replace(EntityOnRemoteScimId externalId, S scimForReplace) { Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); + logger.warn(scimForReplace); ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder .update(getResourceClass(), getScimEndpoint(), externalId.asString()) .setResource(scimForReplace) diff --git a/src/main/java/sh/libre/scim/core/ScimPropagationException.java b/src/main/java/sh/libre/scim/core/ScimPropagationException.java new file mode 100644 index 0000000..145c7c4 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimPropagationException.java @@ -0,0 +1,4 @@ +package sh.libre.scim.core; + +public class ScimPropagationException { +} -- GitLab From 0cac71c4a1c32f7778ff1dc4e68bdc6962394cef Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 21 Jun 2024 15:30:24 +0200 Subject: [PATCH 40/58] Rollback on ScimPropagationException --- .../libre/scim/core/AbstractScimService.java | 31 ++++---- .../java/sh/libre/scim/core/ScimClient.java | 8 +-- .../sh/libre/scim/core/ScimDispatcher.java | 70 +++++++++++++++---- .../scim/core/ScimPropagationException.java | 10 ++- 4 files changed, 88 insertions(+), 31 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 4e42351..8f92a22 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -35,7 +35,7 @@ public abstract class AbstractScimService { 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); + try { + findById(id).get(); + LOGGER.info("Replacing it"); + replace(resource); + } catch (NoSuchElementException e) { + LOGGER.info("Creating it"); + create(resource); + } + syncRes.increaseUpdated(); + } catch (ScimPropagationException e) { + // TODO handle exception } - syncRes.increaseUpdated(); } }); } @@ -187,7 +192,7 @@ public abstract class AbstractScimService implements AutoCloseable { - private final Logger logger = Logger.getLogger(ScimClient.class); + private static final Logger LOGGER = Logger.getLogger(ScimClient.class); private final RetryRegistry retryRegistry; @@ -83,8 +83,8 @@ public class ScimClient implements AutoCloseable { private void checkResponseIsSuccess(ServerResponse response) { if (!response.isSuccess()) { - logger.warn(response.getResponseBody()); - logger.warn(response.getHttpStatus()); + LOGGER.warn(response.getResponseBody()); + LOGGER.warn(response.getHttpStatus()); } } @@ -98,7 +98,7 @@ public class ScimClient implements AutoCloseable { public void replace(EntityOnRemoteScimId externalId, S scimForReplace) { Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); - logger.warn(scimForReplace); + LOGGER.warn(scimForReplace); ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder .update(getResourceClass(), getScimEndpoint(), externalId.asString()) .setResource(scimForReplace) diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 6804c06..6be68dc 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -6,9 +6,10 @@ import org.keycloak.models.KeycloakSession; import sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; -import java.util.function.Consumer; +import java.util.Set; /** * In charge of sending SCIM Request to all registered Scim endpoints. @@ -69,38 +70,64 @@ public class ScimDispatcher { } } - public void dispatchUserModificationToAll(Consumer operationToDispatch) { + public void dispatchUserModificationToAll(SCIMPropagationConsumer operationToDispatch) { initializeClientsIfNeeded(); - userScimServices.forEach(operationToDispatch); - logger.infof("[SCIM] User operation dispatched to %d SCIM server", userScimServices.size()); + Set servicesCorrectlyPropagated = new LinkedHashSet<>(); + userScimServices.forEach(userScimService -> { + try { + operationToDispatch.acceptThrows(userScimService); + servicesCorrectlyPropagated.add(userScimService); + } catch (ScimPropagationException e) { + logAndRollback(userScimService.getConfiguration(), e); + } + }); + // TODO we could iterate on servicesCorrectlyPropagated to undo modification + logger.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); } - public void dispatchGroupModificationToAll(Consumer operationToDispatch) { + public void dispatchGroupModificationToAll(SCIMPropagationConsumer operationToDispatch) { initializeClientsIfNeeded(); - groupScimServices.forEach(operationToDispatch); - logger.infof("[SCIM] Group operation dispatched to %d SCIM server", groupScimServices.size()); + Set servicesCorrectlyPropagated = new LinkedHashSet<>(); + groupScimServices.forEach(groupScimService -> { + try { + operationToDispatch.acceptThrows(groupScimService); + servicesCorrectlyPropagated.add(groupScimService); + } catch (ScimPropagationException e) { + logAndRollback(groupScimService.getConfiguration(), e); + } + }); + // TODO we could iterate on servicesCorrectlyPropagated to undo modification + logger.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); } - public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { + public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer operationToDispatch) { initializeClientsIfNeeded(); // Scim client should already have been created Optional 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 server %s", matchingClient.get().getConfiguration().getId()); + try { + operationToDispatch.acceptThrows(matchingClient.get()); + logger.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); + } catch (ScimPropagationException e) { + logAndRollback(matchingClient.get().getConfiguration(), e); + } } else { logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); } } - public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer operationToDispatch) { + public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer operationToDispatch) { initializeClientsIfNeeded(); // Scim client should already have been created Optional 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 server %s", matchingClient.get().getConfiguration().getId()); + try { + operationToDispatch.acceptThrows(matchingClient.get()); + logger.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); + } catch (ScimPropagationException e) { + logAndRollback(matchingClient.get().getConfiguration(), e); + } } else { logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); } @@ -117,10 +144,27 @@ public class ScimDispatcher { userScimServices.clear(); } + private void logAndRollback(ScrimProviderConfiguration scimServerConfiguration, ScimPropagationException e) { + logger.error("[SCIM] Error while propagating to SCIM endpoint " + scimServerConfiguration.getId(), e); + session.getTransactionManager().rollback(); + } + private void initializeClientsIfNeeded() { if (!clientsInitialized) { clientsInitialized = true; refreshActiveScimEndpoints(); } } + + /** + * A Consumer that throws ScimPropagationException. + * + * @param An {@link AbstractScimService to call} + */ + @FunctionalInterface + public interface SCIMPropagationConsumer { + + void acceptThrows(T elem) throws ScimPropagationException; + + } } diff --git a/src/main/java/sh/libre/scim/core/ScimPropagationException.java b/src/main/java/sh/libre/scim/core/ScimPropagationException.java index 145c7c4..135b555 100644 --- a/src/main/java/sh/libre/scim/core/ScimPropagationException.java +++ b/src/main/java/sh/libre/scim/core/ScimPropagationException.java @@ -1,4 +1,12 @@ package sh.libre.scim.core; -public class ScimPropagationException { +public class ScimPropagationException extends Exception { + + public ScimPropagationException(String message) { + super(message); + } + + public ScimPropagationException(String message, Exception e) { + super(message, e); + } } -- GitLab From cce36ab990d9bd4caf8aeec46718a555c100c1fc Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 21 Jun 2024 17:13:04 +0200 Subject: [PATCH 41/58] Fix /Users URI for group membership changes --- src/main/java/sh/libre/scim/core/GroupScimService.java | 5 +++-- src/main/java/sh/libre/scim/core/ScimClient.java | 2 +- src/main/java/sh/libre/scim/core/ScimDispatcher.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java index 0648f10..99c8c5c 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimService.java +++ b/src/main/java/sh/libre/scim/core/GroupScimService.java @@ -99,13 +99,14 @@ public class GroupScimService extends AbstractScimService { logger.debug(userMapping.getExternalIdAsEntityOnRemoteScimId()); logger.debug(userMapping.getIdAsKeycloakId()); groupMember.setValue(userMapping.getExternalIdAsEntityOnRemoteScimId().asString()); - URI ref = new URI(String.format("Users/%s", userMapping.getExternalIdAsEntityOnRemoteScimId())); + String refString = String.format("Users/%s", userMapping.getExternalIdAsEntityOnRemoteScimId().asString()); + URI ref = new URI(refString); 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"); + logger.warnf("bad ref uri for member " + member); } } return group; diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 22bfa0e..9189ad3 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -83,6 +83,7 @@ public class ScimClient implements AutoCloseable { private void checkResponseIsSuccess(ServerResponse response) { if (!response.isSuccess()) { + LOGGER.warn("[SCIM] Issue on SCIM Server response "); LOGGER.warn(response.getResponseBody()); LOGGER.warn(response.getHttpStatus()); } @@ -98,7 +99,6 @@ public class ScimClient implements AutoCloseable { public void replace(EntityOnRemoteScimId externalId, S scimForReplace) { Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); - LOGGER.warn(scimForReplace); ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder .update(getResourceClass(), getScimEndpoint(), externalId.asString()) .setResource(scimForReplace) diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 6be68dc..8154bb3 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -146,7 +146,7 @@ public class ScimDispatcher { private void logAndRollback(ScrimProviderConfiguration scimServerConfiguration, ScimPropagationException e) { logger.error("[SCIM] Error while propagating to SCIM endpoint " + scimServerConfiguration.getId(), e); - session.getTransactionManager().rollback(); + // TODO session.getTransactionManager().rollback(); } private void initializeClientsIfNeeded() { -- GitLab From 8e97335de4af41d2face06f35b18633a197b1780 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Mon, 24 Jun 2024 09:56:16 +0200 Subject: [PATCH 42/58] Improve error messages and logs --- src/main/java/sh/libre/scim/core/AbstractScimService.java | 2 +- src/main/java/sh/libre/scim/core/ScimDispatcher.java | 2 +- .../java/sh/libre/scim/event/ScimEventListenerProvider.java | 2 +- .../ScimEndpointConfigurationStorageProviderFactory.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 8f92a22..a0939ad 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -97,7 +97,7 @@ public abstract class AbstractScimService ScimResourceType.USER; case "groups" -> ScimResourceType.GROUP; - default -> throw new IllegalArgumentException("Unsuported resource type : " + rawResourceType); + default -> throw new IllegalArgumentException("Unsuported resource type: " + rawResourceType); }; KeycloakId id = new KeycloakId(matcher.group(2)); handleRoleMappingEvent(event, type, id); diff --git a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java index 748e18b..00a527f 100644 --- a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java @@ -78,7 +78,7 @@ public class ScimEndpointConfigurationStorageProviderFactory ScimDispatcher dispatcher = new ScimDispatcher(session); 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()); + logger.infof("[SCIM] Dirty group: %s", group.getName()); dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); group.removeAttribute("scim-dirty"); } -- GitLab From ad60363cbdce5eaef9f177f13e26dd8fef37d502 Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Mon, 24 Jun 2024 11:39:32 +0200 Subject: [PATCH 43/58] Remove all unsafe Optional.get calls --- .../libre/scim/core/AbstractScimService.java | 86 ++++++++++--------- .../sh/libre/scim/core/GroupScimService.java | 65 +++++++------- .../sh/libre/scim/core/UserScimService.java | 44 ++++------ 3 files changed, 94 insertions(+), 101 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index a0939ad..9586a23 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -12,8 +12,9 @@ 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.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; public abstract class AbstractScimService implements AutoCloseable { @@ -76,52 +77,55 @@ public abstract class AbstractScimService entityOnRemoteScimId = findById(id) + .map(ScimResource::getExternalIdAsEntityOnRemoteScimId); + entityOnRemoteScimId + .ifPresentOrElse( + externalId -> doReplace(roleMapperModel, externalId), + () -> LOGGER.warnf("failed to replace resource %s, scim mapping not found", id) + ); } catch (Exception e) { LOGGER.error(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 S toScimForReplace(RMM roleMapperModel, EntityOnRemoteScimId externalId); public void delete(KeycloakId id) throws ScimPropagationException { - try { - ScimResource resource = findById(id).get(); - EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId(); - scimClient.delete(externalId); - getScimResourceDao().delete(resource); - } catch (NoSuchElementException e) { - throw new ScimPropagationException("Failed to delete resource %s, scim mapping not found: ".formatted(id), e); - } + 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); } public void refreshResources(SynchronizationResult syncRes) throws ScimPropagationException { LOGGER.info("Refresh resources"); - getResourceStream().forEach(resource -> { - KeycloakId id = getId(resource); - LOGGER.infof("Reconciling local resource %s", id); - if (!isSkipRefresh(resource)) { - try { - try { - findById(id).get(); - LOGGER.info("Replacing it"); - replace(resource); - } catch (NoSuchElementException e) { - LOGGER.info("Creating it"); - create(resource); - } - syncRes.increaseUpdated(); - } catch (ScimPropagationException e) { - // TODO handle exception + try (Stream resourcesStream = getResourceStream()) { + Set resources = resourcesStream.collect(Collectors.toUnmodifiableSet()); + for (RMM resource : resources) { + KeycloakId id = getId(resource); + LOGGER.infof("Reconciling local resource %s", id); + if (isSkipRefresh(resource)) { + LOGGER.infof("Skip local resource %s", id); + continue; } + if (findById(id).isPresent()) { + LOGGER.info("Replacing it"); + replace(resource); + } else { + LOGGER.info("Creating it"); + create(resource); + } + syncRes.increaseUpdated(); } - }); + } } protected abstract boolean isSkipRefresh(RMM resource); @@ -130,14 +134,16 @@ public abstract class AbstractScimService 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(); + EntityOnRemoteScimId externalId = resource.getId() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new ScimPropagationException("remote SCIM resource doesn't have an id")); + Optional optionalMapping = getScimResourceDao().findByExternalId(externalId, type); + if (optionalMapping.isPresent()) { + ScimResource mapping = optionalMapping.get(); if (entityExists(mapping.getIdAsKeycloakId())) { LOGGER.info("Valid mapping found, skipping"); continue; @@ -145,8 +151,6 @@ public abstract class AbstractScimService mapped = tryToMap(resource); @@ -167,7 +171,7 @@ public abstract class AbstractScimService tryToMap(S resource); + protected abstract Optional tryToMap(S resource) throws ScimPropagationException; protected abstract boolean entityExists(KeycloakId keycloakId); diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java index 99c8c5c..687d52a 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimService.java +++ b/src/main/java/sh/libre/scim/core/GroupScimService.java @@ -3,9 +3,9 @@ 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.apache.commons.lang3.StringUtils; import org.jboss.logging.Logger; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; @@ -15,9 +15,9 @@ 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.TreeSet; import java.util.stream.Stream; public class GroupScimService extends AbstractScimService { @@ -39,9 +39,9 @@ public class GroupScimService extends AbstractScimService { @Override protected Optional tryToMap(Group resource) { - String externalId = resource.getId().get(); - String displayName = resource.getDisplayName().get(); - Set names = Set.of(externalId, displayName); + Set names = new TreeSet<>(); + resource.getId().ifPresent(names::add); + resource.getDisplayName().ifPresent(names::add); Optional group = getKeycloakDao().getGroupsStream() .filter(groupModel -> names.contains(groupModel.getName())) .findFirst(); @@ -49,28 +49,22 @@ public class GroupScimService extends AbstractScimService { } @Override - protected KeycloakId createEntity(Group resource) { - String displayName = resource.getDisplayName().get(); + protected KeycloakId createEntity(Group resource) throws ScimPropagationException { + String displayName = resource.getDisplayName() + .filter(StringUtils::isNotBlank) + .orElseThrow(() -> new ScimPropagationException("can't create group without name: " + resource)); GroupModel group = getKeycloakDao().createGroup(displayName); List 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()); - } + EntityOnRemoteScimId externalId = groupMember.getValue() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new ScimPropagationException("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))); + UserModel userModel = getKeycloakDao().getUserById(userId); + userModel.joinGroup(group); } } return new KeycloakId(group.getId()); @@ -94,19 +88,22 @@ public class GroupScimService extends AbstractScimService { 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()); - String refString = String.format("Users/%s", userMapping.getExternalIdAsEntityOnRemoteScimId().asString()); - URI ref = new URI(refString); - groupMember.setRef(ref.toString()); + Optional optionalGroupMemberMapping = getScimResourceDao().findUserById(member); + if (optionalGroupMemberMapping.isPresent()) { + ScimResource groupMemberMapping = optionalGroupMemberMapping.get(); + EntityOnRemoteScimId externalIdAsEntityOnRemoteScimId = groupMemberMapping.getExternalIdAsEntityOnRemoteScimId(); + logger.debugf("found mapping for group member %s as %s", member, externalIdAsEntityOnRemoteScimId); + groupMember.setValue(externalIdAsEntityOnRemoteScimId.asString()); + try { + String refString = String.format("Users/%s", externalIdAsEntityOnRemoteScimId.asString()); + URI ref = new URI(refString); + groupMember.setRef(ref.toString()); + } catch (URISyntaxException e) { + logger.warnf("bad ref uri for member " + member); + } group.addMember(groupMember); - } catch (NoSuchElementException e) { + } else { logger.warnf("member %s not found for group %s", member, groupModel.getId()); - } catch (URISyntaxException e) { - logger.warnf("bad ref uri for member " + member); } } return group; diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/UserScimService.java index 9a65849..3c30023 100644 --- a/src/main/java/sh/libre/scim/core/UserScimService.java +++ b/src/main/java/sh/libre/scim/core/UserScimService.java @@ -40,45 +40,37 @@ public class UserScimService extends AbstractScimService { @Override protected Optional tryToMap(User resource) { - String username = resource.getUserName().get(); - String email = resource.getEmails().stream() + Optional matchedByUsername = resource.getUserName() + .map(getKeycloakDao()::getUserByUsername) + .map(this::getId); + Optional matchedByEmail = 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); + .map(getKeycloakDao()::getUserByEmail) + .map(this::getId); + if (matchedByUsername.isPresent() + && matchedByEmail.isPresent() + && !matchedByUsername.equals(matchedByEmail)) { + logger.warnf("found 2 possible users for remote user %s %s", matchedByUsername.get(), matchedByEmail.get()); return Optional.empty(); } - if (sameUsernameUser != null) { - return Optional.of(getId(sameUsernameUser)); + if (matchedByUsername.isPresent()) { + return matchedByUsername; } - if (sameEmailUser != null) { - return Optional.of(getId(sameEmailUser)); - } - return Optional.empty(); + return matchedByEmail; } @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"); - } + protected KeycloakId createEntity(User resource) throws ScimPropagationException { + String username = resource.getUserName() + .filter(StringUtils::isNotBlank) + .orElseThrow(() -> new ScimPropagationException("can't create user with empty username, 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().get()); + user.setEnabled(resource.isActive().orElseThrow(() -> new ScimPropagationException("can't create user with undefined 'active', resource id = %s".formatted(resource.getId())))); return new KeycloakId(user.getId()); } -- GitLab From 54a2f10114dc26568b3fc5d44d0d7ac7a07335af Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Mon, 24 Jun 2024 15:00:09 +0200 Subject: [PATCH 44/58] Factorize URI creations --- .../java/sh/libre/scim/core/AbstractScimService.java | 12 ++++++++---- .../java/sh/libre/scim/core/GroupScimService.java | 10 ++-------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 9586a23..1d32e32 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -207,13 +207,17 @@ public abstract class AbstractScimService { EntityOnRemoteScimId externalIdAsEntityOnRemoteScimId = groupMemberMapping.getExternalIdAsEntityOnRemoteScimId(); logger.debugf("found mapping for group member %s as %s", member, externalIdAsEntityOnRemoteScimId); groupMember.setValue(externalIdAsEntityOnRemoteScimId.asString()); - try { - String refString = String.format("Users/%s", externalIdAsEntityOnRemoteScimId.asString()); - URI ref = new URI(refString); - groupMember.setRef(ref.toString()); - } catch (URISyntaxException e) { - logger.warnf("bad ref uri for member " + member); - } + URI ref = getUri(ScimResourceType.USER, externalIdAsEntityOnRemoteScimId); + groupMember.setRef(ref.toString()); group.addMember(groupMember); } else { logger.warnf("member %s not found for group %s", member, groupModel.getId()); -- GitLab From 51d4837c19a4fff874e9eda4530dad40e7882cae Mon Sep 17 00:00:00 2001 From: Brendan Le Ny Date: Mon, 24 Jun 2024 15:46:56 +0200 Subject: [PATCH 45/58] Close stream --- .../java/sh/libre/scim/core/GroupScimService.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java index 4245b24..e5b2315 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimService.java +++ b/src/main/java/sh/libre/scim/core/GroupScimService.java @@ -41,10 +41,14 @@ public class GroupScimService extends AbstractScimService { Set names = new TreeSet<>(); resource.getId().ifPresent(names::add); resource.getDisplayName().ifPresent(names::add); - Optional group = getKeycloakDao().getGroupsStream() - .filter(groupModel -> names.contains(groupModel.getName())) - .findFirst(); - return group.map(GroupModel::getId).map(KeycloakId::new); + try (Stream groupsStream = getKeycloakDao().getGroupsStream()) { + Optional group = groupsStream + .filter(groupModel -> names.contains(groupModel.getName())) + .findFirst(); + return group + .map(GroupModel::getId) + .map(KeycloakId::new); + } } @Override -- GitLab From 72e597a09ca26f48de717dd77e4cfd98c9913aba Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 25 Jun 2024 09:14:13 +0200 Subject: [PATCH 46/58] Exception Handler - Step 1: basic wiring --- .../libre/scim/core/AbstractScimService.java | 10 ++++-- .../java/sh/libre/scim/core/ScimClient.java | 10 +++--- .../sh/libre/scim/core/ScimDispatcher.java | 19 +++++------- .../libre/scim/core/ScimExceptionHandler.java | 31 +++++++++++++++++++ .../scim/event/ScimEventListenerProvider.java | 2 +- 5 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 src/main/java/sh/libre/scim/core/ScimExceptionHandler.java diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 1d32e32..637283c 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -132,7 +132,7 @@ public abstract class AbstractScimService getResourceStream(); - public void importResources(SynchronizationResult syncRes) { + public void importResources(SynchronizationResult syncRes) throws ScimPropagationException { LOGGER.info("Import"); try { for (S resource : scimClient.listResources()) { @@ -180,13 +180,17 @@ public abstract class AbstractScimService implements AutoCloseable { private static final Logger LOGGER = Logger.getLogger(ScimClient.class); @@ -62,10 +63,11 @@ public class ScimClient implements AutoCloseable { return new ScimClient(scimRequestBuilder, scimResourceType); } - public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) { - if (scimForCreation.getId().isPresent()) { - throw new IllegalArgumentException( - "%s is already created on remote with id %s".formatted(id, scimForCreation.getId().get()) + public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws ScimPropagationException { + Optional scimForCreationId = scimForCreation.getId(); + if (scimForCreationId.isPresent()) { + throw new ScimPropagationException( + "%s is already created on remote with id %s".formatted(id, scimForCreationId.get()) ); } Retry retry = retryRegistry.retry("create-%s".formatted(id.asString())); diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 62a8bfb..54c4843 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -19,6 +19,7 @@ public class ScimDispatcher { private static final Logger logger = Logger.getLogger(ScimDispatcher.class); private final KeycloakSession session; + private final ScimExceptionHandler exceptionHandler; private boolean clientsInitialized = false; private final List userScimServices = new ArrayList<>(); private final List groupScimServices = new ArrayList<>(); @@ -26,6 +27,7 @@ public class ScimDispatcher { public ScimDispatcher(KeycloakSession session) { this.session = session; + this.exceptionHandler = new ScimExceptionHandler(session); } /** @@ -78,7 +80,7 @@ public class ScimDispatcher { operationToDispatch.acceptThrows(userScimService); servicesCorrectlyPropagated.add(userScimService); } catch (ScimPropagationException e) { - logAndRollback(userScimService.getConfiguration(), e); + exceptionHandler.handleException(userScimService.getConfiguration(), e); } }); // TODO we could iterate on servicesCorrectlyPropagated to undo modification @@ -93,7 +95,7 @@ public class ScimDispatcher { operationToDispatch.acceptThrows(groupScimService); servicesCorrectlyPropagated.add(groupScimService); } catch (ScimPropagationException e) { - logAndRollback(groupScimService.getConfiguration(), e); + exceptionHandler.handleException(groupScimService.getConfiguration(), e); } }); // TODO we could iterate on servicesCorrectlyPropagated to undo modification @@ -109,10 +111,10 @@ public class ScimDispatcher { operationToDispatch.acceptThrows(matchingClient.get()); logger.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); } catch (ScimPropagationException e) { - logAndRollback(matchingClient.get().getConfiguration(), e); + exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); } } else { - logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); + logger.error("[SCIM] Could not find a Scim Client matching User endpoint configuration" + scimServerConfiguration.getId()); } } @@ -126,10 +128,10 @@ public class ScimDispatcher { operationToDispatch.acceptThrows(matchingClient.get()); logger.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); } catch (ScimPropagationException e) { - logAndRollback(matchingClient.get().getConfiguration(), e); + exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); } } else { - logger.error("[SCIM] Could not find a Scim Client matching endpoint configuration" + scimServerConfiguration.getId()); + logger.error("[SCIM] Could not find a Scim Client matching Group endpoint configuration" + scimServerConfiguration.getId()); } } @@ -144,11 +146,6 @@ public class ScimDispatcher { userScimServices.clear(); } - private void logAndRollback(ScrimProviderConfiguration scimServerConfiguration, ScimPropagationException e) { - logger.error("[SCIM] Error while propagating to SCIM endpoint " + scimServerConfiguration.getId(), e); - // TODO session.getTransactionManager().rollback(); - } - private void initializeClientsIfNeeded() { if (!clientsInitialized) { clientsInitialized = true; diff --git a/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java b/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java new file mode 100644 index 0000000..44d65ff --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java @@ -0,0 +1,31 @@ +package sh.libre.scim.core; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; + +/** + * In charge of dealing with SCIM exceptions by ignoring, logging or rollback transaction according to : + * - The context in which it occurs (sync, user creation...) + * - The related SCIM endpoint and its configuration + * - The thrown exception itself + */ +public class ScimExceptionHandler { + + private static final Logger logger = Logger.getLogger(ScimDispatcher.class); + private final KeycloakSession session; + + public ScimExceptionHandler(KeycloakSession session) { + this.session = session; + } + + /** + * Handles the given exception by loggin and/or rollback transaction. + * + * @param scimProviderConfiguration the configuration of the endpoint for which the propagation exception occured + * @param e the occuring exception + */ + public void handleException(ScrimProviderConfiguration scimProviderConfiguration, ScimPropagationException e) { + logger.error("[SCIM] Error while propagating to SCIM endpoint " + scimProviderConfiguration.getId(), e); + // TODO session.getTransactionManager().rollback(); + } +} diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 06a01db..4195e6b 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -107,7 +107,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { ScimResourceType type = switch (rawResourceType) { case "users" -> ScimResourceType.USER; case "groups" -> ScimResourceType.GROUP; - default -> throw new IllegalArgumentException("Unsuported resource type: " + rawResourceType); + default -> throw new IllegalArgumentException("Unsupported resource type: " + rawResourceType); }; KeycloakId id = new KeycloakId(matcher.group(2)); handleRoleMappingEvent(event, type, id); -- GitLab From 399dfbd17ed6c591a27f2c94313b06a2a04afd8f Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 25 Jun 2024 09:36:20 +0200 Subject: [PATCH 47/58] Exception Handler - Step 2: logging uniformisation and add //TODOs --- .../libre/scim/core/AbstractScimService.java | 37 +++++----- .../sh/libre/scim/core/GroupScimService.java | 6 +- .../java/sh/libre/scim/core/ScimClient.java | 1 + .../sh/libre/scim/core/ScimDispatcher.java | 74 ++++++++----------- .../libre/scim/core/ScimExceptionHandler.java | 14 +++- .../sh/libre/scim/core/UserScimService.java | 13 ++-- .../scim/event/ScimEventListenerProvider.java | 6 +- ...ntConfigurationStorageProviderFactory.java | 6 +- 8 files changed, 78 insertions(+), 79 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 637283c..b72bef6 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -82,10 +82,12 @@ public abstract class AbstractScimService doReplace(roleMapperModel, externalId), - () -> LOGGER.warnf("failed to replace resource %s, scim mapping not found", id) + () -> { + // TODO Exception Handling : should we throw a ScimPropagationException here ? + LOGGER.warnf("failed to replace resource %s, scim mapping not found", id); + } ); } catch (Exception e) { - LOGGER.error(e); throw new ScimPropagationException("[SCIM] Error while replacing SCIM resource", e); } } @@ -106,21 +108,21 @@ public abstract class AbstractScimService resourcesStream = getResourceStream()) { Set resources = resourcesStream.collect(Collectors.toUnmodifiableSet()); for (RMM resource : resources) { KeycloakId id = getId(resource); - LOGGER.infof("Reconciling local resource %s", id); + LOGGER.infof("[SCIM] Reconciling local resource %s", id); if (isSkipRefresh(resource)) { - LOGGER.infof("Skip local resource %s", id); + LOGGER.infof("[SCIM] Skip local resource %s", id); continue; } if (findById(id).isPresent()) { - LOGGER.info("Replacing it"); + LOGGER.info("[SCIM] Replacing it"); replace(resource); } else { - LOGGER.info("Creating it"); + LOGGER.info("[SCIM] Creating it"); create(resource); } syncRes.increaseUpdated(); @@ -133,11 +135,11 @@ public abstract class AbstractScimService getResourceStream(); public void importResources(SynchronizationResult syncRes) throws ScimPropagationException { - LOGGER.info("Import"); + LOGGER.info("[SCIM] Import resources for scim endpoint " + this.getConfiguration().getEndPoint()); try { for (S resource : scimClient.listResources()) { try { - LOGGER.infof("Reconciling remote resource %s", resource); + 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")); @@ -145,49 +147,50 @@ public abstract class AbstractScimService mapped = tryToMap(resource); if (mapped.isPresent()) { - LOGGER.info("Matched"); + LOGGER.info("[SCIM] Matched"); createMapping(mapped.get(), externalId); } else { switch (scimProviderConfiguration.getImportAction()) { case CREATE_LOCAL: - LOGGER.info("Create local resource"); + 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("Delete remote resource"); + LOGGER.info("[SCIM] Delete remote resource"); this.scimClient.delete(externalId); syncRes.increaseRemoved(); break; case NOTHING: - LOGGER.info("Import action set to NOTHING"); + LOGGER.info("[SCIM] Import action set to NOTHING"); break; } } } catch (Exception e) { - // TODO should we stop and throw ScimPropagationException here ? + // TODO ExceptionHandling should we stop and throw ScimPropagationException here ? LOGGER.error(e); e.printStackTrace(); syncRes.increaseFailed(); } } } catch (ResponseException e) { - // TODO should we stop and throw ScimPropagationException here ? + // TODO ExceptionHandling should we stop and throw ScimPropagationException here ? LOGGER.error(e); e.printStackTrace(); syncRes.increaseFailed(); diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java index e5b2315..c448a9a 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimService.java +++ b/src/main/java/sh/libre/scim/core/GroupScimService.java @@ -20,7 +20,7 @@ import java.util.TreeSet; import java.util.stream.Stream; public class GroupScimService extends AbstractScimService { - private final Logger logger = Logger.getLogger(GroupScimService.class); + private final Logger LOGGER = Logger.getLogger(GroupScimService.class); public GroupScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) { super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP); @@ -95,13 +95,13 @@ public class GroupScimService extends AbstractScimService { if (optionalGroupMemberMapping.isPresent()) { ScimResource groupMemberMapping = optionalGroupMemberMapping.get(); EntityOnRemoteScimId externalIdAsEntityOnRemoteScimId = groupMemberMapping.getExternalIdAsEntityOnRemoteScimId(); - logger.debugf("found mapping for group member %s as %s", member, externalIdAsEntityOnRemoteScimId); groupMember.setValue(externalIdAsEntityOnRemoteScimId.asString()); URI ref = getUri(ScimResourceType.USER, externalIdAsEntityOnRemoteScimId); groupMember.setRef(ref.toString()); group.addMember(groupMember); } else { - logger.warnf("member %s not found for group %s", member, groupModel.getId()); + // TODO Exception Handling : should we throw an exception when some group members can't be found ? + LOGGER.warnf("member %s not found for group %s", member, groupModel.getId()); } } return group; diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index ea89c60..caa318a 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -85,6 +85,7 @@ public class ScimClient implements AutoCloseable { private void checkResponseIsSuccess(ServerResponse response) { 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()); diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 54c4843..2ec51aa 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -16,7 +16,7 @@ import java.util.Set; */ public class ScimDispatcher { - private static final Logger logger = Logger.getLogger(ScimDispatcher.class); + private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); private final KeycloakSession session; private final ScimExceptionHandler exceptionHandler; @@ -34,42 +34,32 @@ public class ScimDispatcher { * Lists all active ScimStorageProviderFactory and create new ScimClients for each of them */ public void refreshActiveScimEndpoints() { - try { - // Step 1: close existing clients - for (GroupScimService c : groupScimServices) { - c.close(); - } - groupScimServices.clear(); - for (UserScimService c : userScimServices) { - c.close(); - } - userScimServices.clear(); - - // Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory) - session.getContext().getRealm().getComponentsStream() - .filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId()) - && m.get("enabled", true)) - .forEach(scimEndpointConfigurationRaw -> { - ScrimProviderConfiguration scrimProviderConfiguration = new ScrimProviderConfiguration(scimEndpointConfigurationRaw); - try { - // Step 3 : create scim clients for each endpoint - if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { - GroupScimService groupScimService = new GroupScimService(session, scrimProviderConfiguration); - groupScimServices.add(groupScimService); - } - 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", scimEndpointConfigurationRaw.getId(), e.getMessage()); - // TODO is it ok to log and try to create the other clients ? + // Step 1: close existing clients (as configuration may have changed) + groupScimServices.forEach(GroupScimService::close); + groupScimServices.clear(); + userScimServices.forEach(UserScimService::close); + userScimServices.clear(); + + // Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory) + session.getContext().getRealm().getComponentsStream() + .filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId()) + && m.get("enabled", true)) + .forEach(scimEndpointConfigurationRaw -> { + ScrimProviderConfiguration scrimProviderConfiguration = new ScrimProviderConfiguration(scimEndpointConfigurationRaw); + try { + // Step 3 : create scim clients for each endpoint + if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { + GroupScimService groupScimService = new GroupScimService(session, scrimProviderConfiguration); + groupScimServices.add(groupScimService); } - }); - } catch (Exception e) { - logger.error("[SCIM] Error while refreshing scim clients ", e); - // TODO : how to handle exception here ? - } + if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { + UserScimService userScimService = new UserScimService(session, scrimProviderConfiguration); + userScimServices.add(userScimService); + } + } catch (Exception e) { + exceptionHandler.handleInvalidEndpointConfiguration(scimEndpointConfigurationRaw, e); + } + }); } public void dispatchUserModificationToAll(SCIMPropagationConsumer operationToDispatch) { @@ -84,7 +74,7 @@ public class ScimDispatcher { } }); // TODO we could iterate on servicesCorrectlyPropagated to undo modification - logger.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); + LOGGER.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); } public void dispatchGroupModificationToAll(SCIMPropagationConsumer operationToDispatch) { @@ -99,7 +89,7 @@ public class ScimDispatcher { } }); // TODO we could iterate on servicesCorrectlyPropagated to undo modification - logger.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); + LOGGER.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); } public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, SCIMPropagationConsumer operationToDispatch) { @@ -109,12 +99,12 @@ public class ScimDispatcher { if (matchingClient.isPresent()) { try { operationToDispatch.acceptThrows(matchingClient.get()); - logger.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); + LOGGER.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); } catch (ScimPropagationException e) { exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); } } else { - logger.error("[SCIM] Could not find a Scim Client matching User endpoint configuration" + scimServerConfiguration.getId()); + LOGGER.error("[SCIM] Could not find a Scim Client matching User endpoint configuration" + scimServerConfiguration.getId()); } } @@ -126,12 +116,12 @@ public class ScimDispatcher { if (matchingClient.isPresent()) { try { operationToDispatch.acceptThrows(matchingClient.get()); - logger.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); + LOGGER.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); } catch (ScimPropagationException e) { exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); } } else { - logger.error("[SCIM] Could not find a Scim Client matching Group endpoint configuration" + scimServerConfiguration.getId()); + LOGGER.error("[SCIM] Could not find a Scim Client matching Group endpoint configuration" + scimServerConfiguration.getId()); } } diff --git a/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java b/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java index 44d65ff..47b8c71 100644 --- a/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java +++ b/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java @@ -1,6 +1,7 @@ package sh.libre.scim.core; import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; /** @@ -10,8 +11,8 @@ import org.keycloak.models.KeycloakSession; * - The thrown exception itself */ public class ScimExceptionHandler { + private static final Logger LOGGER = Logger.getLogger(ScimExceptionHandler.class); - private static final Logger logger = Logger.getLogger(ScimDispatcher.class); private final KeycloakSession session; public ScimExceptionHandler(KeycloakSession session) { @@ -25,7 +26,14 @@ public class ScimExceptionHandler { * @param e the occuring exception */ public void handleException(ScrimProviderConfiguration scimProviderConfiguration, ScimPropagationException e) { - logger.error("[SCIM] Error while propagating to SCIM endpoint " + scimProviderConfiguration.getId(), e); - // TODO session.getTransactionManager().rollback(); + LOGGER.error("[SCIM] Error while propagating to SCIM endpoint %s", scimProviderConfiguration.getId(), e); + // TODO Exception Handling : rollback only for critical operations, if configuration says so + // session.getTransactionManager().rollback(); + } + + public void handleInvalidEndpointConfiguration(ComponentModel scimEndpointConfigurationRaw, Exception e) { + LOGGER.error("[SCIM] Invalid Endpoint configuration " + scimEndpointConfigurationRaw.getId(), e); + // TODO Exception Handling is it ok to ignore an invalid Scim endpoint Configuration ? + // IF not, we should propagate the exception here } } diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/UserScimService.java index 3c30023..fbae362 100644 --- a/src/main/java/sh/libre/scim/core/UserScimService.java +++ b/src/main/java/sh/libre/scim/core/UserScimService.java @@ -20,7 +20,7 @@ import java.util.Optional; import java.util.stream.Stream; public class UserScimService extends AbstractScimService { - private final Logger logger = Logger.getLogger(UserScimService.class); + private final Logger LOGGER = Logger.getLogger(UserScimService.class); public UserScimService( KeycloakSession keycloakSession, @@ -41,17 +41,18 @@ public class UserScimService extends AbstractScimService { @Override protected Optional tryToMap(User resource) { Optional matchedByUsername = resource.getUserName() - .map(getKeycloakDao()::getUserByUsername) - .map(this::getId); + .map(getKeycloakDao()::getUserByUsername) + .map(this::getId); Optional matchedByEmail = resource.getEmails().stream() .findFirst() .flatMap(MultiComplexNode::getValue) .map(getKeycloakDao()::getUserByEmail) .map(this::getId); if (matchedByUsername.isPresent() - && matchedByEmail.isPresent() - && !matchedByUsername.equals(matchedByEmail)) { - logger.warnf("found 2 possible users for remote user %s %s", matchedByUsername.get(), matchedByEmail.get()); + && 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(); } if (matchedByUsername.isPresent()) { diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 4195e6b..5465222 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -228,11 +228,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { @Override public void close() { - try { - dispatcher.close(); - } catch (Exception e) { - LOGGER.error("Error while closing dispatcher", e); - } + dispatcher.close(); } diff --git a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java index 00a527f..724fa92 100644 --- a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java @@ -31,7 +31,7 @@ import java.util.List; public class ScimEndpointConfigurationStorageProviderFactory implements UserStorageProviderFactory, ImportSynchronization { public static final String ID = "scim"; - private final Logger logger = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class); + private final Logger LOGGER = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class); @Override public String getId() { @@ -43,7 +43,7 @@ public class ScimEndpointConfigurationStorageProviderFactory public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) { // TODO if this should be kept here, better document purpose & usage - logger.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getId()); + LOGGER.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getId()); SynchronizationResult result = new SynchronizationResult(); KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { RealmModel realm = session.realms().getRealm(realmId); @@ -78,7 +78,7 @@ public class ScimEndpointConfigurationStorageProviderFactory ScimDispatcher dispatcher = new ScimDispatcher(session); 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()); + LOGGER.infof("[SCIM] Dirty group: %s", group.getName()); dispatcher.dispatchGroupModificationToAll(client -> client.replace(group)); group.removeAttribute("scim-dirty"); } -- GitLab From 9d52c0eb4b676ffa70b3efe76755cd04f82e0fc0 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 25 Jun 2024 10:35:56 +0200 Subject: [PATCH 48/58] Exception Handler - Step 3 : exception categorization --- .../libre/scim/core/AbstractScimService.java | 263 +++++++++--------- .../sh/libre/scim/core/GroupScimService.java | 29 +- .../java/sh/libre/scim/core/KeycloakId.java | 1 + .../java/sh/libre/scim/core/ScimClient.java | 23 +- .../sh/libre/scim/core/ScimDispatcher.java | 2 + .../sh/libre/scim/core/UserScimService.java | 25 +- .../ErrorForScimEndpointException.java | 8 + .../InconsistentScimDataException.java | 7 + .../ScimExceptionHandler.java | 3 +- .../ScimPropagationException.java | 4 +- .../UnexpectedScimDataException.java | 7 + .../scim/event/ScimEventListenerProvider.java | 24 +- .../sh/libre/scim/jpa/ScimResourceDao.java | 16 +- ...Resource.java => ScimResourceMapping.java} | 4 +- .../libre/scim/jpa/ScimResourceProvider.java | 2 +- ...ntConfigurationStorageProviderFactory.java | 2 +- 16 files changed, 219 insertions(+), 201 deletions(-) create mode 100644 src/main/java/sh/libre/scim/core/exceptions/ErrorForScimEndpointException.java create mode 100644 src/main/java/sh/libre/scim/core/exceptions/InconsistentScimDataException.java rename src/main/java/sh/libre/scim/core/{ => exceptions}/ScimExceptionHandler.java (94%) rename src/main/java/sh/libre/scim/core/{ => exceptions}/ScimPropagationException.java (64%) create mode 100644 src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java rename src/main/java/sh/libre/scim/jpa/{ScimResource.java => ScimResourceMapping.java} (96%) diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index b72bef6..9fc896b 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -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 implements AutoCloseable { +// TODO Document K and S +public abstract class AbstractScimService implements AutoCloseable { private static final Logger LOGGER = Logger.getLogger(AbstractScimService.class); @@ -36,179 +40,164 @@ public abstract class AbstractScimService findById(KeycloakId keycloakId) { - return getScimResourceDao().findById(keycloakId, type); - } - - private KeycloakSession getKeycloakSession() { - return keycloakSession; - } - - public void replace(RMM roleMapperModel) throws ScimPropagationException { - try { - if (isSkip(roleMapperModel)) - return; - KeycloakId id = getId(roleMapperModel); - Optional 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); + 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); } - private void doReplace(RMM roleMapperModel, EntityOnRemoteScimId externalId) { - S scimForReplace = toScimForReplace(roleMapperModel, externalId); - scimClient.replace(externalId, scimForReplace); - } - - protected abstract S toScimForReplace(RMM roleMapperModel, EntityOnRemoteScimId externalId); + protected abstract S scimRequestBodyForUpdate(K roleMapperModel, EntityOnRemoteScimId externalId); - 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))); + 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 refreshResources(SynchronizationResult syncRes) throws ScimPropagationException { - LOGGER.info("[SCIM] Refresh resources for endpoint " + this.getConfiguration().getEndPoint()); - try (Stream resourcesStream = getResourceStream()) { - Set 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); + public void pushResourcesToScim(SynchronizationResult syncRes) throws ErrorForScimEndpointException, InconsistentScimDataException { + LOGGER.info("[SCIM] Push resources to endpoint " + this.getConfiguration().getEndPoint()); + try (Stream resourcesStream = getResourceStream()) { + Set 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; } - syncRes.increaseUpdated(); } } } - protected abstract boolean isSkipRefresh(RMM resource); - protected abstract Stream getResourceStream(); + 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 optionalMapping = getScimResourceDao().findByExternalId(externalId, type); - 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 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); - } + // 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); } + } - Optional 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; + // Here no keycloak user/group matching the SCIM external id exists + // Try to match existing keycloak resource by properties (username, email, name) + Optional 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 (Exception e) { - // TODO ExceptionHandling should we stop and throw ScimPropagationException here ? - LOGGER.error(e); - e.printStackTrace(); - syncRes.increaseFailed(); } + } catch (ScimPropagationException e) { + throw new RuntimeException(e); } - } 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 tryToMap(S resource) throws ScimPropagationException; + 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); + } + + protected ScimResourceDao getScimResourceDao() { + return ScimResourceDao.newInstance(getKeycloakSession(), scimProviderConfiguration.getId()); + } + + private Optional findMappingById(KeycloakId keycloakId) { + return getScimResourceDao().findById(keycloakId, type); + } + + private KeycloakSession getKeycloakSession() { + return keycloakSession; + } + + + protected abstract boolean shouldIgnoreForScimSynchronization(K resource); + + protected abstract Stream getResourceStream(); + + protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException; + + protected abstract Optional 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); } } diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java index c448a9a..4679725 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimService.java +++ b/src/main/java/sh/libre/scim/core/GroupScimService.java @@ -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 { } @Override - protected Optional tryToMap(Group resource) { + protected Optional matchKeycloakMappingByScimProperties(Group resource) { Set names = new TreeSet<>(); resource.getId().ifPresent(names::add); resource.getDisplayName().ifPresent(names::add); @@ -52,20 +53,20 @@ public class GroupScimService extends AbstractScimService { } @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 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 { } @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 { } @Override - protected Group toScimForCreation(GroupModel groupModel) { + protected Group scimRequestBodyForCreate(GroupModel groupModel) { Set 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 optionalGroupMemberMapping = getScimResourceDao().findUserById(member); + Optional 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 { } @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 { } @Override - protected boolean isSkipRefresh(GroupModel resource) { + protected boolean shouldIgnoreForScimSynchronization(GroupModel resource) { return false; } } diff --git a/src/main/java/sh/libre/scim/core/KeycloakId.java b/src/main/java/sh/libre/scim/core/KeycloakId.java index f35817d..432892e 100644 --- a/src/main/java/sh/libre/scim/core/KeycloakId.java +++ b/src/main/java/sh/libre/scim/core/KeycloakId.java @@ -1,5 +1,6 @@ package sh.libre.scim.core; +// TODO rename this public record KeycloakId( String asString ) { diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index caa318a..9bdf3cc 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -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 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 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 response = retry.executeSupplier(() -> scimRequestBuilder .create(getResourceClass(), getScimEndpoint()) @@ -80,15 +82,12 @@ public class ScimClient 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 response) { + private void checkResponseIsSuccess(ServerResponse 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 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 response = retry.executeSupplier(() -> scimRequestBuilder .update(getResourceClass(), getScimEndpoint(), externalId.asString()) .setResource(scimForReplace) @@ -110,8 +110,9 @@ public class ScimClient 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 response = retry.executeSupplier(() -> scimRequestBuilder .delete(getResourceClass(), getScimEndpoint(), externalId.asString()) .sendRequest() diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 2ec51aa..927d0f9 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -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; diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/UserScimService.java index fbae362..23b2079 100644 --- a/src/main/java/sh/libre/scim/core/UserScimService.java +++ b/src/main/java/sh/libre/scim/core/UserScimService.java @@ -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 { } @Override - protected Optional tryToMap(User resource) { + protected Optional matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimDataException { Optional matchedByUsername = resource.getUserName() .map(getKeycloakDao()::getUserByUsername) .map(this::getId); @@ -51,9 +53,7 @@ public class UserScimService extends AbstractScimService { 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 { } @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 { } @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 { } @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 { } @Override - protected boolean isSkipRefresh(UserModel userModel) { + protected boolean shouldIgnoreForScimSynchronization(UserModel userModel) { return "admin".equals(userModel.getUsername()); } } diff --git a/src/main/java/sh/libre/scim/core/exceptions/ErrorForScimEndpointException.java b/src/main/java/sh/libre/scim/core/exceptions/ErrorForScimEndpointException.java new file mode 100644 index 0000000..1705e99 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/ErrorForScimEndpointException.java @@ -0,0 +1,8 @@ +package sh.libre.scim.core.exceptions; + +public class ErrorForScimEndpointException extends ScimPropagationException { + + public ErrorForScimEndpointException(String message) { + super(message); + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimDataException.java b/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimDataException.java new file mode 100644 index 0000000..947a9f1 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimDataException.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.exceptions; + +public class InconsistentScimDataException extends ScimPropagationException { + public InconsistentScimDataException(String message) { + super(message); + } +} diff --git a/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java similarity index 94% rename from src/main/java/sh/libre/scim/core/ScimExceptionHandler.java rename to src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java index 47b8c71..81afeea 100644 --- a/src/main/java/sh/libre/scim/core/ScimExceptionHandler.java +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java @@ -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 : diff --git a/src/main/java/sh/libre/scim/core/ScimPropagationException.java b/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java similarity index 64% rename from src/main/java/sh/libre/scim/core/ScimPropagationException.java rename to src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java index 135b555..7b1a747 100644 --- a/src/main/java/sh/libre/scim/core/ScimPropagationException.java +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java @@ -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); diff --git a/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java b/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java new file mode 100644 index 0000000..918127e --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/UnexpectedScimDataException.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.exceptions; + +public class UnexpectedScimDataException extends ScimPropagationException { + public UnexpectedScimDataException(String message) { + super(message); + } +} diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 5465222..4d6f7f4 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -33,7 +33,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { private final KeycloakSession session; - private final KeycloakDao keycloackDao; + private final KeycloakDao keycloakDao; private final Map 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 diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java index 7d96b28..97db33b 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -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 getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) { + private TypedQuery 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 findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) { + public Optional findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) { try { return Optional.of( getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult() @@ -72,7 +72,7 @@ public class ScimResourceDao { } } - public Optional findById(KeycloakId keycloakId, ScimResourceType type) { + public Optional findById(KeycloakId keycloakId, ScimResourceType type) { try { return Optional.of( getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult() @@ -82,15 +82,15 @@ public class ScimResourceDao { } } - public Optional findUserById(KeycloakId id) { + public Optional findUserById(KeycloakId id) { return findById(id, ScimResourceType.USER); } - public Optional findUserByExternalId(EntityOnRemoteScimId externalId) { + public Optional findUserByExternalId(EntityOnRemoteScimId externalId) { return findByExternalId(externalId, ScimResourceType.USER); } - public void delete(ScimResource resource) { + public void delete(ScimResourceMapping resource) { EntityManager entityManager = getEntityManager(); entityManager.remove(resource); } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java similarity index 96% rename from src/main/java/sh/libre/scim/jpa/ScimResource.java rename to src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java index 5536a6d..26eafa0 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResource.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java @@ -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) diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java index ab12d4c..3dc284e 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java @@ -9,7 +9,7 @@ public class ScimResourceProvider implements JpaEntityProvider { @Override public List> getEntities() { - return Collections.singletonList(ScimResource.class); + return Collections.singletonList(ScimResourceMapping.class); } @Override diff --git a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java index 724fa92..8bc99d6 100644 --- a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java @@ -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(); -- GitLab From 09d0b6544ba47c86f6d92c082586b2a3ef329d92 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 25 Jun 2024 15:44:40 +0200 Subject: [PATCH 49/58] Exception Handler - Step 4 : define SkipOrStop strategy --- .../libre/scim/core/AbstractScimService.java | 195 +++++++++++------- .../sh/libre/scim/core/GroupScimService.java | 19 +- .../java/sh/libre/scim/core/ScimClient.java | 2 +- .../sh/libre/scim/core/ScimDispatcher.java | 26 ++- ...n.java => ScrimEndPointConfiguration.java} | 20 +- .../sh/libre/scim/core/UserScimService.java | 6 +- .../core/exceptions/RollbackStrategy.java | 22 ++ .../exceptions/RollbackStrategyFactory.java | 34 +++ .../core/exceptions/ScimExceptionHandler.java | 27 +-- .../core/exceptions/SkipOrStopStrategy.java | 66 ++++++ .../exceptions/SkipOrStopStrategyFactory.java | 74 +++++++ ...ntConfigurationStorageProviderFactory.java | 22 +- 12 files changed, 386 insertions(+), 127 deletions(-) rename src/main/java/sh/libre/scim/core/{ScrimProviderConfiguration.java => ScrimEndPointConfiguration.java} (81%) create mode 100644 src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java create mode 100644 src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java create mode 100644 src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java create mode 100644 src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategyFactory.java diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index 9fc896b..dda7b72 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -8,7 +8,7 @@ import org.keycloak.models.RoleMapperModel; import org.keycloak.storage.user.SynchronizationResult; 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.SkipOrStopStrategy; import sh.libre.scim.core.exceptions.UnexpectedScimDataException; import sh.libre.scim.jpa.ScimResourceDao; import sh.libre.scim.jpa.ScimResourceMapping; @@ -20,24 +20,29 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -// TODO Document K and S +/** + * A service in charge of synchronisation (CRUD) between + * a Keykloak Role (UserModel, GroupModel) and a SCIM Resource (User,Group). + * + * @param The Keycloack Model (e.g. UserModel, GroupModel) + * @param The SCIM Resource (e.g. User, Group) + */ public abstract class AbstractScimService implements AutoCloseable { private static final Logger LOGGER = Logger.getLogger(AbstractScimService.class); private final KeycloakSession keycloakSession; - - private final ScrimProviderConfiguration scimProviderConfiguration; - + protected final SkipOrStopStrategy skipOrStopStrategy; + private final ScrimEndPointConfiguration scimProviderConfiguration; private final ScimResourceType type; - private final ScimClient scimClient; - protected AbstractScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType type) { + protected AbstractScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType type, SkipOrStopStrategy skipOrStopStrategy) { this.keycloakSession = keycloakSession; this.scimProviderConfiguration = scimProviderConfiguration; this.type = type; this.scimClient = ScimClient.open(scimProviderConfiguration, type); + this.skipOrStopStrategy = skipOrStopStrategy; } public void create(K roleMapperModel) throws InconsistentScimDataException, ErrorForScimEndpointException { @@ -68,7 +73,7 @@ public abstract class AbstractScimService resourcesStream = getResourceStream()) { Set 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; - } + KeycloakId id = getId(resource); + pushSingleResourceToScim(syncRes, resource, id); } } } - - public void pullResourcesFromScim(SynchronizationResult syncRes) { - LOGGER.info("[SCIM] Import resources from endpoint " + this.getConfiguration().getEndPoint()); + public void pullAllResourcesFromScim(SynchronizationResult syncRes) throws UnexpectedScimDataException, InconsistentScimDataException, ErrorForScimEndpointException { + LOGGER.info("[SCIM] Pull 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 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); - } - } + pullSingleResourceFromScim(syncRes, resource); + } + } + + private void pushSingleResourceToScim(SynchronizationResult syncRes, K resource, KeycloakId id) throws ErrorForScimEndpointException, InconsistentScimDataException { + try { + LOGGER.infof("[SCIM] Reconciling local resource %s", id); + if (shouldIgnoreForScimSynchronization(resource)) { + LOGGER.infof("[SCIM] Skip local resource %s", id); + return; + } + if (findMappingById(id).isPresent()) { + LOGGER.info("[SCIM] Replacing it"); + update(resource); + } else { + LOGGER.info("[SCIM] Creating it"); + create(resource); + } + syncRes.increaseUpdated(); + } catch (ErrorForScimEndpointException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPushingToScim(this.getConfiguration())) { + LOGGER.warn("Error while syncing " + id + " to endpoint " + getConfiguration().getEndPoint()); + } else { + throw e; + } + } catch (InconsistentScimDataException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPushingToScim(this.getConfiguration())) { + LOGGER.warn("Inconsistent data for element " + id + " and endpoint " + getConfiguration().getEndPoint()); + } else { + throw e; + } + } + } - // Here no keycloak user/group matching the SCIM external id exists - // Try to match existing keycloak resource by properties (username, email, name) - Optional 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(); + + private void pullSingleResourceFromScim(SynchronizationResult syncRes, S resource) throws UnexpectedScimDataException, InconsistentScimDataException, ErrorForScimEndpointException { + 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 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"); + return; } 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"); - } + LOGGER.info("[SCIM] Delete a dangling mapping"); + getScimResourceDao().delete(mapping); } - } catch (ScimPropagationException e) { - throw new RuntimeException(e); } + // Here no keycloak user/group matching the SCIM external id exists + // Try to match existing keycloak resource by properties (username, email, name) + Optional 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 (UnexpectedScimDataException e) { + if (skipOrStopStrategy.skipInvalidDataFromScimEndpoint(getConfiguration())) { + LOGGER.warn("[SCIM] Skipping element synchronisation because of invalid Scim Data for element " + resource.getId() + " : " + e.getMessage()); + } else { + throw e; + } + } catch (InconsistentScimDataException e) { + if (skipOrStopStrategy.allowPartialSynchronizationWhenPullingFromScim(getConfiguration())) { + LOGGER.warn("[SCIM] Skipping element synchronisation because of inconsistent mapping for element " + resource.getId() + " : " + e.getMessage()); + } else { + throw e; + } + } catch (ErrorForScimEndpointException e) { + // Can only occur in case of a DELETE_REMOTE conflict action + if (skipOrStopStrategy.allowPartialSynchronizationWhenPullingFromScim(getConfiguration())) { + LOGGER.warn("[SCIM] Could not delete SCIM resource " + resource.getId() + " during synchronisation"); + } else { + throw e; + } } + } - protected abstract S scimRequestBodyForCreate(K roleMapperModel); + protected abstract S scimRequestBodyForCreate(K roleMapperModel) throws InconsistentScimDataException; protected abstract KeycloakId getId(K roleMapperModel); @@ -192,12 +231,12 @@ public abstract class AbstractScimService { private final Logger LOGGER = Logger.getLogger(GroupScimService.class); - public GroupScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) { - super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP); + public GroupScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration, SkipOrStopStrategy skipOrStopStrategy) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP, skipOrStopStrategy); } @Override @@ -66,6 +68,7 @@ public class GroupScimService extends AbstractScimService { .orElseThrow(() -> new UnexpectedScimDataException("can't create group member for group '%s' without id: ".formatted(displayName) + resource)); KeycloakId userId = getScimResourceDao().findUserByExternalId(externalId) .map(ScimResourceMapping::getIdAsKeycloakId) + // TODO Exception handling : here if think this is a InconsistentScimData : Scim member is valid, but not mapped yet in our keycloak .orElseThrow(() -> new UnexpectedScimDataException("can't find mapping for group member %s".formatted(externalId))); UserModel userModel = getKeycloakDao().getUserById(userId); userModel.joinGroup(group); @@ -85,7 +88,7 @@ public class GroupScimService extends AbstractScimService { } @Override - protected Group scimRequestBodyForCreate(GroupModel groupModel) { + protected Group scimRequestBodyForCreate(GroupModel groupModel) throws InconsistentScimDataException { Set members = getKeycloakDao().getGroupMembers(groupModel); Group group = new Group(); group.setExternalId(groupModel.getId()); @@ -101,15 +104,19 @@ public class GroupScimService extends AbstractScimService { groupMember.setRef(ref.toString()); group.addMember(groupMember); } else { - // TODO Exception Handling : should we throw an exception when some group members can't be found ? - LOGGER.warnf("member %s not found for group %s", member, groupModel.getId()); + String message = "Unmapped member " + member + " for group " + groupModel.getId(); + if (skipOrStopStrategy.allowMissingMembersWhenPushingGroupToScim(this.getConfiguration())) { + LOGGER.warn(message); + } else { + throw new InconsistentScimDataException(message); + } } } return group; } @Override - protected Group scimRequestBodyForUpdate(GroupModel groupModel, EntityOnRemoteScimId externalId) { + protected Group scimRequestBodyForUpdate(GroupModel groupModel, EntityOnRemoteScimId externalId) throws InconsistentScimDataException { Group group = scimRequestBodyForCreate(groupModel); group.setId(externalId.asString()); Meta meta = newMetaLocation(externalId); diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 9bdf3cc..9e0d136 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -43,7 +43,7 @@ public class ScimClient implements AutoCloseable { this.scimResourceType = scimResourceType; } - public static ScimClient open(ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { + public static ScimClient open(ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); Map httpHeaders = new HashMap<>(); httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue()); diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 927d0f9..0ab00fc 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -5,6 +5,8 @@ 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.core.exceptions.SkipOrStopStrategy; +import sh.libre.scim.core.exceptions.SkipOrStopStrategyFactory; import sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory; import java.util.ArrayList; @@ -22,6 +24,7 @@ public class ScimDispatcher { private final KeycloakSession session; private final ScimExceptionHandler exceptionHandler; + private final SkipOrStopStrategy skipOrStopStrategy; private boolean clientsInitialized = false; private final List userScimServices = new ArrayList<>(); private final List groupScimServices = new ArrayList<>(); @@ -30,6 +33,10 @@ public class ScimDispatcher { public ScimDispatcher(KeycloakSession session) { this.session = session; this.exceptionHandler = new ScimExceptionHandler(session); + // By default, use a permissive Skip or Stop strategy + this.skipOrStopStrategy = SkipOrStopStrategyFactory.create( + SkipOrStopStrategyFactory.SkipOrStopApproach.ALWAYS_SKIP_AND_CONTINUE + ); } /** @@ -47,19 +54,24 @@ public class ScimDispatcher { .filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true)) .forEach(scimEndpointConfigurationRaw -> { - ScrimProviderConfiguration scrimProviderConfiguration = new ScrimProviderConfiguration(scimEndpointConfigurationRaw); try { + ScrimEndPointConfiguration scrimEndPointConfiguration = new ScrimEndPointConfiguration(scimEndpointConfigurationRaw); + // Step 3 : create scim clients for each endpoint - if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { - GroupScimService groupScimService = new GroupScimService(session, scrimProviderConfiguration); + if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) { + GroupScimService groupScimService = new GroupScimService(session, scrimEndPointConfiguration, skipOrStopStrategy); groupScimServices.add(groupScimService); } - if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) { - UserScimService userScimService = new UserScimService(session, scrimProviderConfiguration); + if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER, false)) { + UserScimService userScimService = new UserScimService(session, scrimEndPointConfiguration, skipOrStopStrategy); userScimServices.add(userScimService); } - } catch (Exception e) { - exceptionHandler.handleInvalidEndpointConfiguration(scimEndpointConfigurationRaw, e); + } catch (IllegalArgumentException e) { + if (skipOrStopStrategy.allowInvalidEndpointConfiguration()) { + LOGGER.warn("[SCIM] Invalid Endpoint configuration " + scimEndpointConfigurationRaw.getId(), e); + } else { + throw e; + } } }); } diff --git a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java similarity index 81% rename from src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java rename to src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java index 6cd5431..0df9ced 100644 --- a/src/main/java/sh/libre/scim/core/ScrimProviderConfiguration.java +++ b/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java @@ -3,7 +3,7 @@ package sh.libre.scim.core; import de.captaingoldfish.scim.sdk.client.http.BasicAuth; import org.keycloak.component.ComponentModel; -public class ScrimProviderConfiguration { +public class ScrimEndPointConfiguration { // Configuration keys : also used in Admin Console page public static final String CONF_KEY_AUTH_MODE = "auth-mode"; public static final String CONF_KEY_AUTH_PASSWORD = "auth-pass"; @@ -21,10 +21,10 @@ public class ScrimProviderConfiguration { private final String contentType; private final String authorizationHeaderValue; private final ImportAction importAction; - private final boolean syncImport; - private final boolean syncRefresh; + private final boolean pullFromScimSynchronisationActivated; + private final boolean pushToScimSynchronisationActivated; - public ScrimProviderConfiguration(ComponentModel scimProviderConfiguration) { + public ScrimEndPointConfiguration(ComponentModel scimProviderConfiguration) { try { AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE)); @@ -43,19 +43,19 @@ public class ScrimProviderConfiguration { endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT, ""); id = scimProviderConfiguration.getId(); importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); - syncImport = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); - syncRefresh = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); + pullFromScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); + pushToScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("authMode '" + scimProviderConfiguration.get(CONF_KEY_AUTH_MODE) + "' is not supported"); } } - public boolean isSyncRefresh() { - return syncRefresh; + public boolean isPushToScimSynchronisationActivated() { + return pushToScimSynchronisationActivated; } - public boolean isSyncImport() { - return syncImport; + public boolean isPullFromScimSynchronisationActivated() { + return pullFromScimSynchronisationActivated; } public String getContentType() { diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/UserScimService.java index 23b2079..b74e0b8 100644 --- a/src/main/java/sh/libre/scim/core/UserScimService.java +++ b/src/main/java/sh/libre/scim/core/UserScimService.java @@ -14,6 +14,7 @@ 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.SkipOrStopStrategy; import sh.libre.scim.core.exceptions.UnexpectedScimDataException; import java.util.ArrayList; @@ -26,8 +27,9 @@ public class UserScimService extends AbstractScimService { public UserScimService( KeycloakSession keycloakSession, - ScrimProviderConfiguration scimProviderConfiguration) { - super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER); + ScrimEndPointConfiguration scimProviderConfiguration, + SkipOrStopStrategy skipOrStopStrategy) { + super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER, skipOrStopStrategy); } @Override diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java new file mode 100644 index 0000000..90d8593 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategy.java @@ -0,0 +1,22 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +/** + * In charge of deciding, when facing a SCIM-related issue during an operation (e.g User creation), + * whether we should : + * - Log the issue and let the operation succeed in Keycloack database (potentially unsynchronising + * Keycloack with the SCIM servers) + * - Rollback the whole operation + */ +public interface RollbackStrategy { + + /** + * Indicates whether we should rollback the whole transaction because of the given exception. + * + * @param configuration The SCIM Endpoint configuration for which the exception occured + * @param e the exception that we have to handle + * @return true if transaction should be rolled back, false if we should log and continue operation + */ + boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e); +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java new file mode 100644 index 0000000..b2df155 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java @@ -0,0 +1,34 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +public class RollbackStrategyFactory { + + public static RollbackStrategy create(RollbackApproach approach) { + // We could imagine more fine-grained rollback strategies (e.g. based on each Scim endpoint configuration) + return switch (approach) { + case ALWAYS_ROLLBACK -> new AlwaysRollbackStrategy(); + case NEVER_ROLLBACK -> new NeverRollbackStrategy(); + }; + } + + public enum RollbackApproach { + ALWAYS_ROLLBACK, NEVER_ROLLBACK + } + + private static final class AlwaysRollbackStrategy implements RollbackStrategy { + + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + return true; + } + } + + private static final class NeverRollbackStrategy implements RollbackStrategy { + + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + return true; + } + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java index 81afeea..626c04e 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java @@ -1,9 +1,8 @@ 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; +import sh.libre.scim.core.ScrimEndPointConfiguration; /** * In charge of dealing with SCIM exceptions by ignoring, logging or rollback transaction according to : @@ -15,9 +14,15 @@ public class ScimExceptionHandler { private static final Logger LOGGER = Logger.getLogger(ScimExceptionHandler.class); private final KeycloakSession session; + private final RollbackStrategy rollbackStrategy; public ScimExceptionHandler(KeycloakSession session) { + this(session, RollbackStrategyFactory.create(RollbackStrategyFactory.RollbackApproach.NEVER_ROLLBACK)); + } + + public ScimExceptionHandler(KeycloakSession session, RollbackStrategy rollbackStrategy) { this.session = session; + this.rollbackStrategy = rollbackStrategy; } /** @@ -26,15 +31,13 @@ public class ScimExceptionHandler { * @param scimProviderConfiguration the configuration of the endpoint for which the propagation exception occured * @param e the occuring exception */ - public void handleException(ScrimProviderConfiguration scimProviderConfiguration, ScimPropagationException e) { - LOGGER.error("[SCIM] Error while propagating to SCIM endpoint %s", scimProviderConfiguration.getId(), e); - // TODO Exception Handling : rollback only for critical operations, if configuration says so - // session.getTransactionManager().rollback(); - } - - public void handleInvalidEndpointConfiguration(ComponentModel scimEndpointConfigurationRaw, Exception e) { - LOGGER.error("[SCIM] Invalid Endpoint configuration " + scimEndpointConfigurationRaw.getId(), e); - // TODO Exception Handling is it ok to ignore an invalid Scim endpoint Configuration ? - // IF not, we should propagate the exception here + public void handleException(ScrimEndPointConfiguration scimProviderConfiguration, ScimPropagationException e) { + String errorMessage = "[SCIM] Error while propagating to SCIM endpoint " + scimProviderConfiguration.getId(); + if (rollbackStrategy.shouldRollback(scimProviderConfiguration, e)) { + session.getTransactionManager().rollback(); + LOGGER.error(errorMessage, e); + } else { + LOGGER.warn(errorMessage); + } } } diff --git a/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java new file mode 100644 index 0000000..8ad46c7 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategy.java @@ -0,0 +1,66 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +/** + * In charge of deciding, when facing a SCIM-related issue, whether we should : + * - log a warning, skip the problematic element and continue the rest of the operation + * - stop immediately the whole operation (typically, a synchronisation between SCIM and Keycloack) + */ +public interface SkipOrStopStrategy { + /** + * Indicates if, during a synchronisation from Keycloack to a SCIM endpoint, we should : + * - cancel the whole synchronisation if an element CRUD fail, or + * - keep on with synchronisation, allowing a partial synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial synchronisation is allowed, + * false if we should stop the whole synchronisation at first issue + */ + boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration); + + /** + * Indicates if, during a synchronisation from a SCIM endpoint to Keycloack, we should : + * - cancel the whole synchronisation if an element CRUD fail, or + * - keep on with synchronisation, allowing a partial synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial synchronisation is allowed, + * false if we should interrupt the whole synchronisation at first issue + */ + boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration); + + + /** + * Indicates if, when we propagate a group creation or update to a SCIM endpoint and some + * of its members are not mapped to SCIM, we should allow partial group update or interrupt completely. + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if a partial group update is allowed, + * false if we should interrupt the group update in case of any unmapped member + */ + boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration); + + /** + * Indicates if, when facing an invalid SCIM endpoint configuration (resulting in a unreachable SCIM server), + * we should stop or ignore this configuration. + * + * @return true the invalid endpoint should be ignored, + * * false if we should interrupt the rest of the synchronisation + */ + boolean allowInvalidEndpointConfiguration(); + + /** + * Indicates if, when trying to pull User or Groups from a SCIM endpoint, + * we encounter a invalid data (e.g. group with empty name), we should : + * - Skip the invalid element pull and continue + * - Cancel the whole synchronisation + * + * @param configuration the configuration of the endpoint in which the error occurred + * @return true if we should skip the invalid data synchronisation and pursue, + * false if we should interrupt immediately the whole synchronisation + */ + boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration); + + +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategyFactory.java b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategyFactory.java new file mode 100644 index 0000000..3bde1d4 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategyFactory.java @@ -0,0 +1,74 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +public class SkipOrStopStrategyFactory { + + public static SkipOrStopStrategy create(SkipOrStopApproach approach) { + // We could imagine more fine-grained strategies (e.g. based on each Scim endpoint configuration) + return switch (approach) { + case ALWAYS_STOP -> new AlwaysStopStrategy(); + case ALWAYS_SKIP_AND_CONTINUE -> new AlwaysSkipAndContinueStrategy(); + }; + } + + public enum SkipOrStopApproach { + ALWAYS_SKIP_AND_CONTINUE, ALWAYS_STOP + } + + private static final class AlwaysStopStrategy implements SkipOrStopStrategy { + + @Override + public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration) { + return false; + } + + @Override + public boolean allowInvalidEndpointConfiguration() { + return false; + } + + @Override + public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) { + return false; + } + } + + private static final class AlwaysSkipAndContinueStrategy implements SkipOrStopStrategy { + + @Override + public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration) { + return true; + } + + @Override + public boolean allowInvalidEndpointConfiguration() { + return true; + } + + @Override + public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) { + return true; + } + } +} diff --git a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java index 8bc99d6..f825ab6 100644 --- a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java @@ -19,7 +19,7 @@ import org.keycloak.storage.user.ImportSynchronization; import org.keycloak.storage.user.SynchronizationResult; import org.keycloak.timer.TimerProvider; import sh.libre.scim.core.ScimDispatcher; -import sh.libre.scim.core.ScrimProviderConfiguration; +import sh.libre.scim.core.ScrimEndPointConfiguration; import java.time.Duration; import java.util.Date; @@ -94,7 +94,7 @@ public class ScimEndpointConfigurationStorageProviderFactory // These Config Properties will be use to generate configuration page in Admin Console return ProviderConfigurationBuilder.create() .property() - .name(ScrimProviderConfiguration.CONF_KEY_ENDPOINT) + .name(ScrimEndPointConfiguration.CONF_KEY_ENDPOINT) .type(ProviderConfigProperty.STRING_TYPE) .required(true) .label("SCIM 2.0 endpoint") @@ -102,7 +102,7 @@ public class ScimEndpointConfigurationStorageProviderFactory "URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)") .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_CONTENT_TYPE) + .name(ScrimEndPointConfiguration.CONF_KEY_CONTENT_TYPE) .type(ProviderConfigProperty.LIST_TYPE) .label("Endpoint content type") .helpText("Only used when endpoint doesn't support application/scim+json") @@ -110,7 +110,7 @@ public class ScimEndpointConfigurationStorageProviderFactory .defaultValue(HttpHeader.SCIM_CONTENT_TYPE) .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_AUTH_MODE) + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_MODE) .type(ProviderConfigProperty.LIST_TYPE) .label("Auth mode") .helpText("Select the authorization mode") @@ -118,38 +118,38 @@ public class ScimEndpointConfigurationStorageProviderFactory .defaultValue("NONE") .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_AUTH_USER) + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_USER) .type(ProviderConfigProperty.STRING_TYPE) .label("Auth username") .helpText("Required for basic authentication.") .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_AUTH_PASSWORD) + .name(ScrimEndPointConfiguration.CONF_KEY_AUTH_PASSWORD) .type(ProviderConfigProperty.PASSWORD) .label("Auth password/token") .helpText("Password or token required for basic or bearer authentication.") .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER) + .name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable user propagation") .helpText("Should operation on users be propagated to this provider?") .defaultValue(BooleanUtils.TRUE) .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP) + .name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable group propagation") .helpText("Should operation on groups be propagated to this provider?") .defaultValue(BooleanUtils.TRUE) .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT) + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable import during sync") .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_SYNC_IMPORT_ACTION) + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT_ACTION) .type(ProviderConfigProperty.LIST_TYPE) .label("Import action") .helpText("What to do when the user doesn't exists in Keycloak.") @@ -157,7 +157,7 @@ public class ScimEndpointConfigurationStorageProviderFactory .defaultValue("CREATE_LOCAL") .add() .property() - .name(ScrimProviderConfiguration.CONF_KEY_SYNC_REFRESH) + .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_REFRESH) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable refresh during sync") .add() -- GitLab From 620c9e84bb107d94a8cf6895f23234a2735f88b1 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 25 Jun 2024 16:45:35 +0200 Subject: [PATCH 50/58] Exception Handler - Step 5 : basic Rolback Strategy implementation --- .../libre/scim/core/AbstractScimService.java | 42 +++++++++---------- .../sh/libre/scim/core/GroupScimService.java | 13 +++--- .../java/sh/libre/scim/core/ScimClient.java | 14 +++---- .../sh/libre/scim/core/UserScimService.java | 6 +-- .../ErrorForScimEndpointException.java | 8 ---- .../InconsistentScimDataException.java | 7 ---- .../InconsistentScimMappingException.java | 7 ++++ ...alidResponseFromScimEndpointException.java | 18 ++++++++ ...RollbackOnlyForCriticalErrorsStrategy.java | 32 ++++++++++++++ .../exceptions/RollbackStrategyFactory.java | 4 +- .../core/exceptions/ScimExceptionHandler.java | 2 +- .../META-INF/scim-resource-changelog.xml | 27 +++++++----- 12 files changed, 115 insertions(+), 65 deletions(-) delete mode 100644 src/main/java/sh/libre/scim/core/exceptions/ErrorForScimEndpointException.java delete mode 100644 src/main/java/sh/libre/scim/core/exceptions/InconsistentScimDataException.java create mode 100644 src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java create mode 100644 src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java create mode 100644 src/main/java/sh/libre/scim/core/exceptions/RollbackOnlyForCriticalErrorsStrategy.java diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java index dda7b72..c350702 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java @@ -6,8 +6,8 @@ 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.core.exceptions.ErrorForScimEndpointException; -import sh.libre.scim.core.exceptions.InconsistentScimDataException; +import sh.libre.scim.core.exceptions.InconsistentScimMappingException; +import sh.libre.scim.core.exceptions.InvalidResponseFromScimEndpointException; import sh.libre.scim.core.exceptions.SkipOrStopStrategy; import sh.libre.scim.core.exceptions.UnexpectedScimDataException; import sh.libre.scim.jpa.ScimResourceDao; @@ -45,7 +45,7 @@ public abstract class AbstractScimService new InconsistentScimDataException("Failed to find SCIM mapping for " + keycloakId)); + .orElseThrow(() -> new InconsistentScimMappingException("Failed to find SCIM mapping for " + keycloakId)); S scimForReplace = scimRequestBodyForUpdate(roleMapperModel, entityOnRemoteScimId); scimClient.update(entityOnRemoteScimId, scimForReplace); } - protected abstract S scimRequestBodyForUpdate(K roleMapperModel, EntityOnRemoteScimId externalId) throws InconsistentScimDataException; + protected abstract S scimRequestBodyForUpdate(K roleMapperModel, EntityOnRemoteScimId externalId) throws InconsistentScimMappingException; - public void delete(KeycloakId id) throws InconsistentScimDataException, ErrorForScimEndpointException { + public void delete(KeycloakId id) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException { ScimResourceMapping resource = findMappingById(id) - .orElseThrow(() -> new InconsistentScimDataException("Failed to delete resource %s, scim mapping not found: ".formatted(id))); + .orElseThrow(() -> new InconsistentScimMappingException("Failed to delete resource %s, scim mapping not found: ".formatted(id))); EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId(); scimClient.delete(externalId); getScimResourceDao().delete(resource); } - public void pushAllResourcesToScim(SynchronizationResult syncRes) throws ErrorForScimEndpointException, InconsistentScimDataException { + public void pushAllResourcesToScim(SynchronizationResult syncRes) throws InvalidResponseFromScimEndpointException, InconsistentScimMappingException { LOGGER.info("[SCIM] Push resources to endpoint " + this.getConfiguration().getEndPoint()); try (Stream resourcesStream = getResourceStream()) { Set resources = resourcesStream.collect(Collectors.toUnmodifiableSet()); @@ -94,14 +94,14 @@ public abstract class AbstractScimService getResourceStream(); - protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException; + protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException, InconsistentScimMappingException; - protected abstract Optional matchKeycloakMappingByScimProperties(S resource) throws InconsistentScimDataException; + protected abstract Optional matchKeycloakMappingByScimProperties(S resource) throws InconsistentScimMappingException; protected abstract boolean entityExists(KeycloakId keycloakId); - public void sync(SynchronizationResult syncRes) throws InconsistentScimDataException, ErrorForScimEndpointException, UnexpectedScimDataException { + public void sync(SynchronizationResult syncRes) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException, UnexpectedScimDataException { if (this.scimProviderConfiguration.isPullFromScimSynchronisationActivated()) { this.pullAllResourcesFromScim(syncRes); } diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java index 92857ec..35daece 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimService.java +++ b/src/main/java/sh/libre/scim/core/GroupScimService.java @@ -10,7 +10,7 @@ import org.jboss.logging.Logger; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; -import sh.libre.scim.core.exceptions.InconsistentScimDataException; +import sh.libre.scim.core.exceptions.InconsistentScimMappingException; import sh.libre.scim.core.exceptions.SkipOrStopStrategy; import sh.libre.scim.core.exceptions.UnexpectedScimDataException; import sh.libre.scim.jpa.ScimResourceMapping; @@ -55,7 +55,7 @@ public class GroupScimService extends AbstractScimService { } @Override - protected KeycloakId createEntity(Group resource) throws UnexpectedScimDataException { + protected KeycloakId createEntity(Group resource) throws UnexpectedScimDataException, InconsistentScimMappingException { String displayName = resource.getDisplayName() .filter(StringUtils::isNotBlank) .orElseThrow(() -> new UnexpectedScimDataException("Remote Scim group has empty name, can't create. Resource id = %s".formatted(resource.getId()))); @@ -68,8 +68,7 @@ public class GroupScimService extends AbstractScimService { .orElseThrow(() -> new UnexpectedScimDataException("can't create group member for group '%s' without id: ".formatted(displayName) + resource)); KeycloakId userId = getScimResourceDao().findUserByExternalId(externalId) .map(ScimResourceMapping::getIdAsKeycloakId) - // TODO Exception handling : here if think this is a InconsistentScimData : Scim member is valid, but not mapped yet in our keycloak - .orElseThrow(() -> new UnexpectedScimDataException("can't find mapping for group member %s".formatted(externalId))); + .orElseThrow(() -> new InconsistentScimMappingException("can't find mapping for group member %s".formatted(externalId))); UserModel userModel = getKeycloakDao().getUserById(userId); userModel.joinGroup(group); } @@ -88,7 +87,7 @@ public class GroupScimService extends AbstractScimService { } @Override - protected Group scimRequestBodyForCreate(GroupModel groupModel) throws InconsistentScimDataException { + protected Group scimRequestBodyForCreate(GroupModel groupModel) throws InconsistentScimMappingException { Set members = getKeycloakDao().getGroupMembers(groupModel); Group group = new Group(); group.setExternalId(groupModel.getId()); @@ -108,7 +107,7 @@ public class GroupScimService extends AbstractScimService { if (skipOrStopStrategy.allowMissingMembersWhenPushingGroupToScim(this.getConfiguration())) { LOGGER.warn(message); } else { - throw new InconsistentScimDataException(message); + throw new InconsistentScimMappingException(message); } } } @@ -116,7 +115,7 @@ public class GroupScimService extends AbstractScimService { } @Override - protected Group scimRequestBodyForUpdate(GroupModel groupModel, EntityOnRemoteScimId externalId) throws InconsistentScimDataException { + protected Group scimRequestBodyForUpdate(GroupModel groupModel, EntityOnRemoteScimId externalId) throws InconsistentScimMappingException { Group group = scimRequestBodyForCreate(groupModel); group.setId(externalId.asString()); Meta meta = newMetaLocation(externalId); diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java index 9e0d136..7649ccb 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -12,7 +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 sh.libre.scim.core.exceptions.InvalidResponseFromScimEndpointException; import java.util.Collections; import java.util.HashMap; @@ -64,7 +64,7 @@ public class ScimClient implements AutoCloseable { return new ScimClient(scimRequestBuilder, scimResourceType); } - public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws ErrorForScimEndpointException { + public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws InvalidResponseFromScimEndpointException { Optional scimForCreationId = scimForCreation.getId(); if (scimForCreationId.isPresent()) { throw new IllegalArgumentException( @@ -82,12 +82,12 @@ public class ScimClient implements AutoCloseable { S resource = response.getResource(); return resource.getId() .map(EntityOnRemoteScimId::new) - .orElseThrow(() -> new ErrorForScimEndpointException("Created SCIM resource does not have id")); + .orElseThrow(() -> new InvalidResponseFromScimEndpointException(response, "Created SCIM resource does not have id")); } - private void checkResponseIsSuccess(ServerResponse response) throws ErrorForScimEndpointException { + private void checkResponseIsSuccess(ServerResponse response) throws InvalidResponseFromScimEndpointException { if (!response.isSuccess()) { - throw new ErrorForScimEndpointException("Server answered with status " + response.getResponseBody() + ": " + response.getResponseBody()); + throw new InvalidResponseFromScimEndpointException(response, "Server answered with status " + response.getResponseBody() + ": " + response.getResponseBody()); } } @@ -99,7 +99,7 @@ public class ScimClient implements AutoCloseable { return scimResourceType.getResourceClass(); } - public void update(EntityOnRemoteScimId externalId, S scimForReplace) throws ErrorForScimEndpointException { + public void update(EntityOnRemoteScimId externalId, S scimForReplace) throws InvalidResponseFromScimEndpointException { Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); // TODO Exception handling : check that all exceptions are wrapped in server response ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder @@ -110,7 +110,7 @@ public class ScimClient implements AutoCloseable { checkResponseIsSuccess(response); } - public void delete(EntityOnRemoteScimId externalId) throws ErrorForScimEndpointException { + public void delete(EntityOnRemoteScimId externalId) throws InvalidResponseFromScimEndpointException { Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString())); // TODO Exception handling : check that all exceptions are wrapped in server response ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/UserScimService.java index b74e0b8..54c8345 100644 --- a/src/main/java/sh/libre/scim/core/UserScimService.java +++ b/src/main/java/sh/libre/scim/core/UserScimService.java @@ -13,7 +13,7 @@ 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.InconsistentScimMappingException; import sh.libre.scim.core.exceptions.SkipOrStopStrategy; import sh.libre.scim.core.exceptions.UnexpectedScimDataException; @@ -43,7 +43,7 @@ public class UserScimService extends AbstractScimService { } @Override - protected Optional matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimDataException { + protected Optional matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimMappingException { Optional matchedByUsername = resource.getUserName() .map(getKeycloakDao()::getUserByUsername) .map(this::getId); @@ -55,7 +55,7 @@ public class UserScimService extends AbstractScimService { if (matchedByUsername.isPresent() && matchedByEmail.isPresent() && !matchedByUsername.equals(matchedByEmail)) { - throw new InconsistentScimDataException("Found 2 possible users for remote user " + matchedByUsername.get() + " - " + matchedByEmail.get()); + throw new InconsistentScimMappingException("Found 2 possible users for remote user " + matchedByUsername.get() + " - " + matchedByEmail.get()); } if (matchedByUsername.isPresent()) { return matchedByUsername; diff --git a/src/main/java/sh/libre/scim/core/exceptions/ErrorForScimEndpointException.java b/src/main/java/sh/libre/scim/core/exceptions/ErrorForScimEndpointException.java deleted file mode 100644 index 1705e99..0000000 --- a/src/main/java/sh/libre/scim/core/exceptions/ErrorForScimEndpointException.java +++ /dev/null @@ -1,8 +0,0 @@ -package sh.libre.scim.core.exceptions; - -public class ErrorForScimEndpointException extends ScimPropagationException { - - public ErrorForScimEndpointException(String message) { - super(message); - } -} diff --git a/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimDataException.java b/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimDataException.java deleted file mode 100644 index 947a9f1..0000000 --- a/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimDataException.java +++ /dev/null @@ -1,7 +0,0 @@ -package sh.libre.scim.core.exceptions; - -public class InconsistentScimDataException extends ScimPropagationException { - public InconsistentScimDataException(String message) { - super(message); - } -} diff --git a/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java b/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java new file mode 100644 index 0000000..44f7eb4 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/InconsistentScimMappingException.java @@ -0,0 +1,7 @@ +package sh.libre.scim.core.exceptions; + +public class InconsistentScimMappingException extends ScimPropagationException { + public InconsistentScimMappingException(String message) { + super(message); + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java b/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java new file mode 100644 index 0000000..27c5c97 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java @@ -0,0 +1,18 @@ +package sh.libre.scim.core.exceptions; + +import de.captaingoldfish.scim.sdk.client.response.ServerResponse; + +public class InvalidResponseFromScimEndpointException extends ScimPropagationException { + + private final ServerResponse response; + + public InvalidResponseFromScimEndpointException(ServerResponse response, String message) { + super(message); + this.response = response; + } + + public ServerResponse getResponse() { + return response; + } + +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackOnlyForCriticalErrorsStrategy.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackOnlyForCriticalErrorsStrategy.java new file mode 100644 index 0000000..fae0926 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackOnlyForCriticalErrorsStrategy.java @@ -0,0 +1,32 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + +public class RollbackOnlyForCriticalErrorsStrategy implements RollbackStrategy { + + private boolean shouldRollback(InvalidResponseFromScimEndpointException e) { + int httpStatus = e.getResponse().getHttpStatus(); + return httpStatus == 500; + } + + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + if (e instanceof InvalidResponseFromScimEndpointException invalidResponseFromScimEndpointException) { + return shouldRollback(invalidResponseFromScimEndpointException); + } + if (e instanceof InconsistentScimMappingException) { + // Occurs when mapping between a SCIM resource and a keycloak user failed (missing, ambiguous..) + // Log can be sufficient here, no rollback required + return false; + } + if (e instanceof UnexpectedScimDataException) { + // Occurs when a SCIM endpoint sends invalid date (e.g. group with empty name, user without ids...) + // No rollback required : we cannot recover. This needs to be fixed in the SCIM endpoint data + return false; + } + // Should not occur + throw new IllegalStateException("Unkown ScimPropagationException", e); + } + + +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java index b2df155..23651a7 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java @@ -9,13 +9,15 @@ public class RollbackStrategyFactory { return switch (approach) { case ALWAYS_ROLLBACK -> new AlwaysRollbackStrategy(); case NEVER_ROLLBACK -> new NeverRollbackStrategy(); + case CRITICAL_ONLY_ROLLBACK -> new RollbackOnlyForCriticalErrorsStrategy(); }; } public enum RollbackApproach { - ALWAYS_ROLLBACK, NEVER_ROLLBACK + ALWAYS_ROLLBACK, NEVER_ROLLBACK, CRITICAL_ONLY_ROLLBACK } + private static final class AlwaysRollbackStrategy implements RollbackStrategy { @Override diff --git a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java index 626c04e..7a6d6bf 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java @@ -17,7 +17,7 @@ public class ScimExceptionHandler { private final RollbackStrategy rollbackStrategy; public ScimExceptionHandler(KeycloakSession session) { - this(session, RollbackStrategyFactory.create(RollbackStrategyFactory.RollbackApproach.NEVER_ROLLBACK)); + this(session, RollbackStrategyFactory.create(RollbackStrategyFactory.RollbackApproach.CRITICAL_ONLY_ROLLBACK)); } public ScimExceptionHandler(KeycloakSession session, RollbackStrategy rollbackStrategy) { diff --git a/src/main/resources/META-INF/scim-resource-changelog.xml b/src/main/resources/META-INF/scim-resource-changelog.xml index 45be732..cf300fa 100644 --- a/src/main/resources/META-INF/scim-resource-changelog.xml +++ b/src/main/resources/META-INF/scim-resource-changelog.xml @@ -1,28 +1,35 @@ - + - + - + - + - + - + - + - - - + + + \ No newline at end of file -- GitLab From 387abc30f8b94d1ecb92e01c3c66ca6a848351b4 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Thu, 27 Jun 2024 11:15:59 +0200 Subject: [PATCH 51/58] Use enum interfaces for RollbackStrategy and SkipOrStopStrategy --- .../sh/libre/scim/core/ScimDispatcher.java | 6 +-- .../core/exceptions/RollbackApproach.java | 44 +++++++++++++++++++ ...RollbackOnlyForCriticalErrorsStrategy.java | 32 -------------- .../exceptions/RollbackStrategyFactory.java | 36 --------------- .../core/exceptions/ScimExceptionHandler.java | 2 +- ...gyFactory.java => SkipOrStopApproach.java} | 23 ++-------- 6 files changed, 51 insertions(+), 92 deletions(-) create mode 100644 src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java delete mode 100644 src/main/java/sh/libre/scim/core/exceptions/RollbackOnlyForCriticalErrorsStrategy.java delete mode 100644 src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java rename src/main/java/sh/libre/scim/core/exceptions/{SkipOrStopStrategyFactory.java => SkipOrStopApproach.java} (70%) diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 0ab00fc..b0142ee 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -5,8 +5,8 @@ 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.core.exceptions.SkipOrStopApproach; import sh.libre.scim.core.exceptions.SkipOrStopStrategy; -import sh.libre.scim.core.exceptions.SkipOrStopStrategyFactory; import sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory; import java.util.ArrayList; @@ -34,9 +34,7 @@ public class ScimDispatcher { this.session = session; this.exceptionHandler = new ScimExceptionHandler(session); // By default, use a permissive Skip or Stop strategy - this.skipOrStopStrategy = SkipOrStopStrategyFactory.create( - SkipOrStopStrategyFactory.SkipOrStopApproach.ALWAYS_SKIP_AND_CONTINUE - ); + this.skipOrStopStrategy = SkipOrStopApproach.ALWAYS_SKIP_AND_CONTINUE; } /** diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java new file mode 100644 index 0000000..fa5f598 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java @@ -0,0 +1,44 @@ +package sh.libre.scim.core.exceptions; + +import sh.libre.scim.core.ScrimEndPointConfiguration; + + +public enum RollbackApproach implements RollbackStrategy { + ALWAYS_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + return true; + } + }, + NEVER_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + return false; + } + }, + CRITICAL_ONLY_ROLLBACK { + @Override + public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { + if (e instanceof InconsistentScimMappingException) { + // Occurs when mapping between a SCIM resource and a keycloak user failed (missing, ambiguous..) + // Log can be sufficient here, no rollback required + return false; + } + if (e instanceof UnexpectedScimDataException) { + // Occurs when a SCIM endpoint sends invalid date (e.g. group with empty name, user without ids...) + // No rollback required : we cannot recover. This needs to be fixed in the SCIM endpoint data + return false; + } + if (e instanceof InvalidResponseFromScimEndpointException invalidResponseFromScimEndpointException) { + return shouldRollbackBecauseOfResponse(invalidResponseFromScimEndpointException); + } + // Should not occur + throw new IllegalStateException("Unkown ScimPropagationException", e); + } + + private boolean shouldRollbackBecauseOfResponse(InvalidResponseFromScimEndpointException e) { + int httpStatus = e.getResponse().getHttpStatus(); + return httpStatus == 500; + } + } +} diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackOnlyForCriticalErrorsStrategy.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackOnlyForCriticalErrorsStrategy.java deleted file mode 100644 index fae0926..0000000 --- a/src/main/java/sh/libre/scim/core/exceptions/RollbackOnlyForCriticalErrorsStrategy.java +++ /dev/null @@ -1,32 +0,0 @@ -package sh.libre.scim.core.exceptions; - -import sh.libre.scim.core.ScrimEndPointConfiguration; - -public class RollbackOnlyForCriticalErrorsStrategy implements RollbackStrategy { - - private boolean shouldRollback(InvalidResponseFromScimEndpointException e) { - int httpStatus = e.getResponse().getHttpStatus(); - return httpStatus == 500; - } - - @Override - public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { - if (e instanceof InvalidResponseFromScimEndpointException invalidResponseFromScimEndpointException) { - return shouldRollback(invalidResponseFromScimEndpointException); - } - if (e instanceof InconsistentScimMappingException) { - // Occurs when mapping between a SCIM resource and a keycloak user failed (missing, ambiguous..) - // Log can be sufficient here, no rollback required - return false; - } - if (e instanceof UnexpectedScimDataException) { - // Occurs when a SCIM endpoint sends invalid date (e.g. group with empty name, user without ids...) - // No rollback required : we cannot recover. This needs to be fixed in the SCIM endpoint data - return false; - } - // Should not occur - throw new IllegalStateException("Unkown ScimPropagationException", e); - } - - -} diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java deleted file mode 100644 index 23651a7..0000000 --- a/src/main/java/sh/libre/scim/core/exceptions/RollbackStrategyFactory.java +++ /dev/null @@ -1,36 +0,0 @@ -package sh.libre.scim.core.exceptions; - -import sh.libre.scim.core.ScrimEndPointConfiguration; - -public class RollbackStrategyFactory { - - public static RollbackStrategy create(RollbackApproach approach) { - // We could imagine more fine-grained rollback strategies (e.g. based on each Scim endpoint configuration) - return switch (approach) { - case ALWAYS_ROLLBACK -> new AlwaysRollbackStrategy(); - case NEVER_ROLLBACK -> new NeverRollbackStrategy(); - case CRITICAL_ONLY_ROLLBACK -> new RollbackOnlyForCriticalErrorsStrategy(); - }; - } - - public enum RollbackApproach { - ALWAYS_ROLLBACK, NEVER_ROLLBACK, CRITICAL_ONLY_ROLLBACK - } - - - private static final class AlwaysRollbackStrategy implements RollbackStrategy { - - @Override - public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { - return true; - } - } - - private static final class NeverRollbackStrategy implements RollbackStrategy { - - @Override - public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) { - return true; - } - } -} diff --git a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java index 7a6d6bf..b525ae1 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java @@ -17,7 +17,7 @@ public class ScimExceptionHandler { private final RollbackStrategy rollbackStrategy; public ScimExceptionHandler(KeycloakSession session) { - this(session, RollbackStrategyFactory.create(RollbackStrategyFactory.RollbackApproach.CRITICAL_ONLY_ROLLBACK)); + this(session, RollbackApproach.CRITICAL_ONLY_ROLLBACK); } public ScimExceptionHandler(KeycloakSession session, RollbackStrategy rollbackStrategy) { diff --git a/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategyFactory.java b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java similarity index 70% rename from src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategyFactory.java rename to src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java index 3bde1d4..e0669d5 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopStrategyFactory.java +++ b/src/main/java/sh/libre/scim/core/exceptions/SkipOrStopApproach.java @@ -2,22 +2,9 @@ package sh.libre.scim.core.exceptions; import sh.libre.scim.core.ScrimEndPointConfiguration; -public class SkipOrStopStrategyFactory { - - public static SkipOrStopStrategy create(SkipOrStopApproach approach) { - // We could imagine more fine-grained strategies (e.g. based on each Scim endpoint configuration) - return switch (approach) { - case ALWAYS_STOP -> new AlwaysStopStrategy(); - case ALWAYS_SKIP_AND_CONTINUE -> new AlwaysSkipAndContinueStrategy(); - }; - } - - public enum SkipOrStopApproach { - ALWAYS_SKIP_AND_CONTINUE, ALWAYS_STOP - } - - private static final class AlwaysStopStrategy implements SkipOrStopStrategy { +public enum SkipOrStopApproach implements SkipOrStopStrategy { + ALWAYS_SKIP_AND_CONTINUE { @Override public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) { return false; @@ -42,10 +29,8 @@ public class SkipOrStopStrategyFactory { public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) { return false; } - } - - private static final class AlwaysSkipAndContinueStrategy implements SkipOrStopStrategy { - + }, + ALWAYS_STOP { @Override public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) { return true; -- GitLab From 9c7c557b76bfb668fe8a0b9f36dad5f4a6cce4a3 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Thu, 18 Jul 2024 10:43:38 +0200 Subject: [PATCH 52/58] Separate Dirty group update in a dedicated class --- .../sh/libre/scim/core/ScimDispatcher.java | 8 +- ...ntConfigurationStorageProviderFactory.java | 33 ++------- .../core/exceptions/RollbackApproach.java | 7 +- .../{ => service}/AbstractScimService.java | 3 +- .../{ => service}/EntityOnRemoteScimId.java | 2 +- .../core/{ => service}/GroupScimService.java | 3 +- .../scim/core/{ => service}/KeycloakDao.java | 2 +- .../scim/core/{ => service}/KeycloakId.java | 3 +- .../scim/core/{ => service}/ScimClient.java | 6 +- .../core/{ => service}/ScimResourceType.java | 2 +- .../core/{ => service}/UserScimService.java | 3 +- .../ScimBackgroundGroupMembershipUpdater.java | 74 +++++++++++++++++++ .../scim/event/ScimEventListenerProvider.java | 19 +++-- .../sh/libre/scim/jpa/ScimResourceDao.java | 6 +- .../sh/libre/scim/jpa/ScimResourceId.java | 4 +- .../libre/scim/jpa/ScimResourceMapping.java | 4 +- ...eycloak.storage.UserStorageProviderFactory | 2 +- 17 files changed, 122 insertions(+), 59 deletions(-) rename src/main/java/sh/libre/scim/{storage => core}/ScimEndpointConfigurationStorageProviderFactory.java (80%) rename src/main/java/sh/libre/scim/core/{ => service}/AbstractScimService.java (99%) rename src/main/java/sh/libre/scim/core/{ => service}/EntityOnRemoteScimId.java (65%) rename src/main/java/sh/libre/scim/core/{ => service}/GroupScimService.java (98%) rename src/main/java/sh/libre/scim/core/{ => service}/KeycloakDao.java (98%) rename src/main/java/sh/libre/scim/core/{ => service}/KeycloakId.java (54%) rename src/main/java/sh/libre/scim/core/{ => service}/ScimClient.java (95%) rename src/main/java/sh/libre/scim/core/{ => service}/ScimResourceType.java (95%) rename src/main/java/sh/libre/scim/core/{ => service}/UserScimService.java (98%) create mode 100644 src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index b0142ee..4146c30 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -7,7 +7,9 @@ import sh.libre.scim.core.exceptions.ScimExceptionHandler; import sh.libre.scim.core.exceptions.ScimPropagationException; import sh.libre.scim.core.exceptions.SkipOrStopApproach; import sh.libre.scim.core.exceptions.SkipOrStopStrategy; -import sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory; +import sh.libre.scim.core.service.AbstractScimService; +import sh.libre.scim.core.service.GroupScimService; +import sh.libre.scim.core.service.UserScimService; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -85,7 +87,7 @@ public class ScimDispatcher { exceptionHandler.handleException(userScimService.getConfiguration(), e); } }); - // TODO we could iterate on servicesCorrectlyPropagated to undo modification + // TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints LOGGER.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); } @@ -100,7 +102,7 @@ public class ScimDispatcher { exceptionHandler.handleException(groupScimService.getConfiguration(), e); } }); - // TODO we could iterate on servicesCorrectlyPropagated to undo modification + // TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints LOGGER.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size()); } diff --git a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java similarity index 80% rename from src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java rename to src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java index f825ab6..60f61b8 100644 --- a/src/main/java/sh/libre/scim/storage/ScimEndpointConfigurationStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java @@ -1,11 +1,10 @@ -package sh.libre.scim.storage; +package sh.libre.scim.core; import de.captaingoldfish.scim.sdk.common.constants.HttpHeader; import jakarta.ws.rs.core.MediaType; import org.apache.commons.lang3.BooleanUtils; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; -import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; @@ -17,11 +16,8 @@ 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.ScimDispatcher; -import sh.libre.scim.core.ScrimEndPointConfiguration; +import sh.libre.scim.event.ScimBackgroundGroupMembershipUpdater; -import java.time.Duration; import java.util.Date; import java.util.List; @@ -31,7 +27,7 @@ import java.util.List; public class ScimEndpointConfigurationStorageProviderFactory implements UserStorageProviderFactory, ImportSynchronization { public static final String ID = "scim"; - private final Logger LOGGER = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class); + private static final Logger LOGGER = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class); @Override public String getId() { @@ -42,7 +38,7 @@ public class ScimEndpointConfigurationStorageProviderFactory @Override public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) { - // TODO if this should be kept here, better document purpose & usage + // Manually Launch a synchronization between keycloack and the SCIM endpoint described in the given model LOGGER.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getId()); SynchronizationResult result = new SynchronizationResult(); KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { @@ -68,25 +64,8 @@ public class ScimEndpointConfigurationStorageProviderFactory @Override public void postInit(KeycloakSessionFactory factory) { - // TODO : find a better way to handle scim dirty (use a QUEUE for SCIM queries ?) - try (KeycloakSession keycloakSession = factory.create()) { - TimerProvider timer = keycloakSession.getProvider(TimerProvider.class); - timer.scheduleTask(taskSession -> { - for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { - KeycloakModelUtils.runJobInTransaction(factory, session -> { - session.getContext().setRealm(realm); - ScimDispatcher dispatcher = new ScimDispatcher(session); - 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.update(group)); - group.removeAttribute("scim-dirty"); - } - dispatcher.close(); - }); - } - }, Duration.ofSeconds(30).toMillis(), "scim-background"); - } + ScimBackgroundGroupMembershipUpdater scimBackgroundGroupMembershipUpdater = new ScimBackgroundGroupMembershipUpdater(factory); + scimBackgroundGroupMembershipUpdater.startBackgroundUpdates(); } @Override diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java index fa5f598..aeca5e9 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java @@ -1,7 +1,10 @@ package sh.libre.scim.core.exceptions; +import com.google.common.collect.Lists; import sh.libre.scim.core.ScrimEndPointConfiguration; +import java.util.ArrayList; + public enum RollbackApproach implements RollbackStrategy { ALWAYS_ROLLBACK { @@ -37,8 +40,10 @@ public enum RollbackApproach implements RollbackStrategy { } private boolean shouldRollbackBecauseOfResponse(InvalidResponseFromScimEndpointException e) { + // We consider that 404 are acceptable, otherwise rollback int httpStatus = e.getResponse().getHttpStatus(); - return httpStatus == 500; + ArrayList acceptableStatus = Lists.newArrayList(200, 204, 404); + return !acceptableStatus.contains(httpStatus); } } } diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/service/AbstractScimService.java similarity index 99% rename from src/main/java/sh/libre/scim/core/AbstractScimService.java rename to src/main/java/sh/libre/scim/core/service/AbstractScimService.java index c350702..8397464 100644 --- a/src/main/java/sh/libre/scim/core/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/service/AbstractScimService.java @@ -1,4 +1,4 @@ -package sh.libre.scim.core; +package sh.libre.scim.core.service; import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; @@ -6,6 +6,7 @@ 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.core.ScrimEndPointConfiguration; import sh.libre.scim.core.exceptions.InconsistentScimMappingException; import sh.libre.scim.core.exceptions.InvalidResponseFromScimEndpointException; import sh.libre.scim.core.exceptions.SkipOrStopStrategy; diff --git a/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java b/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java similarity index 65% rename from src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java rename to src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java index 3249608..df96a12 100644 --- a/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java +++ b/src/main/java/sh/libre/scim/core/service/EntityOnRemoteScimId.java @@ -1,4 +1,4 @@ -package sh.libre.scim.core; +package sh.libre.scim.core.service; public record EntityOnRemoteScimId( String asString diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/service/GroupScimService.java similarity index 98% rename from src/main/java/sh/libre/scim/core/GroupScimService.java rename to src/main/java/sh/libre/scim/core/service/GroupScimService.java index 35daece..5297acd 100644 --- a/src/main/java/sh/libre/scim/core/GroupScimService.java +++ b/src/main/java/sh/libre/scim/core/service/GroupScimService.java @@ -1,4 +1,4 @@ -package sh.libre.scim.core; +package sh.libre.scim.core.service; import de.captaingoldfish.scim.sdk.common.resources.Group; import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; @@ -10,6 +10,7 @@ import org.jboss.logging.Logger; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; +import sh.libre.scim.core.ScrimEndPointConfiguration; import sh.libre.scim.core.exceptions.InconsistentScimMappingException; import sh.libre.scim.core.exceptions.SkipOrStopStrategy; import sh.libre.scim.core.exceptions.UnexpectedScimDataException; diff --git a/src/main/java/sh/libre/scim/core/KeycloakDao.java b/src/main/java/sh/libre/scim/core/service/KeycloakDao.java similarity index 98% rename from src/main/java/sh/libre/scim/core/KeycloakDao.java rename to src/main/java/sh/libre/scim/core/service/KeycloakDao.java index 67f58da..f4c406c 100644 --- a/src/main/java/sh/libre/scim/core/KeycloakDao.java +++ b/src/main/java/sh/libre/scim/core/service/KeycloakDao.java @@ -1,4 +1,4 @@ -package sh.libre.scim.core; +package sh.libre.scim.core.service; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; diff --git a/src/main/java/sh/libre/scim/core/KeycloakId.java b/src/main/java/sh/libre/scim/core/service/KeycloakId.java similarity index 54% rename from src/main/java/sh/libre/scim/core/KeycloakId.java rename to src/main/java/sh/libre/scim/core/service/KeycloakId.java index 432892e..04bad47 100644 --- a/src/main/java/sh/libre/scim/core/KeycloakId.java +++ b/src/main/java/sh/libre/scim/core/service/KeycloakId.java @@ -1,6 +1,5 @@ -package sh.libre.scim.core; +package sh.libre.scim.core.service; -// TODO rename this public record KeycloakId( String asString ) { diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/service/ScimClient.java similarity index 95% rename from src/main/java/sh/libre/scim/core/ScimClient.java rename to src/main/java/sh/libre/scim/core/service/ScimClient.java index 7649ccb..7fcda90 100644 --- a/src/main/java/sh/libre/scim/core/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/service/ScimClient.java @@ -1,4 +1,4 @@ -package sh.libre.scim.core; +package sh.libre.scim.core.service; import com.google.common.net.HttpHeaders; import de.captaingoldfish.scim.sdk.client.ScimClientConfig; @@ -12,9 +12,9 @@ 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.ScrimEndPointConfiguration; import sh.libre.scim.core.exceptions.InvalidResponseFromScimEndpointException; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -53,8 +53,6 @@ public class ScimClient implements AutoCloseable { .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(); ScimRequestBuilder scimRequestBuilder = new ScimRequestBuilder( diff --git a/src/main/java/sh/libre/scim/core/ScimResourceType.java b/src/main/java/sh/libre/scim/core/service/ScimResourceType.java similarity index 95% rename from src/main/java/sh/libre/scim/core/ScimResourceType.java rename to src/main/java/sh/libre/scim/core/service/ScimResourceType.java index 23df9f8..b90845b 100644 --- a/src/main/java/sh/libre/scim/core/ScimResourceType.java +++ b/src/main/java/sh/libre/scim/core/service/ScimResourceType.java @@ -1,4 +1,4 @@ -package sh.libre.scim.core; +package sh.libre.scim.core.service; import de.captaingoldfish.scim.sdk.common.resources.Group; import de.captaingoldfish.scim.sdk.common.resources.ResourceNode; diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/service/UserScimService.java similarity index 98% rename from src/main/java/sh/libre/scim/core/UserScimService.java rename to src/main/java/sh/libre/scim/core/service/UserScimService.java index 54c8345..cfd4a82 100644 --- a/src/main/java/sh/libre/scim/core/UserScimService.java +++ b/src/main/java/sh/libre/scim/core/service/UserScimService.java @@ -1,4 +1,4 @@ -package sh.libre.scim.core; +package sh.libre.scim.core.service; import de.captaingoldfish.scim.sdk.common.resources.User; import de.captaingoldfish.scim.sdk.common.resources.complex.Meta; @@ -13,6 +13,7 @@ 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.ScrimEndPointConfiguration; import sh.libre.scim.core.exceptions.InconsistentScimMappingException; import sh.libre.scim.core.exceptions.SkipOrStopStrategy; import sh.libre.scim.core.exceptions.UnexpectedScimDataException; diff --git a/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java b/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java new file mode 100644 index 0000000..d7a0731 --- /dev/null +++ b/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java @@ -0,0 +1,74 @@ +package sh.libre.scim.event; + +import org.jboss.logging.Logger; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.timer.TimerProvider; +import sh.libre.scim.core.ScimDispatcher; + +import java.time.Duration; + +/** + * In charge of making background checks and sent + * UPDATE requests from group for which membership information has changed. + *

+ * This is required to avoid immediate group membership updates which could cause + * to incorrect group members list in case of concurrent group membership changes. + */ +public class ScimBackgroundGroupMembershipUpdater { + public static final String GROUP_DIRTY_SINCE_ATTRIBUTE_NAME = "scim-dirty-since"; + + private static final Logger LOGGER = Logger.getLogger(ScimBackgroundGroupMembershipUpdater.class); + // Update check loop will run every time this delay has passed + private static final long UPDATE_CHECK_DELAY_MS = 2000; + // If a group is marked dirty since less that this debounce delay, wait for the next update check loop + private static final long DEBOUNCE_DELAY_MS = 1200; + private final KeycloakSessionFactory sessionFactory; + + public ScimBackgroundGroupMembershipUpdater(KeycloakSessionFactory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + public void startBackgroundUpdates() { + // Every UPDATE_CHECK_DELAY_MS, check for dirty groups and send updates if required + try (KeycloakSession keycloakSession = sessionFactory.create()) { + TimerProvider timer = keycloakSession.getProvider(TimerProvider.class); + timer.scheduleTask(taskSession -> { + for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) { + dispatchDirtyGroupsUpdates(realm); + } + }, Duration.ofMillis(UPDATE_CHECK_DELAY_MS).toMillis(), "scim-background"); + } + } + + private void dispatchDirtyGroupsUpdates(RealmModel realm) { + KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { + session.getContext().setRealm(realm); + ScimDispatcher dispatcher = new ScimDispatcher(session); + // Identify groups marked as dirty by the ScimEventListenerProvider + for (GroupModel group : session.groups().getGroupsStream(realm) + .filter(this::isDirtyGroup).toList()) { + LOGGER.infof("[SCIM] Group %s is dirty, dispatch an update", group.getName()); + // If dirty : dispatch a group update to all clients and mark it clean + dispatcher.dispatchGroupModificationToAll(client -> client.update(group)); + group.removeAttribute(GROUP_DIRTY_SINCE_ATTRIBUTE_NAME); + } + dispatcher.close(); + }); + } + + private boolean isDirtyGroup(GroupModel g) { + String groupDirtySinceAttribute = g.getFirstAttribute(GROUP_DIRTY_SINCE_ATTRIBUTE_NAME); + try { + int groupDirtySince = Integer.parseInt(groupDirtySinceAttribute); + // Must be dirty for more than DEBOUNCE_DELAY_MS + // (otherwise update will be dispatched in next scheduled loop) + return System.currentTimeMillis() - groupDirtySince > DEBOUNCE_DELAY_MS; + } catch (NumberFormatException e) { + return false; + } + } +} diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 4d6f7f4..7d765b6 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -1,6 +1,5 @@ package sh.libre.scim.event; -import org.apache.commons.lang3.BooleanUtils; import org.jboss.logging.Logger; import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; @@ -11,10 +10,10 @@ import org.keycloak.events.admin.ResourceType; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; -import sh.libre.scim.core.KeycloakDao; -import sh.libre.scim.core.KeycloakId; import sh.libre.scim.core.ScimDispatcher; -import sh.libre.scim.core.ScimResourceType; +import sh.libre.scim.core.service.KeycloakDao; +import sh.libre.scim.core.service.KeycloakId; +import sh.libre.scim.core.service.ScimResourceType; import java.util.Map; import java.util.regex.Matcher; @@ -35,7 +34,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { private final KeycloakDao keycloakDao; - private final Map patterns = Map.of( + private final Map listenedEventPathPatterns = Map.of( ResourceType.USER, Pattern.compile("users/(.+)"), ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"), ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)"), @@ -79,7 +78,7 @@ public class ScimEventListenerProvider implements EventListenerProvider { @Override public void onEvent(AdminEvent event, boolean includeRepresentation) { // Step 1: check if event is relevant for propagation through SCIM - Pattern pattern = patterns.get(event.getResourceType()); + Pattern pattern = listenedEventPathPatterns.get(event.getResourceType()); if (pattern == null) return; Matcher matcher = pattern.matcher(event.getResourcePath()); @@ -171,10 +170,16 @@ public class ScimEventListenerProvider implements EventListenerProvider { private void handleGroupMemberShipEvent(AdminEvent groupMemberShipEvent, KeycloakId userId, KeycloakId groupId) { LOGGER.infof("[SCIM] Propagate GroupMemberShip %s - User %s Group %s", groupMemberShipEvent.getOperationType(), userId, groupId); + // Step 1: update USER immediately GroupModel group = getGroup(groupId); - group.setSingleAttribute("scim-dirty", BooleanUtils.TRUE); UserModel user = getUser(userId); dispatcher.dispatchUserModificationToAll(client -> client.update(user)); + + // Step 2: delayed GROUP update : + // if several users are added to the group simultaneously in different Keycloack sessions + // update the group in the context of the current session may not reflect those other changes + // We trigger a delayed update by setting an attribute on the group (that will be handled by ScimBackgroundGroupMembershipUpdaters) + group.setSingleAttribute(ScimBackgroundGroupMembershipUpdater.GROUP_DIRTY_SINCE_ATTRIBUTE_NAME, "" + System.currentTimeMillis()); } private void handleRoleMappingEvent(AdminEvent roleMappingEvent, ScimResourceType type, KeycloakId id) { diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java index 97db33b..3824ad4 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -5,9 +5,9 @@ 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 sh.libre.scim.core.service.EntityOnRemoteScimId; +import sh.libre.scim.core.service.KeycloakId; +import sh.libre.scim.core.service.ScimResourceType; import java.util.Optional; diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java index 99bce76..d0abddf 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -67,10 +67,8 @@ public class ScimResourceId implements Serializable { public boolean equals(Object other) { if (this == other) return true; - if (!(other instanceof ScimResourceId)) + if (!(other instanceof ScimResourceId o)) return false; - ScimResourceId o = (ScimResourceId) other; - // TODO return (StringUtils.equals(o.id, id) && StringUtils.equals(o.realmId, realmId) && StringUtils.equals(o.componentId, componentId) && diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java b/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java index 26eafa0..fabd266 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java @@ -7,8 +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; +import sh.libre.scim.core.service.EntityOnRemoteScimId; +import sh.libre.scim.core.service.KeycloakId; @Entity @IdClass(ScimResourceId.class) diff --git a/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory index 23371dd..308796c 100644 --- a/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory +++ b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -1 +1 @@ -sh.libre.scim.storage.ScimEndpointConfigurationStorageProviderFactory +sh.libre.scim.core.ScimEndpointConfigurationStorageProviderFactory -- GitLab From d8cba394b2de0c679aac070261a90bef788b1b07 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Thu, 18 Jul 2024 15:44:15 +0200 Subject: [PATCH 53/58] Handle retry exceptions as a 500 server response --- .../sh/libre/scim/core/ScimDispatcher.java | 4 +- .../scim/core/ScrimEndPointConfiguration.java | 6 ++ ...alidResponseFromScimEndpointException.java | 19 +++-- .../core/exceptions/RollbackApproach.java | 14 ++-- .../core/exceptions/ScimExceptionHandler.java | 4 +- .../exceptions/ScimPropagationException.java | 4 +- .../scim/core/service/GroupScimService.java | 2 +- .../libre/scim/core/service/ScimClient.java | 72 +++++++++++-------- .../scim/core/service/UserScimService.java | 11 +-- .../ScimBackgroundGroupMembershipUpdater.java | 2 +- .../sh/libre/scim/jpa/ScimResourceDao.java | 1 - .../libre/scim/jpa/ScimResourceMapping.java | 4 +- .../libre/scim/jpa/ScimResourceProvider.java | 1 + .../META-INF/scim-resource-changelog.xml | 10 +-- 14 files changed, 95 insertions(+), 59 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java index 4146c30..d3d6751 100644 --- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -113,7 +113,7 @@ public class ScimDispatcher { if (matchingClient.isPresent()) { try { operationToDispatch.acceptThrows(matchingClient.get()); - LOGGER.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); + LOGGER.infof("[SCIM] User operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName()); } catch (ScimPropagationException e) { exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); } @@ -130,7 +130,7 @@ public class ScimDispatcher { if (matchingClient.isPresent()) { try { operationToDispatch.acceptThrows(matchingClient.get()); - LOGGER.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getId()); + LOGGER.infof("[SCIM] Group operation dispatched to SCIM server %s", matchingClient.get().getConfiguration().getName()); } catch (ScimPropagationException e) { exceptionHandler.handleException(matchingClient.get().getConfiguration(), e); } diff --git a/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java index 0df9ced..23755fd 100644 --- a/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java +++ b/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java @@ -18,6 +18,7 @@ public class ScrimEndPointConfiguration { private final String endPoint; private final String id; + private final String name; private final String contentType; private final String authorizationHeaderValue; private final ImportAction importAction; @@ -42,6 +43,7 @@ public class ScrimEndPointConfiguration { contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE, ""); endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT, ""); id = scimProviderConfiguration.getId(); + name = scimProviderConfiguration.getName(); importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); pullFromScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); pushToScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); @@ -70,6 +72,10 @@ public class ScrimEndPointConfiguration { return id; } + public String getName() { + return name; + } + public ImportAction getImportAction() { return importAction; } diff --git a/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java b/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java index 27c5c97..0794436 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java +++ b/src/main/java/sh/libre/scim/core/exceptions/InvalidResponseFromScimEndpointException.java @@ -2,16 +2,27 @@ package sh.libre.scim.core.exceptions; import de.captaingoldfish.scim.sdk.client.response.ServerResponse; +import java.util.Optional; + public class InvalidResponseFromScimEndpointException extends ScimPropagationException { - - private final ServerResponse response; + + private final transient Optional response; public InvalidResponseFromScimEndpointException(ServerResponse response, String message) { super(message); - this.response = response; + this.response = Optional.of(response); } - public ServerResponse getResponse() { + public InvalidResponseFromScimEndpointException(String message, Exception e) { + super(message, e); + this.response = Optional.empty(); + } + + + /** + * Empty response can occur if a major exception was thrown while retrying the request. + */ + public Optional getResponse() { return response; } diff --git a/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java b/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java index aeca5e9..d1fb108 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java +++ b/src/main/java/sh/libre/scim/core/exceptions/RollbackApproach.java @@ -40,10 +40,16 @@ public enum RollbackApproach implements RollbackStrategy { } private boolean shouldRollbackBecauseOfResponse(InvalidResponseFromScimEndpointException e) { - // We consider that 404 are acceptable, otherwise rollback - int httpStatus = e.getResponse().getHttpStatus(); - ArrayList acceptableStatus = Lists.newArrayList(200, 204, 404); - return !acceptableStatus.contains(httpStatus); + // If we have a response + return e.getResponse().map(r -> { + // We consider that 404 are acceptable, otherwise rollback + ArrayList acceptableStatus = Lists.newArrayList(200, 204, 404); + return !acceptableStatus.contains(r.getHttpStatus()); + }).orElse( + // Never got an answer, server was either misconfigured or unreachable + // No rollback in that case. + false + ); } } } diff --git a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java index b525ae1..993f287 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimExceptionHandler.java @@ -32,10 +32,10 @@ public class ScimExceptionHandler { * @param e the occuring exception */ public void handleException(ScrimEndPointConfiguration scimProviderConfiguration, ScimPropagationException e) { - String errorMessage = "[SCIM] Error while propagating to SCIM endpoint " + scimProviderConfiguration.getId(); + String errorMessage = "[SCIM] Error while propagating to SCIM endpoint " + scimProviderConfiguration.getName(); if (rollbackStrategy.shouldRollback(scimProviderConfiguration, e)) { session.getTransactionManager().rollback(); - LOGGER.error(errorMessage, e); + LOGGER.error("TRANSACTION ROLLBACK - " + errorMessage, e); } else { LOGGER.warn(errorMessage); } diff --git a/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java b/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java index 7b1a747..bee5ee1 100644 --- a/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java +++ b/src/main/java/sh/libre/scim/core/exceptions/ScimPropagationException.java @@ -2,11 +2,11 @@ package sh.libre.scim.core.exceptions; public abstract class ScimPropagationException extends Exception { - public ScimPropagationException(String message) { + protected ScimPropagationException(String message) { super(message); } - public ScimPropagationException(String message, Exception e) { + protected ScimPropagationException(String message, Exception e) { super(message, e); } } diff --git a/src/main/java/sh/libre/scim/core/service/GroupScimService.java b/src/main/java/sh/libre/scim/core/service/GroupScimService.java index 5297acd..bd09f3e 100644 --- a/src/main/java/sh/libre/scim/core/service/GroupScimService.java +++ b/src/main/java/sh/libre/scim/core/service/GroupScimService.java @@ -24,7 +24,7 @@ import java.util.TreeSet; import java.util.stream.Stream; public class GroupScimService extends AbstractScimService { - private final Logger LOGGER = Logger.getLogger(GroupScimService.class); + private static final Logger LOGGER = Logger.getLogger(GroupScimService.class); public GroupScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration, SkipOrStopStrategy skipOrStopStrategy) { super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP, skipOrStopStrategy); diff --git a/src/main/java/sh/libre/scim/core/service/ScimClient.java b/src/main/java/sh/libre/scim/core/service/ScimClient.java index 7fcda90..90bcfcf 100644 --- a/src/main/java/sh/libre/scim/core/service/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/service/ScimClient.java @@ -29,7 +29,9 @@ public class ScimClient implements AutoCloseable { private final ScimResourceType scimResourceType; - { + private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType) { + this.scimRequestBuilder = scimRequestBuilder; + this.scimResourceType = scimResourceType; RetryConfig retryConfig = RetryConfig.custom() .maxAttempts(10) .intervalFunction(IntervalFunction.ofExponentialBackoff()) @@ -38,11 +40,6 @@ public class ScimClient implements AutoCloseable { retryRegistry = RetryRegistry.of(retryConfig); } - private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType) { - this.scimRequestBuilder = scimRequestBuilder; - this.scimResourceType = scimResourceType; - } - public static ScimClient open(ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); Map httpHeaders = new HashMap<>(); @@ -69,18 +66,23 @@ public class ScimClient implements AutoCloseable { "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 response = retry.executeSupplier(() -> scimRequestBuilder - .create(getResourceClass(), getScimEndpoint()) - .setResource(scimForCreation) - .sendRequest() - ); - checkResponseIsSuccess(response); - S resource = response.getResource(); - return resource.getId() - .map(EntityOnRemoteScimId::new) - .orElseThrow(() -> new InvalidResponseFromScimEndpointException(response, "Created SCIM resource does not have id")); + try { + Retry retry = retryRegistry.retry("create-%s".formatted(id.asString())); + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .create(getResourceClass(), getScimEndpoint()) + .setResource(scimForCreation) + .sendRequest() + ); + checkResponseIsSuccess(response); + S resource = response.getResource(); + return resource.getId() + .map(EntityOnRemoteScimId::new) + .orElseThrow(() -> new InvalidResponseFromScimEndpointException(response, "Created SCIM resource does not have id")); + + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying create " + e.getMessage(), e); + } } private void checkResponseIsSuccess(ServerResponse response) throws InvalidResponseFromScimEndpointException { @@ -99,23 +101,31 @@ public class ScimClient implements AutoCloseable { public void update(EntityOnRemoteScimId externalId, S scimForReplace) throws InvalidResponseFromScimEndpointException { Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); - // TODO Exception handling : check that all exceptions are wrapped in server response - ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder - .update(getResourceClass(), getScimEndpoint(), externalId.asString()) - .setResource(scimForReplace) - .sendRequest() - ); - checkResponseIsSuccess(response); + try { + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .update(getResourceClass(), getScimEndpoint(), externalId.asString()) + .setResource(scimForReplace) + .sendRequest() + ); + checkResponseIsSuccess(response); + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying update " + e.getMessage(), e); + } } public void delete(EntityOnRemoteScimId externalId) throws InvalidResponseFromScimEndpointException { Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString())); - // TODO Exception handling : check that all exceptions are wrapped in server response - ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder - .delete(getResourceClass(), getScimEndpoint(), externalId.asString()) - .sendRequest() - ); - checkResponseIsSuccess(response); + try { + ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder + .delete(getResourceClass(), getScimEndpoint(), externalId.asString()) + .sendRequest() + ); + checkResponseIsSuccess(response); + } catch (Exception e) { + LOGGER.warn(e); + throw new InvalidResponseFromScimEndpointException("Exception while retrying delete " + e.getMessage(), e); + } } @Override diff --git a/src/main/java/sh/libre/scim/core/service/UserScimService.java b/src/main/java/sh/libre/scim/core/service/UserScimService.java index cfd4a82..c0262f3 100644 --- a/src/main/java/sh/libre/scim/core/service/UserScimService.java +++ b/src/main/java/sh/libre/scim/core/service/UserScimService.java @@ -20,11 +20,12 @@ import sh.libre.scim.core.exceptions.UnexpectedScimDataException; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; public class UserScimService extends AbstractScimService { - private final Logger LOGGER = Logger.getLogger(UserScimService.class); + private static final Logger LOGGER = Logger.getLogger(UserScimService.class); public UserScimService( KeycloakSession keycloakSession, @@ -56,7 +57,9 @@ public class UserScimService extends AbstractScimService { if (matchedByUsername.isPresent() && matchedByEmail.isPresent() && !matchedByUsername.equals(matchedByEmail)) { - throw new InconsistentScimMappingException("Found 2 possible users for remote user " + matchedByUsername.get() + " - " + matchedByEmail.get()); + String inconstencyErrorMessage = "Found 2 possible users for remote user " + matchedByUsername.get() + " - " + matchedByEmail.get(); + LOGGER.warn(inconstencyErrorMessage); + throw new InconsistentScimMappingException(inconstencyErrorMessage); } if (matchedByUsername.isPresent()) { return matchedByUsername; @@ -94,12 +97,12 @@ public class UserScimService extends AbstractScimService { String firstAndLastName = String.format("%s %s", StringUtils.defaultString(roleMapperModel.getFirstName()), StringUtils.defaultString(roleMapperModel.getLastName())).trim(); - String displayName = StringUtils.defaultString(firstAndLastName, roleMapperModel.getUsername()); + String displayName = Objects.toString(firstAndLastName, roleMapperModel.getUsername()); Stream groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream); Stream roleModels = roleMapperModel.getRoleMappingsStream(); Stream allRoleModels = Stream.concat(groupRoleModels, roleModels); List roles = allRoleModels - .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) + .filter(r -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim"))) .map(RoleModel::getName) .map(roleName -> { PersonRole personRole = new PersonRole(); diff --git a/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java b/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java index d7a0731..4c49f74 100644 --- a/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java +++ b/src/main/java/sh/libre/scim/event/ScimBackgroundGroupMembershipUpdater.java @@ -63,7 +63,7 @@ public class ScimBackgroundGroupMembershipUpdater { private boolean isDirtyGroup(GroupModel g) { String groupDirtySinceAttribute = g.getFirstAttribute(GROUP_DIRTY_SINCE_ATTRIBUTE_NAME); try { - int groupDirtySince = Integer.parseInt(groupDirtySinceAttribute); + long groupDirtySince = Long.parseLong(groupDirtySinceAttribute); // Must be dirty for more than DEBOUNCE_DELAY_MS // (otherwise update will be dispatched in next scheduled loop) return System.currentTimeMillis() - groupDirtySince > DEBOUNCE_DELAY_MS; diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java index 3824ad4..4deec37 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java @@ -91,7 +91,6 @@ public class ScimResourceDao { } public void delete(ScimResourceMapping resource) { - EntityManager entityManager = getEntityManager(); entityManager.remove(resource); } } diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java b/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java index fabd266..ade6848 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceMapping.java @@ -14,8 +14,8 @@ import sh.libre.scim.core.service.KeycloakId; @IdClass(ScimResourceId.class) @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") + @NamedQuery(name = "findById", query = "from ScimResourceMapping where realmId = :realmId and componentId = :componentId and type = :type and id = :id"), + @NamedQuery(name = "findByExternalId", query = "from ScimResourceMapping where realmId = :realmId and componentId = :componentId and type = :type and externalId = :id") }) public class ScimResourceMapping { diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java index 3dc284e..6ef55a0 100644 --- a/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java @@ -19,6 +19,7 @@ public class ScimResourceProvider implements JpaEntityProvider { @Override public void close() { + // Nothing to close } @Override diff --git a/src/main/resources/META-INF/scim-resource-changelog.xml b/src/main/resources/META-INF/scim-resource-changelog.xml index cf300fa..d3e2687 100644 --- a/src/main/resources/META-INF/scim-resource-changelog.xml +++ b/src/main/resources/META-INF/scim-resource-changelog.xml @@ -22,13 +22,13 @@ - - - -- GitLab From 97a3e13bc6c17b3e898b1e6b0120be312e32c17c Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 19 Jul 2024 10:12:45 +0200 Subject: [PATCH 54/58] Add option to activate full SCIM requests logs --- ...intConfigurationStorageProviderFactory.java | 11 ++++++++--- .../scim/core/ScrimEndPointConfiguration.java | 7 +++++++ .../sh/libre/scim/core/service/ScimClient.java | 18 ++++++++++++++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java index 60f61b8..1197980 100644 --- a/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java @@ -39,16 +39,17 @@ public class ScimEndpointConfigurationStorageProviderFactory public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) { // Manually Launch a synchronization between keycloack and the SCIM endpoint described in the given model - LOGGER.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getId()); + LOGGER.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getName()); SynchronizationResult result = new SynchronizationResult(); KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> { RealmModel realm = session.realms().getRealm(realmId); session.getContext().setRealm(realm); ScimDispatcher dispatcher = new ScimDispatcher(session); - if (BooleanUtils.TRUE.equals(model.get("propagation-user"))) { + LOGGER.info("-->" + model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER) + "//" + model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP)); + if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER))) { dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); } - if (BooleanUtils.TRUE.equals(model.get("propagation-group"))) { + if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP))) { dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result)); } dispatcher.close(); @@ -139,6 +140,10 @@ public class ScimEndpointConfigurationStorageProviderFactory .name(ScrimEndPointConfiguration.CONF_KEY_SYNC_REFRESH) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Enable refresh during sync") + .name(ScrimEndPointConfiguration.CONF_KEY_LOG_ALL_SCIM_REQUESTS) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Log SCIM requests and responses") + .helpText("If true, all sent SCIM requests and responses will be logged") .add() .build(); } diff --git a/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java b/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java index 23755fd..6359b57 100644 --- a/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java +++ b/src/main/java/sh/libre/scim/core/ScrimEndPointConfiguration.java @@ -15,6 +15,7 @@ public class ScrimEndPointConfiguration { public static final String CONF_KEY_SYNC_REFRESH = "sync-refresh"; public static final String CONF_KEY_PROPAGATION_USER = "propagation-user"; public static final String CONF_KEY_PROPAGATION_GROUP = "propagation-group"; + public static final String CONF_KEY_LOG_ALL_SCIM_REQUESTS = "log-all-scim-requests"; private final String endPoint; private final String id; @@ -24,6 +25,7 @@ public class ScrimEndPointConfiguration { private final ImportAction importAction; private final boolean pullFromScimSynchronisationActivated; private final boolean pushToScimSynchronisationActivated; + private final boolean logAllScimRequests; public ScrimEndPointConfiguration(ComponentModel scimProviderConfiguration) { try { @@ -47,6 +49,7 @@ public class ScrimEndPointConfiguration { importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION)); pullFromScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false); pushToScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false); + logAllScimRequests = scimProviderConfiguration.get(CONF_KEY_LOG_ALL_SCIM_REQUESTS, false); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("authMode '" + scimProviderConfiguration.get(CONF_KEY_AUTH_MODE) + "' is not supported"); } @@ -84,6 +87,10 @@ public class ScrimEndPointConfiguration { return endPoint; } + public boolean isLogAllScimRequests() { + return logAllScimRequests; + } + public enum AuthMode { BEARER, BASIC_AUTH, NONE } diff --git a/src/main/java/sh/libre/scim/core/service/ScimClient.java b/src/main/java/sh/libre/scim/core/service/ScimClient.java index 90bcfcf..8a5cd73 100644 --- a/src/main/java/sh/libre/scim/core/service/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/service/ScimClient.java @@ -28,8 +28,9 @@ public class ScimClient implements AutoCloseable { private final ScimRequestBuilder scimRequestBuilder; private final ScimResourceType scimResourceType; + private final boolean logAllRequests; - private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType) { + private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType, boolean detailedLogs) { this.scimRequestBuilder = scimRequestBuilder; this.scimResourceType = scimResourceType; RetryConfig retryConfig = RetryConfig.custom() @@ -38,6 +39,7 @@ public class ScimClient implements AutoCloseable { .retryExceptions(ProcessingException.class) .build(); retryRegistry = RetryRegistry.of(retryConfig); + this.logAllRequests = detailedLogs; } public static ScimClient open(ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { @@ -56,7 +58,7 @@ public class ScimClient implements AutoCloseable { scimApplicationBaseUrl, scimClientConfig ); - return new ScimClient(scimRequestBuilder, scimResourceType); + return new ScimClient(scimRequestBuilder, scimResourceType, scimProviderConfiguration.isLogAllScimRequests()); } public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws InvalidResponseFromScimEndpointException { @@ -68,6 +70,9 @@ public class ScimClient implements AutoCloseable { } try { Retry retry = retryRegistry.retry("create-%s".formatted(id.asString())); + if (logAllRequests) { + LOGGER.warn("[SCIM] Sending " + scimForCreation.toPrettyString() + "\n to " + getScimEndpoint()); + } ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder .create(getResourceClass(), getScimEndpoint()) .setResource(scimForCreation) @@ -86,6 +91,9 @@ public class ScimClient implements AutoCloseable { } private void checkResponseIsSuccess(ServerResponse response) throws InvalidResponseFromScimEndpointException { + if (logAllRequests) { + LOGGER.warn("[SCIM] Server response " + response.getHttpStatus() + "\n" + response.getResponseBody()); + } if (!response.isSuccess()) { throw new InvalidResponseFromScimEndpointException(response, "Server answered with status " + response.getResponseBody() + ": " + response.getResponseBody()); } @@ -102,6 +110,9 @@ public class ScimClient implements AutoCloseable { public void update(EntityOnRemoteScimId externalId, S scimForReplace) throws InvalidResponseFromScimEndpointException { Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); try { + if (logAllRequests) { + LOGGER.warn("[SCIM] Sending Update " + scimForReplace.toPrettyString() + "\n to " + getScimEndpoint()); + } ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder .update(getResourceClass(), getScimEndpoint(), externalId.asString()) .setResource(scimForReplace) @@ -116,6 +127,9 @@ public class ScimClient implements AutoCloseable { public void delete(EntityOnRemoteScimId externalId) throws InvalidResponseFromScimEndpointException { Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString())); + if (logAllRequests) { + LOGGER.warn("[SCIM] Sending DELETE to " + getScimEndpoint()); + } try { ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder .delete(getResourceClass(), getScimEndpoint(), externalId.asString()) -- GitLab From 4defbc2f6a8183edfbd3505527bd02e2652d23ad Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 19 Jul 2024 10:36:21 +0200 Subject: [PATCH 55/58] Refactoring to lower methods complexity --- .../core/service/AbstractScimService.java | 62 +++++++++++-------- .../libre/scim/core/service/ScimClient.java | 4 +- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/service/AbstractScimService.java b/src/main/java/sh/libre/scim/core/service/AbstractScimService.java index 8397464..af2e938 100644 --- a/src/main/java/sh/libre/scim/core/service/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/service/AbstractScimService.java @@ -139,41 +139,19 @@ public abstract class AbstractScimService new UnexpectedScimDataException("Remote SCIM resource doesn't have an id, cannot import it in Keycloak")); - Optional 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"); - return; - } else { - LOGGER.info("[SCIM] Delete a dangling mapping"); - getScimResourceDao().delete(mapping); - } - } + if (validMappingAlreadyExists(externalId)) return; // Here no keycloak user/group matching the SCIM external id exists // Try to match existing keycloak resource by properties (username, email, name) Optional mapped = matchKeycloakMappingByScimProperties(resource); if (mapped.isPresent()) { + // If found a mapped, update 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"); - } + // If not, create it locally or deleting it remotely (according to the configured Import Action) + createLocalOrDeleteRemote(syncRes, resource, externalId); } } catch (UnexpectedScimDataException e) { if (skipOrStopStrategy.skipInvalidDataFromScimEndpoint(getConfiguration())) { @@ -198,6 +176,38 @@ public abstract class AbstractScimService 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"); + return true; + } else { + LOGGER.info("[SCIM] Delete a dangling mapping"); + getScimResourceDao().delete(mapping); + } + } + return false; + } + + private void createLocalOrDeleteRemote(SynchronizationResult syncRes, S resource, EntityOnRemoteScimId externalId) throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException { + 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"); + } + } + protected abstract S scimRequestBodyForCreate(K roleMapperModel) throws InconsistentScimMappingException; diff --git a/src/main/java/sh/libre/scim/core/service/ScimClient.java b/src/main/java/sh/libre/scim/core/service/ScimClient.java index 8a5cd73..64b2474 100644 --- a/src/main/java/sh/libre/scim/core/service/ScimClient.java +++ b/src/main/java/sh/libre/scim/core/service/ScimClient.java @@ -42,7 +42,7 @@ public class ScimClient implements AutoCloseable { this.logAllRequests = detailedLogs; } - public static ScimClient open(ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { + public static ScimClient open(ScrimEndPointConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) { String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint(); Map httpHeaders = new HashMap<>(); httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue()); @@ -58,7 +58,7 @@ public class ScimClient implements AutoCloseable { scimApplicationBaseUrl, scimClientConfig ); - return new ScimClient(scimRequestBuilder, scimResourceType, scimProviderConfiguration.isLogAllScimRequests()); + return new ScimClient<>(scimRequestBuilder, scimResourceType, scimProviderConfiguration.isLogAllScimRequests()); } public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws InvalidResponseFromScimEndpointException { -- GitLab From a36fe19adc7a05f44f0a457b224faa802a7ef24b Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 19 Jul 2024 10:36:34 +0200 Subject: [PATCH 56/58] Only refresh SCIM endpoints when a SCIM configuration is deleted --- ...dpointConfigurationStorageProviderFactory.java | 1 - .../scim/core/service/AbstractScimService.java | 2 +- .../sh/libre/scim/core/service/ScimClient.java | 8 ++++---- .../scim/event/ScimEventListenerProvider.java | 15 +++++++++++---- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java b/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java index 1197980..b73df47 100644 --- a/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java +++ b/src/main/java/sh/libre/scim/core/ScimEndpointConfigurationStorageProviderFactory.java @@ -45,7 +45,6 @@ public class ScimEndpointConfigurationStorageProviderFactory RealmModel realm = session.realms().getRealm(realmId); session.getContext().setRealm(realm); ScimDispatcher dispatcher = new ScimDispatcher(session); - LOGGER.info("-->" + model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER) + "//" + model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP)); if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER))) { dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result)); } diff --git a/src/main/java/sh/libre/scim/core/service/AbstractScimService.java b/src/main/java/sh/libre/scim/core/service/AbstractScimService.java index af2e938..b2109b4 100644 --- a/src/main/java/sh/libre/scim/core/service/AbstractScimService.java +++ b/src/main/java/sh/libre/scim/core/service/AbstractScimService.java @@ -182,7 +182,7 @@ public abstract class AbstractScimService implements AutoCloseable { try { Retry retry = retryRegistry.retry("create-%s".formatted(id.asString())); if (logAllRequests) { - LOGGER.warn("[SCIM] Sending " + scimForCreation.toPrettyString() + "\n to " + getScimEndpoint()); + LOGGER.info("[SCIM] Sending CREATE " + scimForCreation.toPrettyString() + "\n to " + getScimEndpoint()); } ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder .create(getResourceClass(), getScimEndpoint()) @@ -92,7 +92,7 @@ public class ScimClient implements AutoCloseable { private void checkResponseIsSuccess(ServerResponse response) throws InvalidResponseFromScimEndpointException { if (logAllRequests) { - LOGGER.warn("[SCIM] Server response " + response.getHttpStatus() + "\n" + response.getResponseBody()); + LOGGER.info("[SCIM] Server response " + response.getHttpStatus() + "\n" + response.getResponseBody()); } if (!response.isSuccess()) { throw new InvalidResponseFromScimEndpointException(response, "Server answered with status " + response.getResponseBody() + ": " + response.getResponseBody()); @@ -111,7 +111,7 @@ public class ScimClient implements AutoCloseable { Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString())); try { if (logAllRequests) { - LOGGER.warn("[SCIM] Sending Update " + scimForReplace.toPrettyString() + "\n to " + getScimEndpoint()); + LOGGER.info("[SCIM] Sending UPDATE " + scimForReplace.toPrettyString() + "\n to " + getScimEndpoint()); } ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder .update(getResourceClass(), getScimEndpoint(), externalId.asString()) @@ -128,7 +128,7 @@ public class ScimClient implements AutoCloseable { public void delete(EntityOnRemoteScimId externalId) throws InvalidResponseFromScimEndpointException { Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString())); if (logAllRequests) { - LOGGER.warn("[SCIM] Sending DELETE to " + getScimEndpoint()); + LOGGER.info("[SCIM] Sending DELETE to " + getScimEndpoint()); } try { ServerResponse response = retry.executeSupplier(() -> scimRequestBuilder diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java index 7d765b6..2c177b0 100644 --- a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -1,6 +1,7 @@ package sh.libre.scim.event; import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; import org.keycloak.events.EventType; @@ -11,6 +12,7 @@ import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; import sh.libre.scim.core.ScimDispatcher; +import sh.libre.scim.core.ScimEndpointConfigurationStorageProviderFactory; import sh.libre.scim.core.service.KeycloakDao; import sh.libre.scim.core.service.KeycloakId; import sh.libre.scim.core.service.ScimResourceType; @@ -18,6 +20,7 @@ import sh.libre.scim.core.service.ScimResourceType; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; /** * An Event listener reacting to Keycloak models modification @@ -204,18 +207,22 @@ public class ScimEventListenerProvider implements EventListenerProvider { } private void handleScimEndpointConfigurationEvent(AdminEvent event, String id) { - LOGGER.infof("[SCIM] SCIM Endpoint configuration %s - %s ", event.getOperationType(), id); - // 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 refresh in that case - dispatcher.refreshActiveScimEndpoints(); + Stream scimEndpointConfigurationsWithDeletedId = session.getContext().getRealm().getComponentsStream() + .filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId()) + && id.equals(m.getId())); + if (scimEndpointConfigurationsWithDeletedId.iterator().hasNext()) { + LOGGER.infof("[SCIM] SCIM Endpoint configuration DELETE - %s ", id); + dispatcher.refreshActiveScimEndpoints(); + } } else { // In case of CREATE or UPDATE, we can directly use the string representation // to check if it defines a SCIM endpoint (faster) if (event.getRepresentation() != null && event.getRepresentation().contains("\"providerId\":\"scim\"")) { + LOGGER.infof("[SCIM] SCIM Endpoint configuration CREATE - %s ", id); dispatcher.refreshActiveScimEndpoints(); } } -- GitLab From 7d87d23345b49c25054ddfd43bae9e7b74c15da8 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Fri, 2 Aug 2024 14:06:49 +0200 Subject: [PATCH 57/58] Fix gitlab build --- .gitlab-ci.yml | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 281c4c6..99347f1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,27 +1,10 @@ -include: - - project: men/action24/ci-templates - file: sonarqube.yml - -variables: - A24_SONAR_PROJECT_KEY: com.amorel.scimkeycloak - SONAR_HOST_URL: https://qa.codelutin.com - -stages: - - package - - test - - sonar - package: image: - name: gradle:jdk11 - stage: package + name: gradle:jdk17 script: - gradle jar shadowjar - - gradle -b legacy-build.gradle shadowjar artifacts: paths: - build/libs/keycloak-scim-1.0-SNAPSHOT.jar - build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar - build/libs/keycloak-scim-1.0-SNAPSHOT-all-legacy.jar - only: - - main -- GitLab From e8177237e095cd5d63ee3f8a8e631ee0782b7ea7 Mon Sep 17 00:00:00 2001 From: Alex Morel Date: Tue, 22 Oct 2024 09:26:28 +0200 Subject: [PATCH 58/58] Update to Keycloack 26 and scim-dsk 1.26 --- README.md | 80 +++++++++++------- build.gradle | 17 ++-- docker-compose.yml | 2 +- docs/img/event-listener-page.png | Bin 107162 -> 83173 bytes docs/img/federation-provider-page.png | Bin 154146 -> 107002 bytes .../core/exceptions/ScimExceptionHandler.java | 2 +- .../core/service/AbstractScimService.java | 10 +-- 7 files changed, 67 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4e6889b..785758b 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,59 @@ # keycloak-scim-client -This extension add [SCIM2](http://www.simplecloud.info) client capabilities to Keycloak. (See [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644)). +This extension add [SCIM2](http://www.simplecloud.info) client capabilities to Keycloak. -## Overview +It allows to : -### Motivation +* Declare SCIM endpoints (through the identity federation UI). Any tool implementing SCIM protocol can be wired to the + Keycloack instance through this declaration. +* Propagate users and groups from Keycloack to SCIM endpoints : when a user/group gets created or modified in Keycloack, + the modification is fowarded to all declared SCIM endpoints through SCIM calls within the transaction scope. If + propagation fails, changes can be rolled back or not according to a configurable rollback strategy. +* Import users and groups from SCIM endpoints (through the Keycloack synchronization mechanism). -We want to build a unified collaborative platform based on multiple applications. To do that, we need a way to propagate immediately changes made in Keycloak to all these applications. And we want to keep using OIDC or SAML as the authentication protocol. +See [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643) +and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644)) for further details -This will allow users to collaborate seamlessly across the platform without requiring every user to have connected once to each application. This will also ease GDRP compliance because deleting a user in Keycloak will delete the user from every app. +## Overview -### Technical choices +### Motivation -The SCIM protocol is standard, comprehensible and easy to implement. It's a perfect fit for our goal. +We want to build a unified collaborative platform based on multiple applications. To do that, we need a way to propagate +immediately changes made in Keycloak to all these applications. And we want to keep using OIDC or SAML as the +authentication protocol. -We chose to build application extensions/plugins because it's easier to deploy and thus will benefit to a larger portion of the FOSS community. +This will allow users to collaborate seamlessly across the platform without requiring every user to have connected once +to each application. This will also ease GDRP compliance because deleting a user in Keycloak will delete the user from +every app. The SCIM protocol is standard, comprehensible and easy to implement. It's a perfect fit for our goal. + +We chose to build application extensions/plugins because it's easier to deploy and thus will benefit to a larger portion +of the FOSS community. #### Keycloak specific -This extension uses 3 concepts in KC : -- Event Listener : it's used to listens for changes and transform them in SCIM calls. -- Federation Provider : it's used to set up all the SCIM service providers without creating our own UI. -- JPA Entity Provider : it's used to save the mapping between the local IDs and the service providers IDs. +This extension uses 3 concepts in KeyCloack : -Because the event listener is the source of the SCIM flow, and it is not cancelable, we can't have strictly consistent behavior in case of SCIM service provider failure. +- Event Listener : used to listen for changes within Keycloack (e.g. User creation, Group deletion...) and propagate + them to registered SCIM service providers through SCIM requests. +- Federation Provider : used to set up all the SCIM service providers endpoint without creating our own UI. +- JPA Entity Provider : used to save the mapping between the local IDs and the service providers IDs. ## Usage -### Installation (quick) +### Development mode + +From the repository root : + +* Launch the docker-compose image (composed of a postgre and keycloack instance runing on localhost:8080) : + ``docker compose up -d`` +* Execute ``gradle jar shadowJar && docker compose restart keycloak`` to build extension and update the Keycloack + instance +* You can access extension logs through ``docker compose logs -f`` -1. Download the [latest version](https://lab.libreho.st/libre.sh/scim/keycloak-scim/-/jobs/artifacts/main/raw/build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar?job=package) +### Installation + +1. Download + the [latest version](https://lab.libreho.st/libre.sh/scim/keycloak-scim/-/jobs/artifacts/main/raw/build/libs/keycloak-scim-1.0-SNAPSHOT-all.jar?job=package) 2. Put it in `/opt/keycloak/providers/`. It's also possible to build your own custom image if you run Keycloak in a [container](/docs/container.md). @@ -38,7 +62,7 @@ Other [installation options](/docs/installation.md) are available. ### Setup -#### Add the event listerner +#### Enable SCIM Event listeners 1. Go to `Admin Console > Events > Config`. 2. Add `scim` in `Event Listeners`. @@ -46,37 +70,35 @@ Other [installation options](/docs/installation.md) are available. ![Event listener page](/docs/img/event-listener-page.png) -#### Create a federation provider +#### Register SCIM Service Providers -1. Go to `Admin Console > User Federation`. -2. Click on `Add provider`. -3. Select `scim`. -4. Configure the provider ([see](#configuration)). -5. Save. +1. Go to `Admin Console > Realm Settings > Events`. +2. Add `scim` to the list of event listers +3. Save ![Federation provider page](/docs/img/federation-provider-page.png) ### Configuration -Add the endpoint - for a local set up you have to add the two containers in a docker network and use the container ip see [here](https://docs.docker.com/engine/reference/commandline/network/) -If you use the [rocketchat app](https://lab.libreho.st/libre.sh/scim/rocketchat-scim) you get the endpoint from your rocket Chat Scim Adapter App Details. +Add the endpoint - for a local set up you have to add the two containers in a docker network and use the container ip +see [here](https://docs.docker.com/engine/reference/commandline/network/) +If you use the [rocketchat app](https://lab.libreho.st/libre.sh/scim/rocketchat-scim) you get the endpoint from your +rocket Chat Scim Adapter App Details. Endpoint content type is application/json. Auth mode Bearer or None for local test setup. Copy the bearer token from your app details in rocketchat. If you enable import during sync then you can choose between to following import actions: + - Create Local - adds users to keycloak - Nothing - Delete Remote - deletes users from the remote application - - - ### Sync -You can set up a periodic sync for all users or just changed users - it's not necesarry. You can either do: +You can set up a periodic sync for all users or just changed users - it's not mandatory. You can either do: + - Periodic Full Sync - Periodic Changed User Sync - **[License AGPL](/LICENSE)** diff --git a/build.gradle b/build.gradle index 34a263c..7897ff6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,8 @@ plugins { id 'java' id 'com.github.johnrengelman.shadow' version '8.1.1' - id "org.sonarqube" version "5.0.0.4638" + id "org.sonarqube" version "5.1.0.4882" + id "com.github.ben-manes.versions" version "0.51.0" } group = 'sh.libre.scim' @@ -16,12 +17,12 @@ repositories { } dependencies { - 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' + compileOnly 'org.keycloak:keycloak-core:26.0.1' + compileOnly 'org.keycloak:keycloak-server-spi:26.0.1' + compileOnly 'org.keycloak:keycloak-server-spi-private:26.0.1' + compileOnly 'org.keycloak:keycloak-services:26.0.1' + compileOnly 'org.keycloak:keycloak-model-jpa:26.0.1' 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' + implementation 'de.captaingoldfish:scim-sdk-common:1.26.0' + implementation 'de.captaingoldfish:scim-sdk-client:1.26.0' } diff --git a/docker-compose.yml b/docker-compose.yml index d119247..43e4f3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: ports: - 5432:5432 keycloak: - image: quay.io/keycloak/keycloak:23.0.3 + image: quay.io/keycloak/keycloak:26.0.1 build: . command: start-dev volumes: diff --git a/docs/img/event-listener-page.png b/docs/img/event-listener-page.png index 066b25d774d22caea0f7cfdef61a302c82ed13be..5c45734c68fcc9ecfe558cd84fb571c8fcffb20f 100644 GIT binary patch literal 83173 zcmdSAcT`jB);?<4h*Gx&P^w6iE=ZBCqG0F(p+lsX01-lwjs;Kpd!6V zml}~yfJleXelOoS?sLZd>yGb^JMOq^L>I~G?>px+pZUzW-f3wlQ&TWfoH=ub8uH|k z_L(!}zGu#ykGgmP{Bna~;1I0-LOz7(UIdHpMXR@`Kf66PaMMB9xOtkpTA#6XMmSju zA}w95t(}o}2sh&SMtQK)?bDqUU9HXC>=Dj4b?u$3&p4U83f&YHy@{}N1M54YVmI$d zNsEg~i`|j6*_JtT=H?m5qX)V#QdTBC4dUk5TGxATF#mOy>@4}(OCNZ|pxS_Tr-~|* zO>Uwfe{vFaMZenJPFRz`xI=DUoxWY-JnKCqF+A&H%KqW;XXNZ1DmuF5qk18sX{lYL z!mbqgU|8?w(NtbR!EHrwd;eYneOrCe41d0Xb*&P}$KK&vubV8t8wR31A_Xt|IFxtYxLbupJCO}v0=qv4kSi3E#H11FGjK^$;$$w6f<2+fvbhL|4Xt(Ov zu50@GRm`%5N^bqzRT6k+SHI);c1tOp#d<|xI9;7^VCv}v4DO7~pa16!$DK)%h?2`3 zN+waIBb^pyc3X>m4t-h8bPd4_FA+lgruCHq^r|I2hB)7SlT_M&j@uIs4Grz5RXF^R zT^4M<_&rl`tkOOv{;=**kv?Yl_~;-mDJf}&;I}*J33jX>oSSch+Ald)89{5DO%uE8 zVuXEUS}oWWU^8uqCzDUo$tNbxWiPD?E#nS7}9$Y^rP~;UeQ;2jU6V-ic#IHN0(OdjVGbS zY<~vnr^>r`@1BOxu(&l*OBSfa@kQr!CriSozXm0VSO(LuNV|pWzYS>k{P{jLqu6a_ zOML;@G^Nk^^XDrjd)!A#O!-V|OGB^mZbTw!7{#7k<ee>o|3b`smS--~D~`^EK^Ysk!2fmi>hOIam8{@f?f|n{PM^{hP~;=uAwaw=^DwN?*v$-DOIp zpG1nSCKovbDt~!kFV_T5ir#kn3BmgUlcczK@x#DUY_r9BLp*0Vvy`@Tjd7&Haczo5 zz8ZJT1zOf(^<;_taG#$q$pmW6t8D9VEJG`g=vMd~)Gj<*4KE2X;j(iDc_C12^Cv z)Xev^qOHmd2Ej-S6+BgZ`SK;s;|DkeI1%ubqVc}S*`FZHXzo#=WASff_rh3Y~r)6bj z;53X1{-1FB^GvJbHJZ`7C|%y|#Xd#Tdbg-*>Ft}PUc&GCE+B5gTUf&J$Is3xAu9mrV|AAKob47>vx`b^hSA2hb*peC z-YUDkTLbG(mu$Bt*DBSM`5A;3DSe8_ z80&fZ`3C9|j-??iIjFqw$6 z4`mQ8iH*JX_|c=i<6B)nR1SJ%h+C6Ve={=+Ko}()KXUTky7e%Kk|Eyw`spB2ozBe0 z3t8|vwPyzhRF_Q!#=>WRW6t06mhAp)45PS1EM6Rp>e9h9L_=e^+`5w!(%0ALz23~= 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

;e|hB*F5Lx=3-w$HDAqWMZ5+ z#dowYE;KcaUstYA)hg9(F9WApar`Y0r?J!2`v)00`QOs+%i|j0RSheP@t?n6+M~Iu zuN2HKV?MlUuydv9BP#hkNxLo00I4gE7Er`G=i9~^N}}ssvuDb#JoMHuap|P|d6@Ff zQF|o9nzVf>`@Pkw`Ti?56q&XmX(L&WZ2?zhTOJQDevi@vC$WzXVT-=o_z0r6)VDXb z^*FM5ZFKpYZeS`?BO9O9P_~Vbco691qH=62E_n-r>S@@F-tUy-Q$>XJgrR~%w>oOi zI=iG}~A4cg2V$U z9A|ix!@>a&tAS!GmRs+G+g;1nqLc${QdbQuyVb9z8I&dv$`(10z(RD>q-dwVc(7!@ zO4s-6dQjC9P2_A~P{ipSQFpWUR}@Xl*mTW6hgU%J*3ua-?4T^IZ5*UVD@wARHw?4g z#zYuDxy&@(^;p?b3r@JqBde+XZ)EtChXIi0V*J|V0LmAvayqs;a%HM9ytv}$T7M0B zZ!jp?+h-Q-*sA6mAs=}!E$K~kHU1AI?b6VQto1NlNslx26H8Ez$cS7!u-@nG-p2NE zTejeWwAt%XDhlZA`4B`w7Z^Tkhrff5YepZ((^x`^jp&v4G|_BQ2iY!GQGHxn7-0~V zre|=*^uw+&Cx$p!oRsTBaRXx{cCob)8S}W0?RF>rQLt4?94mqp{}5(Q!v-BBc^rVd z+F-9iYm14_dgT+M<}W(Tbw*4ve!-s;k`E&$ER^Kz;8iIyavgn$L%&&=cWjC6E&3Z? z@M*}jY|n4=YTTqKZ8;Dy!%%sCNSKR7h^p%4#184Pc14YK$HSVj<}*X>KTq-2zCbsh zciQ?SXO@F(1)U2U>(boKru@pnPc!W_Wo>D6za)FNj5wohvo?QbzIGg6WQ~$_{_r9} z;`+9v*ViJ$x>TY~`@4AB*J8J^sN_#x=j}gl^|*RCh4~)jc%Ae^^V)HQ@BJkiagE;B z9W7Y>drA>}_v1J&OLl@pxeq{^8r9NGtC{pCO#2cT_Pyd{5qj=Y>!8m$eY32CGJT() zU3USG#|=AJYjxoT{V`Jq+E;o!8>~}2*XGS~>gHh<{OMwkrEJ4=CY;B1x-E9{97X1x z^oJw$%TQw{$l1xoX!q_vaNr^{XqUoa0}hmwzEos#o6@M;7^R@xZ7%J_#aUvA?P|>w zO~rmbwaz}S*~^yNSx;>vRj zDGTh4z^YHMUN-2q0^6RYVI}NzQ5ROmwTxwV| z6!#({EmqE{%f%o{X4DQpd$+vn_?xna;9uu`0gD3HlCF=+UDw87#hhBXE?U&b4&xvm zQ@Lb)oc|#Yh9&}!Ap?{B~ z0j6h6wT+GRM?1gum(?Pe)JF&>(E8whY09y5T0s4H8n?95yFQagQ?|}7*4hxWEt&jK z6@UlH1WAWqU(NNTWVajx(*G(+E+sZ#f*+C#x(U6xULNn?);3@D%WU@aX{0o3hZ&iD zjO+LRYvVL{2p?nwjRz0~qZOMHedb#Uy(D3bW>P4VmFAn16*rX!ZZs&ycjcSTw~Q^X zRK7UIt?Xk6Vn#Y3DO66iQ?5UCUY-u^jUdg?av*$Voddi;D%SB!(yJMf7t4e|pGTwL zlgl6$kNu-j3Eo!a6QD40was4scyfCCuP3HGA^TMjt(jN!&)qGGbPtYa+T4nbdTmRA zWvWW9j}4>N#ARTYa_jfWT2NQv>H0o@XHTa7Wlp8X1uH=tBO~^?nXay?6XL1J&AT`= zpGMUt;j=&K!}XwR4c)CqcN;2O`F_ZHuYV0fxPdI_@Et8o^ir%Ls+rC*E~Z|mVPa`z z#dopc|tsMkh%%fNnYsEC{j6Or_?X$DJrJ|NG(myU< z>%p8(^96e2i^!_iV)Ta2q-nAU5NPWasFhYx{Qk5Wv)@0diyA`o}qYao@{n1J1Y!?EDm|FxhGBCn=S? z#BP0`w@*5@xsk#iqzFmxobSv$s&1~8EGCn{VN8!l-M;|7aacy!39-4(kHsowb(y0_ z09H_Zyf_A5yZ1dL)Lm|idqUh=WF+L^=mr4lK7j0upPz}*X(FY{%Q}zuE`%#Iyf@AI z&{RR^B?n`J=~A|3npaOvbQv!>nAX;BHa4+P)*m8cNiQ6J?xjpW)QHgKj})6EE<}h` zweIx#uFk$6b?I6++ap7AEvK5$^I-c81mP!N#0(e49;6k|A7{<1a4xoAb#b3iK?gMy z4zu^(;plnG9Y|M2kB%7zS5cJUc?j{fp)|75J&7uEy9;A}Dm0g-5@BQGOt?mLsZ~jG zd1=MF*{VbUj<#wXcl-ksP2>VWy8=z`&#xsQ4DG~F`yAt^(0uDL^?A+0OSP;KT3GC5 zEv%045O&-QmE=J79btx52JtwoCFhTiZ^U+>NRT43pv0LBd%v2;=wc&#iY*eNe4XAq z7j0)e`r?^L77hvVzj=O5p5RTSGlt%YZhTgS@RfMpZM-Gr*zdQ3hyGNYh{}Z}lU0z+ zCmePYcEB;ck4M{xT zV<|N%l&0LHXOa9~g~6T+k1Rz1qTdze9h9&MzR2%b|S-0>2j*SEneWCYy(Q6@Ozj62(aql>)l@d#{gviEywXy3C>8xm9<#OA#@taYqUQ z9agzv_me28jdS?z4JLaw$dtu&r-&%99amwrPspEQyxl&)r96|)2keml132%Ge-4HP z`othm=`H>1SBKD}20I)+Pu<)*d^i{^zP=)#%U(E34*6e4et%mIJ$0qjKvWj;n3RFW z>;S;F7H6CObbxtaJ;*G??i+w?|1%Iev3V`h{ZGjem+zDLqXYE=AdQ!S!|68RIQFmE zM!Vfj2`wN=^5Yj_na+_{X}rME3Nronn}y|njBIs&8WaTUKh$e;? zU_YiM2U%PZl7931Ad9jAxB)Ug&!V`1-8l{H&Isjij|14H{};16aLfKyoc;bf{s%3{ zWdM&}WssTitKUCvT!+Zy)Uf~uNy>e0Ks{tyq@Bvyb))#lKk1(de3utDXsfTl({QHB z%%bV{Kkf#}6PcC57Z_m*=)a!R-__Fp3nTbc6Yi-lH}G1&y-)sbX;XuLZ8C(~!FaI;%Z@%kefhHxnQH{16IJ5Py>6 zLEZ@`IV1NKoG`6pN7O}1+mo4YbH*uZ?*nM9<63RI@=ihwV;H!Xq%g|KOADhvPXRn0 zq=D6Kwe)T}y!#$^4bG`H8OX&W@T##7&~xE^>4(}^n;>TxEp9Wum<2Okg_|J8G z#X)dy2m%@5d_!w-S8*uhdB7cBI1EoP)j5dm;EIS-Jk`exGFOn|e;qT?`KL*4Y)k56 zg&3L$eDVJsZ`r=DEdOJXd;I@ff#?hFd%lx&}@Jqxb3B;_ZU2B8qi zHtkTVyio-TUr-&w2Bz<)6mjRUc8OPhZR7=odT3&=*3rdXOU(6?KHR11Wz-tWXzBL{CIw4sQS&#R3wIX-IdY3s@L|jOjVrKOnmiLz~CAfMy?uYj!#9bZk_%=Zq|? ztfSts?2~&o$PzYo;}&NOijc0*=J=Zj?yE|f8O5x>!p+lWHAk}@mpnH1p$HGSD^=q| zUyL<(?CH~O#oUVTlDy9Uk}@sRZ7f^vTE&2)nbNZa9M%L%zS>f6Z*S9~XnM!~Up&o* zrYk`BX|T1VoO{N2CL9!PQ9dE;t*ZeSB-of>kxGnst+QI+ zsme~>cp0(oj8=YKPF>d+q`O0Oc1?u*vS*0)!)Md~BS7Qd?~F3qtd>17|Iz|b3hnn} z5!4rATdj}W!d|93$n491z_ukb-nutSneOtN$`c5S>e%WNmvE&YGfw%7J{)RR3u5MYBR7j+VZAL0!_sw+pf19h0C|V+?DwAp|e@N>q4E3jUifa zLVVNm>I3WzfHE_XL)k5~FUa!#ITdn2i`6baHUkQaB=Ahn*NQe&dCnA{`5Sa}pqJl& z^sLcU0`%9Bbo%tZxN#ilCqAjK0vAap+uV1yR#d7!pR|IIA{7bD$rRgf7>XR9yL?VL z)Mb!*h%1dfdzYK>7P>0+N(LVOD8^p5q6_k-Be`6wUY+DMI&s1Hkr&KyVT7ZY z7xB|p$?C5lkSVZqfP=tkx~oGkMJIlU3`7?$?fniG0gW&7(6S9KMCA)TGt?cgnRFfq`=MKxXjd>N+>3&l{$t``v-pQX9rd8xerkYY3pv>$>-9b-M zOO@ndkohBtww7mCJrx>Fb0Csa(Jfvbz0?e)vtexE zE+-jlrq+7)5eU3zHY@Gjn*AtXP;mRk0gki4q2L{rT5}4H()?>&Nl!m(;Em{;#urO$dpH zpI)fv-nycbYZ{dc@6omXWJ8{tc!6a^d*zwe8o79;7EY;g1{>$Xk#5hG7{2>pSn>8U zZ4RF@e-5a~ZR(lDC_`n9mDV)P4r|)A5j;oeHxHkeR$+r1oW2)Gw@ZzXT)hR(q&uHb z%4p|VP)Gi%s+#(@g@vbR4%%=95HZ=N>RZ%B^8gVL0sDBgk#<9It1mF0XUK?xn}qe@ftRZ9#952~W1q>%|mlu69OFZWwk= zJ4fA0ecpD=9SrxIO`Y(B+gOO*mxwrw76b>z_(6bSNZ<4N_a8WQe1+>wG5<; z7>u$uS$&strTEqj2C|X7T;E&`lvcV<^G=(Ux;@dm_I2fq77amKN5WQ{!G3dwb`Z10`4Sv*d zPi8CwPg+jYX!^-~nx2EFNxv>_QgC`*_g<}o2<+jSCm*eZQ%7pYmg>PjS7xo8>;>Vg zC^v}6Tsj6>lOQ5wzd1nk@SWGY@UTg3Y*R_nn=0E*pxRTDt(8eFq_wUI#4UDT*KK(? z0PhtSERM)Ql&-rl}cu* zQagtDGQgDJ)njEHmn)wn#+^t1yFk|b6R5Vj&dxTmPvr9>p^WglweFdFyRmV}TJ`S^ z6&~txnaK9J7OiAp(Q!hF+|C3J-sSK0QMwMpHA2ZLj#FpFA?DJ2-a&EIaolkD(G6?! zD@o5S+i0JfL*~F4z^Dwpk=6J1!>nn-UBP#knpx~iCXlXTMLI<7&v6JrRmUeYmI7TR z_N;`Z$^w<5oM+mbZ~qFkyI-sb(G_cFpZaHeG9NKoVp0b`sl5jQUZ&l*vn}a6YTz&w#l^wHhvBBW1r7-vOFFv#yY@F+Bc`^! zZDY-1Vfmg-rrRYi#}y(*Mr7sN!12Q6O|k2FlsIX^jXQ-Q-8-ALQgn>@i|)~Ia6wwU zP)^^qO0x1nd0O#mv#*`KcnZ)|AIOMhW@}08a)qpTR|6qj@DCXA`@inTVCzZyk4A`I z3OIzTHxnJM;Z8n#1k!QI=?UVf*6Uk$LkubCmcKpn@YG zz7fz$uLTGm;hQw4dc@g6bA6d2vd!_tV}k+@?}?#&NM16KL&S*&GQsQ{aJ^bL5x&$J z7#bQjJG!0J`x38prwy+1`wdgg12oLj*d%Ytc~RP*WlL{L*XsrU$n>L05xL0s7ar4*%ftM+-qr>8Tw_ z`f1OO3Pzm_fn*Su;+@;l^YX%;X6GJh74`Qu%;+?S8iB1Nz@eD1xu7ITEPFP ziNeRDYB_a`Jg0cBiP`iH(^FUR06QiDwKdtlW`>FjAdc~}^%u5z4nm1BI2dkPTDw}o z+tncOt-gCh>2EavpT%*c|1!z@G__|Bzw_9}>fKy7vlWJTfu-n_zU=cH(jG%ksB2yW z|Kb9*WH=5AK`lj4OEc6Gl?0kcIF7~ax_^I*`_K3ZdymRZ4>fUP{G$^z^E?zFbUQ&8 z3eY6Rb%L7cmG@>#J_eWP(mwe%&YuFeC0|$m4E`{!HNh76VqM@F^YmGE_+zW7Z=oh0 z9$Nsx;bX}7QIJ4}YrULLT7NsRfK+6j`&fX-8am|5uyFaFQN z{Kv|{?1iE7lKKlot^pgVl%s;;l;n(1&+PTv~C$4F%28UC9$Rx-Q zKepElTi9|UQN&_*z>xk<$89yjKYs?HxNo+|UbNN9`}wn3HLy`QgjnQ2O8Q481?1xK zFmpY&%KO0lmV#mI=fgI@gI|c>_dg)cy1fGj?5`^0rl98QiBolh79>DXv0ng2*%JlHYzoGdtkDy1! z6_u4hqaT5Bj?D~d_;Bgbz)?B zU*s`JP2`4#cB!HwO<+{(nE@9X76ZtpaL*}mT)2Joo2?1J8si%)0 z)0u(G9eUyiXge7brnK7ukQ~*EFL606o3SZp^~oC9zXUR6NKin5SuF( zV|EaW-3dx$**$yrl2tM6y%@stVDHgupEl3A8s3>1^uB#nYRl+!Qep^FTU}paj%Am7 zFqgxW_kf^726^oix9OK(Oas~7XTHi0va3|Tp z1M2EyU(yahN{!HSy}h6*bzU|8qO&?0W*GBXHU?-!PoUU~MhcJdlkK^UExH#Ro4*6r zWB$7a;?soxy5zZz6C<~r-xE`~Omt*ky529yu$g%A$yt7ll*2MI4~A}DkGJa0*1kRH zCT54rlT<7%b~mY-NdNv=;|AMaCqsDU%H;MiL|D(f1u}&e+$&4{m8KSy`32zb3pIo% z)A($g7`DPiv9ZL^b)wta*nii+c+}S#;-d=hJ~ay6-HD%`$X#R=s{>hxk)T%QIg!zp zQ*lY>uzR!|H3F%+m!4f>Ou=-@t6Uq5 zk}=NJEA*;dnExnlcquP-y7pPJR^#;iP!lh{cbqq-sLa?W-CGDKBmSoX+#Xd?v81OK z=OY|u2YBawy4nq%KV+=C1c4tzn|O-j?qg08PD8g+M(P6L;(;A?EDj#EcGDp)DT7$l zN^R8qxxbI@PhGtW0MtIsw|WYD2;v(Wk!72m2VCz=DVQdszjk#dt}dl^fq3`=)dM+l zyRf|8y|x0^W5m2~K3x+{R1T?cd)^Y7q#Oo(XG2c9&Noop7nm-rsHS35m+X|c4W z3zV#RaY5L1^N#Y)5kRFT15a^EzBFTP&rdsB)UD$w6f$0(A8Hg6R_r5@8v+Oeq4*XB z&g(($yFm#ZO^nx)QuF|SV>r>3)=s2WbT&U z^l92Py4kcoy!`4O?GuSUZ()eve11=pk|ZK4ECnJHR-tWW7c7#LSY?WD7X;*AWp}Op7W#`XP#B>xl4Nm_Ya{lvXV{Ql}AL85G z$d`JIIwj=My_M<=a&bV1)6A^zO$-iNvOzk1Zp*f`P&E0RVyRgym2q;HtZh$j&(0eJ zX*2P}RGse+KTkD^&E-04P6MGbkp~t5Z4SC${RO(8moEd%MJv;5HRU<9< zv1RLH)T4_Az6EZheb}{*E4;7kbtiu%E01do=Ot##oL*mB(|Iw^{5e71*0N~S7f0Wg zD62O)ucF$KW0rn&#Wuk~X{2@$IF}6K&VqMSU}u4jrI57NWqc(7SbpNcWJ&S#^%X8( zpSL;*nXpuCz8JBf`bs>tG}E;$y{Tp&NjUJxtu0)6<#Dq1@d59N9dsXyehn7)&vOJX z<=?Ix44T=l2zBcGLxRRrsce3y=^pBk(>a#fUB~%+tlkP46q++$-Gl*UGh6WRbV(!rh)6OCL8~oUh6y@n9t(Rim52feOEbxfRNRvWT_~Lh2%^%4c zA79Dt=P%nVyxOMwrF<<~W_p1x6+P9BXBWMiijuL6;oGPUpB>&5OV5Q&76B2W*qnEe zLa)>X50B{qrRgwOr5qt?d&VlURvJd;0nxQc^V`Uj3G3QSQaYTgd+MVJ(NjE-mMD@g zb0I6C`yguSQ(EdTo9mq|4v%Ubjag9X*9tDF#7gmgN!Fkj9`{WGj&aVkUT0o~Bec3| zO$S6LE%w}73=x)>m|=89L%}>|74j2&r({;dkr>W=gg736E)aT^RR_B6J@VCne=o zuX;4xzLsKiKHwMk)^FVMLpVX*#;{Ktsbv8t-M^8K$41?x2KL$_a(N-*?oOu1ktg3p zd8)=qaI=bAKTp<5yEMR~(6D0d_{rWZr_>~AUeY$OR@U^=TdlkxV5pkAiyYf+RxPXh zZZ0#YV%s!JbTp1=Wd71I%llFyHg|I~#|7_E;Dh()QI5P?_w0xu@1?!L@S;2m3b$_W z_c}9($6T$f*BC$9St9Q;*=v1!_3GOz+Rr{{G+yTI^O9^QTgsg*AQjSk%sGrdoT_so z3?6AbcRKj~4kgq^_U8Cd!QZz8){GM3U!oxo*8In^n$CA$EE{OK2GvDh%<&mF@TTf3YR&CNYdy8A7+?&7`N5ioT(c zR3nP+D#7M;*Tr$>dK+y+8{KTT2UL0idxcES8q$d1Q?XfY887Du6FpS<8X7&aGkxE0 zW3i|FUfh4fb@<`in)sIqgiH0&_oA-|C_BEHP_EamZ+Odge8jzm?RX;_e|?8T>(~+R*mTR(@1Dy+mCVzK%!k<26D9o&1$W+f=cBtni zpt}@j<0v>DNtc#bGKD10`-oGWE6eCe%vkuWSSeU5G~z-tX|z6>`yiv%nZw*Ia!0}_ zeZ7`}v$Mlu^`(v{?ejW=31Nm2cfXFO6{$Gz*5vW-M& zd7bKwhW0*IG#Mow7Y2(W`Qsm{^flh7?#Rm4OI^=h7~AW*8$&Ud$;isjzkTgY_eR>! zoxI#UKGMH@op3#liCN4O6}{OlSa8}nYl!9HLg=QV{b*WiYVO9cS!~J5=lW2FOKX)L z-2P@Hb}X7c(~_P%%hbYa5xJvmu|C5c6YCwS+WSv>I5DA;&P+Pg3r0!1Rz;YZN;;2NJF z*0oXGv{m<#rV1Fzsahz46l9ebxaF&M3M@p71bpg5xwTD=WitiVrj61o^p{-Na~p?u z2Y>Nk?$C)y(xonOs!rLU0HK1Txg8$$N3WJG%n5{`d@6^FqDJyGNB6m)+I#yfMGyUT zxX37n6fme_x_1Xq29!Vid5(pF?ILgnTmI{wa4l5yR71&NQ;|{M^z^gIv6yvH7%u-n zNTT?ts;VkQyKr2*r?zOkNwU(@xXMIQtwp~;x%Nq2Xh<^8XUV%Q+%H>DD+MEl_oZF? zQqSKm!w)V^>>IJ2%*V6!;dWwMMn3m^935FUi(%jnQFBifPKzKb5WZP`$~T%#HYWNs zJA?x~6wLc7wU+k&EYpzClLo<_yU{+MUCb$?(;V9}+DBCeVx>K*G_(vE?r7?sY-7(< zVS3}!%hF<;HeI^hd(m}BD5GMaL*++8h=8=tTDBM}_DeOe6`Aj)Vy2(>bxTo4w!}1= z--iN&}da&eNZ~Q*K_0Uv5`2ys+U^Y z3BF9jy$<8n89lkRLO)EylwB^#G7$3dhK(dM3<_Anbq6OWh@p%7Wuj=Z(6O!3*Y!Bg zlRXR9wYy)8usFx{r_K0}?TI-E))EUHms%Ja-CuaL=sZKsUE;+%|JI%;+s|SX~g8%JjR*9?t@vnUYf;M|-%by$m@| z@>_;2iOZ7ANY?6?_!h5qt_(!pF-~}<+2BIItn2=LNT{DSlRI8_Csu2s=q1Nx8t zX{`A5#ejPp?mV9dpP2QLY`Bxyd34Wdk3=)G@;EA!w(Vx`tPW8WikP&DZD(nrbMF|E z890AhXpd`;v+5k-%LSJqC&jq79p%2NE@zg<7Vq+8Pcj+z`pR}G$MhOXNp#SAt;-S^ zqkIR)n|Z0C3C<%iv%Q|ik)-`j?ep%;dQZDOi?5}ew!6Md-}RzcjC-oF&u~-#b34!9 z4l*)}3$T<#6OLa-lkp$nLZ!VAcQ-$lXZu}97EN7D>{Wz7UaO@He z_+jpNSz(k)uv+PoYP*^I8Qj`Y2yd9k|LD>Tk-j+nK||YulaS1sT{M(C&2wpjp4C@^ z=2rnnGFUuiOP%8pzIi4>cS6^FTUcH@mW&ST4#4BNO+g=2b^Kyb2^!u&f8#pxjS^3+w<1i`;`ua2(3EG)BSoN8@C;ap%pYFsuiq!I} z9F#6C0Ik66cVX-B$u<08su1 z>`tt%K%(^D8jBI6ro>tncnqsxF;V1X<<^Pwuy0IvQesEQz`3+M5F|PMaJjlPuG_gg z{BG$u&oX0rbhoVD+hA1wm5{Xqe?NlAp=E+sYuTvi-sEFZ`2(*{^I)#aK+*G58Xs;# z1clMq!$04XKer~=GMWaA4)ceJi6$>;2vJt*Cr&i1Ni5HcpMe`S8*YZ~Lt?b_TxyVB z(~g`@x8p7<|Jn6OhJU$p$tYX1q0Yp5vpM)H4frcf2^{=Y8vIc2LmMm;o015~JM^!A zW}#J1|0zdTQj`x9zYA*E@5aH4i9ZVe()P0Uxc1M=|Fy5JZ=l!<)MlMzlu!A%P(nCd z3)HF)wYn7L${a!2Bu9^IQP*YIO#vFBW@xfHCOTA@@*7e#S?A#yAjpV!Lgz|$b?3oP z@VMc=W7jeBuiVo=bkAGYM@q~61ziwFO|1-$v9;8aXAEyFp{e>|eFIc~>)Ou-U~a(p z-xW-~OV4#on1tm0GUP=31F8JuaQw$l4!?vx-Pg!DgeJQLi$HOY`M>u1_ZnK`;nN)f zH|wDL!qELbHQGOy^ye|gYy$9EO@6Q+=7P9B7$4IBHMw;YuIk4_R;{Y4C3vxM;oacA z$0)x6Xi0#st^hYeTNaN#9!KSO8TA-7ez@~Ww>?e7{PU38TrHpR1O;cSV@XO3%5NtX zC02m!?N&(bA1hD`T|k3nWnnS;NF!hMGDcGT4g|*tP8E+&T_m&vfsOu7Nr|m*o&!Zn@>0>VtfodCl}q83P$RL1~zt- z7fsKl?vt^)ctT99s`YKlz=A050keUJ#JYF8tu^OI|J@H<6foWdQrfETS_bT*HyiE; zmSx{qFv!(9aq?tkqG1n@&HB3R;2JA|WqrCxYeCzm;K!#MPxeW=Sg|eEGZ;aV7}NCg zAe7HmhK(X7u1f#sh4SY$vC35AjEepY%349D>0aqWUK{paE8p%hHb=*y+!oIyS8^2j z2<(>F2tWE&-{C>!;NXKeo-;G8UvG4!8kO_o2UkLUSQS3A%5%6}lzPmj?A_9s2NJ{B zqDeOKZYRf$Rbk)2(H64`6-g^reWppa5koI`F@k~NwL`r=BRvmO&h!0eZL~&p!)Cqa zv)bEyWVP?U35DHUpSi-y>9xSTflyVZ4f;>g&kguhq^8327wVD+raY&DM-?cg#vPj? zlB#S~@cL4O0mTl^Ks{SGtEHUuiWl;DNZ66rF~FKZICJkr9;b*^&sWnV_JI^f;YUev z?qYqmzjV;QVSsHEv$yj&(vFXz8aj@e>VB*KXrGAGXF9hj)k4n&kchcc@7)Wz zwg=XEol;#rlIvzY2P~_XbZzM=I9;5Us!PIj#d4K>IE&ZfSE^;F?^~v=J2H_Ox_(Iz zx!JHT;yB!pqHnyuG}|oCFpwlG3yr;Z2*0s5q(1V)b26vxkziSd_R(i#^;(9W-rdeP zZ|m4H3c#bV#Pv#FaQyv1{KrrHHi!*2uuK)MVt%eY?3i;%Dg5z7T}j@mO@CWvTK?I% zQv>hs3`%-V%T~YY+o(#+xK#FR@M%j^^dPRtd7MQSKkGNU5~^D1+}^iuBzCpy)MiCr zRdRapTEi!EF!O5p8W>SHi?2zzIeVw$OH_PuZN+!;bLQJ)QGRo9I(}yz^ZWDa`}0v& z6Ni~X_wQyuT~TGYc?GgR+r;WD_YkJ{$@+LjoogvomD&92Hc*>1_|8Op3R3ABV+cO> zHLAXQlVW@{r8ZkzI%3Ld7fVH^e3y4mXSK)Hx^Y%=rj-i0e6<-*!P}}5yd8vRDt^G@ z3QmSr8E(rZA|1Q?3rPEW>z5vi7*|)pwZN_4rvUh1#L&TAYH>l-IdzTdr^lZ@_1L`2JOC-61UshYSzeP&bHHA(+@&MPr2Sg17~fQ6-xz0epCGkjw^VaU zU@4GuvP_j}swui-uW;#tL$rGnx%Rd0Qg3626ARBXW-oAOO(!WugphwE6wb#o0m|%mpVWV2pb4R#K$X?Xj$1H+i}D+QI{pSQz0XbR3HSmzBK<8ll|+d`;WDR`9{2ig)_+aaD55^*i z7)a2(@He~b6(UG&;&D}3EMXmTep!5!{rNbVdG21BY=;$;p*O5;#3Ylkk@WBj1;T0a5WYS9EPFRnXRo^uVU8cl zuW&CNs@=3hFcM+%_8PzduI$l;Whw{|p@bQ+c6ykK^C|C60*3j0X>5I(#qWSmPhLBr zeh3rq1?TjtzsH~Z@t2m&0dj0IRBpJB^5X}9k&LyXMCw!g7);>1P*y-hQt;`#8o484 zvHXHCf!S|1VXmp51HD1Wah7W&3yQpNiOO1gRZZ~J8Ykb z)h(y7R*@?2Q!<+#<9!3dz9l&mr6W_<<4TquF&ldD+7Q}f7^ijbkx@33VIihIHJbjZ+eN$yA&T?kNoMK-UF|KWq< zHF7+oGybGe=Ysic-1=1DsAyuIHl9zmQ`W%!drtmlRg}(z@vA4}Kd*ZB5bifEYAX$n zO{&8u4iq|1BBHg7b)mk8-#+@A6oo-jGA2MED#bIO>t{dWmmh?Mu$PbS9$c7Dpb@`s zows(=X{z;z44bTsP~k$I+gis+=Je;>=~D4Sng$uRxuWhFHa!EX;@x{)d%bO!yicbu zbS&JO@OPOUD3tA?^DV*~TD#0}`FZStEvufB`s^&PZ)h)ca7r!ep21zcNN&Ue<#C1&HYX%pDf-NL(NHh!3N+h^1Vb)Aossjr^p!;86e-st*~d28epVW2*1xTN&G z67h=6Sd0H+6s%vyc#q9T2_lBLpX(XTuDA{GDbPtd2cg^`yhIT_R5Cd8%7@;#my;E* zWpE@&YN7wskH;S~`&v+L{rq?$YT|eIG0GKPT~H4S?pazf_ zja${XDy!!ZajNOes8^A!&>k_^rz$&dHNWz&U{=+I4|i+RW(gJo?v;F5#Kah0aejH! z&6aOaKk88!WDW>l9(*Wk(6xcTI}%MV&sX$FKG26-MRIk;t^b?+U{#cj!r6W*(^Rm7 z#=s=dvt`UPK-dXtoalmGSn=*#gW)pX9^J0f`r3@AT_*N|oW5er(o4j^XY}qcd--I@ z;4|?{oj%dsUF-d%%UZ9EMs0dZ%8KN(ZTt2CrE|o-fnGLPrX(ytp*Eu_OJ?+P0|lGY z#YgKiAFp|)o;0qvwPI4_eKGx|-GI}z$I-AY1>6u-xeD=8V0O#~^Qe^$7s{HQnVBG& z$&lZaf+I!0QT}uiE}`gQk*nd+CYkA%DUBA-=d@Jf0fs~R2AUI6Cc#1^uCO^$#uxYRs}otICMJRG+e}s`TtNhlW{SxhBxmRo`C}yKg@zQ!}+Qt<|i<)U$H5cB*sIrI&m6#)sZn z3y0}xLpU6zid2K|%B%sV{v}j30IZ+41=jEg+F+8b%rOq%xwlr#v(Uj{+OOglN@j1) zPYo=*a4OF0-E^XunOG)I>cerMa4fIA_xv&)IdN{P2=Mt@J_CPcJSX}t&>GBz#Z2-D zCjOWvamljWX@8UuHnm?>YV+D7cFAGKfUJ!<@0W%I5)qHq@!2bxqWP(D3r)T4p^sh9 zW`^d2iZ5euNw?a9lmcv|&4W8qPjl_=_U${hl+Q5OnAdyB`xGG1Dm$d#C3*5q2KdIbJ?Ti zUe?ea60g=9_bOC5OkjF>ktaNYQ-O5u8 zO9wFVMB*v?2@@iGnEYwqIJ`|TYCPE+|Z99)oGI0^U+hA?8lw1W!qYD z5>}VJMUI$vwc(b_iad#~F^5*CYMzFeJ*rZRPI^wGjI$r|2~#Hwd@J4)jor+3->49i z<=Axs`-q{FwpDR+%~uhLObL90(y&`~&vGSa`l`B1@Jw*XV3efu*gjD+vC0795;n6~ z$1_SQK~G{OTb0+p`g93~qTHYG@NjU3>Dg;Y%?`C_$Ib7=_IZs@o?5#fcOF+^0XGW} z@w4_ztIVdZr0W0!bYR1FUZh6YubB8H;?0%xD49lw8;m;Y*;+n^K}aGINLw9f**4ky z%51^V^g7em(xpy;*)aj(_SoY63|#Nk;LM|b*4K|TmgVR@HDGq9W$35mj*T_H8adwa z8SbV?e5ilvsD1sY*}|L4waqx3Fc(YD9xgQ#06vN#1Dk^0 z<{aJSTYaS>iONyW*`7%W^ zK-Cg$t3$1z>D;<|kL94O-2q0I$S9Axk&w*X78gS!eXHgT-!3)=Uc_Q{18$c%eEZ06 zz53(!{pU{@!P?M@z6wC9m1s;by)y~gx&hD~OPJ48#ul)&&jN0xzI-77@j4scb8Fuz zw~Oj0PV)M8&y=M{zI<_`4Oa}+t0_U_M8+*N5UPpqC1>#n{+exGw?b=Fm+#7bQz_sG zD0m4g(Bk+SG$@|kc5z7M*C=L-A&eC!em6PG1-;oTh4|&HILGC;a=R4)!W)n^$>8^r z*p=+isoB2<1W2io|>gadJ%SYQC0&ph|cZ(DWRGMy1l+^Z>K zb{NQJG#J(HHIN<@C^=QgcVQPR3lSM?;FnZCe!KMH$32vv5e!v;zF{&VNzD&D1>i%9 zO8aUy`kq3Uz@q6AS>jE*^37+Rry8Sbo_5(|V_w+T`^qlPLK~|S4)}e9X8GArx!vGk z^UhIrX3Oq;XWulf5~wN@(F*Ai6he*aCtWSnedpId5xQ0sXM=rvMamj;xH0j!p`TlN zTK>7(Y66Lr*7QWFu9q#&)(qp?=V>T~Z*a`O8T760`dV8iEe&pLmpv%k$KI}WZ_O&4 zeM>Wx-5uX|4tw1c?GcT%`f3t> zRy;L3xiZC3l*2Oqw8e+HUEfzOIZlytr5NS*k)|y`x~#-cCfJJn-cS?WF5T+JCwl%lkx+w2kVTUt)p{jptN zJl6Bu-4(lym9O7*B~89rCXj+}qbh&4FxjY#bCbuw)BCE}l!!cA}vkDePJ9Ee??3bx!_7&(gCTves5`*kqPz=xOGS-5B>`qXNkS;fa-ndiS$ zjaLSBS$7qax~1|Q?e&c-F`CBjZa@zugz50hL>@Z;_OedJa#JMbQ759Aq$U z)EFW>8|YyUaFVwT=1{DTv`mTZ35}Y1uk1<@=lP*>|3`74csJJlD2y8b+`k!G8BU6I z&xL2=rzEj0rNL55`4i#8N7R#eBt3;6otJbCVok%#O}qztVhW7^NCDx%SHA9E4oZtz z9noT|a~CoipX-{uuwg;jZZ0YhQc!TT>g96NO7mDo`FA-|l-Dmz>>t5zE}RC&QFSnukU$-U~i&ha>>=Xwc!$5g*|+(+M~mWlikS%dW?99g+} z6eIAIJubZ>ZQAPr_TW^gf2f*Uc43CQ zBdCi*QJ6hFn1h{$h9;wG?9RXgzPLKd&)1hAY5xONkew_T^g3x-Cj?_-O}%H`%_?nG zWkpM~)@|&2XvC}ZYwcS`M>=Kom}Dh4-b~ydDUdZs6>r}EX8-kawV3ZoPn47cesMsz z%h!W!VFKr>>Vo0)thmt;^C3*%UE{7|nk1U+3Xvy6yn+tDB%0cH?a3CJ!CvW_&h!j8 z*=rM(YV_$Hsd3F|%Sz3z>F9}-m?t@1^Ib(17RhNW)f~&?;WM+<&2QC{U?WxzPP+Ei zx}b0ZC%f(g5aD(uGs|t>+NH(^D0LhA!5rU5 zTY)CX<{2nFhHE-i{B8#Q<}dasv!n=O~4 zt$rCG#3Jo^lpV+*eo3@P<4Min>R7kq>f?EHmu59#lKA3GB(2lSW)sCYNlIqwuLJ*m zRTnN^Yx_Y&KiL<$8hA!!k~=<`#CjK+Y>pQQMJ?$D$P%=5tKK|hsad96PgF!&^bMdq zb1Mx$0Z{4|+@dcvT0OSj%vhA)Rp_2Hv_%MRJ!fr~03@s6E}&)R(h6WZF^WWW2(p!b z&d*phrrs%x~(gOP}`q;k>Q{^n+W_j z^ZlDA#V-VgHsl#5$=$Rtx&WKy3~Uy16FJ0UHHcK6@P&%@1?7=MaC6UTrOuT0Iur-pyY;75}&g~M|6YaR;b zVAL?dip{xp(h+IJl3k)25<;om>3PDM!VIE@K!s4gllRI61x~ojU^Bi z#Q}Toc?5BX9NbAP=82?>9*;kbex3=Xo()PW-nZ=3k9+Ht%gY3N8;hcffq-fdt+J|0 z|r>v|j0`HL?HbNDDAC9Ud9=E>v zYo~4b0M&q_tQckQtc)~;M^F58LawLL!CT|O;=Nw;q6cO|Rizk{(`62W$Tq@_fX`s0 z#o{FATl2>{$Bpsdf#9eN66(*b3K`JnvoxxPoU!#_}lZV{XJP`-K^V)X^bFA&Wj}8H%;d&g>`$ys0veFY4lv^j%-pl~6gg+G;fc$q}lZ zee-#8SKIQ8s~+u##~O6@^vbNZ`c98q-@0`_Sx8!Ti7@-2`BR#Hw9njZ#yp2-HM|3n zH63PP@%GgEBXy&7{iOz%)kK`3+bE-bUm52u1ITu>=1cYHS{$5LYbiw<2%L=HOQOST z>Z|<|5YJq55uQrHk-w)9|@7@C|#roQ{V^+K#>(cT72p2nF0 z%WJX*OkGpio=TuMqP7eECs8bp6O?9Jd##SEaoOO=?~}>Ekx(?w?#8c<)f+FD_|Oej z`#zPHU3W8=Be82H)M*eyoR@=zrJf8!}F9SoUU_E zYk4IkohP356crsK?Ps+u6)Y|Ow$@y-ZZr#`gzD*#!IyoWwn}2l3sVE$OP!)S(mu;3 z^2lG1ovV3eU(2Xvdc%Ir{0K@VPGaKmolG;^(n(Hxe4zLk$#ZROa<<;#RECM>8_R=F`}P6%rx)T)7NIxjXC2TQ6r)Nf1_}a2C;G$qF~^9vr@!=q6-3Z&-bJVH=67n z7hoiG<^jZF-%+2~#U}OoNMvM)AM2*L+duHw*t`fApsIH;IB7ex&LI9ZKMZg3E}K&T z?+*aHmuGK4ruR~?-0=eAZ^ePLI|t*(efPLB!uVn0HDEz#+M6q3{|HS%0%Tdxgc4AA zwkE(D5vsRoM%df&Gf?KQVPDY@dGKE;cmDUI!5V$=Bj^|1x)ttwFEQ~xSa?kYQXNnt za1mh*@+$cWYkC(f4dK*WRmDbykgc?JZE99@UIWq2e~2}rtk-hFpG^FSS(&YV;$IZE ztROcaq+fKwIMetiP`icQ0YFWR2-LL2vk-yWuppdXD}xUh2B(Uqk7yS?;TFH2re6r+ z-Af?e{auZ|^>nG7^DEqqrC69|=3{epg6q1VAU8BoDQJ44gmCulV$Gs_MbC0?nOc?D zVLuNB*crN&sbL7mN9#R|GFf?yr1QhXA)Wr(Eb<(4_c{m_*-=h`w<}xO+>|x=@x{PA zp>(#S$2CAMc&+kmTmZ<#DPTuRU+rK)prkN(D&p1JnxVdnRpvy9fbXCmL26%|Qe>-M z0y!fDXi#&yPSD%qq?fB3^YGHF(^&7Nhfc0DtIYaLtiI+SuNg0Xc?lF>vv)@2OWk%M z`)d8=3L5dA&r*59*7;|wioS?*)1=G(MYZ6YPmr0N;SR3!2lRVKi*qltq04zM@0Rqc zZ3mRZ~6$v!cwTQ2R>uWLhx zDgDCqT{?bzdf{EXx%NiPuhEji4hMW2RhiW3t|vuS=>{3qHjUI4{IcZa zdut}1IPc!`+9*i(n_i)uw~@N)Nn#gu>Bj-IHTbymBAV_XHY$kR;t)!OriIS!$0ya` zJ3g`!D(CAv5_C z!BIPk6@_!IM%%X)%np(+1zxr--rvq!7tn33I2#4_{BC_a{5I&H$ zx~PkC3Me1*2i4kO0bB-*~w!-OCQwpwk7dAVF<^<$~q`q zYu_Z^>*VMt%gQHz*0yZ&dGm{p2St1~R`Q1)CKFlNrcztLbp)v%iQ+1jI|HjDhL0Fj zBpRs|fqVqSAdJz|M=%ZGK2RpIXosS#*d{}YTt_yG7eGG+T9C@Q`sE-$(j6y!rLD)Z z^9R$4-t7$EQ9hoQ_55+}yRjYIYT9r#no2KTKr&i~-D<0T`Yp?K4V5wr9%~{RWU;{=rA5fir9aaF(Mf zGLOs|%0onw?4?d{*`6Zevr86dwk#8X#@_?-wniTyCj`H-p%FFois~&x@$>yiAd` z^^m|s@e9DVNlEGjNKFsL2JWppfpCR_#uHdi-O2<=)^Q$y4RbPxs2fygfRtgUFX3O9CZ5>lrLY9}w7h<^&TGzqgYZqUQBL z5X95Zq5fWE&C-3C$%C3-B0-V|?GRFUji{|ry91xH9F07}z@=6S-Sd)@M!auV0X&bS zAG!$^C?nLbff0iU(XH2@nlr4bFSRt_EZ~4o-QV1nKrHqd;1DgHtG-~D{Q@mGRA&;h zb)gt6MgUe|Dp^Z3XcgdS3Aikuy!FP0b`8Uo=E)5Q%kOcfTeskUZcGEZ|5CtMFv>3y zJd5q;B9!1covo+q7SSI58H=h$3>_62yTyn!1Le^~Y0#e|gAvL+TnuH-@aDqC(jj`d zg%M8`qK7-+nuaAft6sz3@H_#JA;MJAz^9h)ZzY1UzY6Pij%E?TCjq_@Rq&0JMTXOp zpf#ZXS|lBGh{b+^Ri(iv&P9TDhfhad>>Nfc_9{^>Xnq+F5flv2g2=<63W%EjR9Jy| zdz2g@{02^8p$qJ(h2%r6P;wisr0T6w5|1DgBV0m3S z)(E0Kf7p@xoU?aseQVF zG(py**J}C`*UMjCOO-qqllY!SNw=~`4Z>p(sk@h0^eGVyNqVmLd(}|F57-dt;2k>; zV=4S^nxM&=$oH0y_-`UodKb3olw9>!8X)<<+RJ*7U3t*_MwlAD5r-0t4q z2E;PEnbc5yOp6eNcmc|kuQ0iYNT{e|NQ$Y3Pm!Saqj&Z{VzWl9eFJ(_0^XQd=(+6v zq67rT8KxM9NXCeI57!KDJZeb@;RZwbor69Yy>mH;xqc)bWw1vAu`|1%LfT!8HDr|0 zFaS6jJfg?s2VXpDD(t}n@I%n4p_sXf5DC!3%xb8x&?0bc?1padJ`#>tX1-uNs#I_c z+y#%I%;5XAkC7Hfc_J3=#?WAoCOb~Hw|u`uCt~d@jIhXnCzOJo`y6*sL~w)qxxo;5 zRWR{j`V}!G6Z%`T)WEVofId`QojivvzXG$#uXQ@(C5)DHwn_)-^&(k;xu0K_v_JW2@Tg6L0D6N2CKxkB&>J9L}<$}z+;?Q}6DUb?LWO1gaqpdIv~ z2N2c}%1@bueCAO2AQ`0ztY1-wY{c4U1YnUJq@;6$o^O~;JV(-e&B73^+!uu0F=1Hr z*CNDyy9ajbgSVXzP<{z8n+iinJ`Ap7Y8Vo!V<)&7xa?p6WW*Pc(>=vu(fNjl4uQh~ zgCWtwP9UVG{E9u80Q)4;&Z_>N%k9 zQ-s+JsX_4E0I3@fLw)_$g+rKr7yvJuAIMoTWno8@DJg+V&<_R`)q&_Z!W6*oqr2~t zQ941lT`zvvg}804p>@`m3EY_Lzd$?ADfuIMS;e5|QP+bQxE{m$ZK`QTOkGi)4vP#z z_WKo}?9dEdq*PiJaG(ux=Yl855WVyIG~$7A5spBGN*>jjp-Y2Apoxwnb<)0oY zVas6vR=PiNBA&lKP?AqXIEcN`FsLM%0*GHa!0#mrsHy0Fz)>*u`1XFpFT0@3u9utg z*m6o}$NlhKh~Yn0usFm&u<>EI<3k|BXp+Vg)HLvi-#KV$3?*R zM-G33Hqk=-@;m(A!GRg_*`0y&^#Hjj;+J{Qy1esv@Tbzl-_VQfM|7utg~efeTLQ8O z_`}u9Ew77MW*ICpVQ`-CLD{NRHs%PY3!QL0Dk%8tZ8AzPD!B6z4-Br$Fm>VYVkqgZ z!EA<|+l^Q;DO5-*CkmMZN1!u~a=wV??;6y&ZfbN8O|}yTmAobm@yj|W6Y_+EzUdBZ zb-A545x=y7U1l4w2J#ecD3jahIHEe`JzU7WWRBqP-vw*_LQpPZnLbdv0r;g=q33ar zdOjm;NG<~m2^(hyIn_3>M7$@qf#o)^-0I}m2A2N|fu)B6>D}M70NWsQ8)R;=;kH5M zHptutnSbR@wm~M~+->&qZ-&w~d%4YCZnKwvHF~$%%Wd}ZuPfr8cj-2;+y<80z;YW{ z0&~CZ7T%0UNL_6ho6P(i4jGnxG>!zp$Z#9bcu>d~gpEkh(bLA8>JIw`C2XEzQI8$NlqKPMI&qvu|8~{`{|}Paobh zy!Rk#UiNkV!{d;Fp%JhG?^i(W>Wo=ca9~1vsDz%)PTCEi?@H#5Wq#eb} z!p_dGDfeXZWz-efk1t=ISQ@S*Cx0hDI)VFV^Utcq(6-Z|JDj*gQ5$RI-WGJQ*ox)c zxhL(&3l}a(`7S_^Yt3x0qoaHB*d5fg9sk*oPNwn{6UTg_O-Szo9zz*0*0+*gjL-h- zuhWVLoBc3sP-V#Bj^mhDr|{10{_p>Naiei)(~@FY?^&m<#UVJMa0nXR-w^2Cl_a6= zJ^S6T5{~zJ5}v58J*QxltHaJ7zE<5T3$EeHS#^=`r~f(rQ`1;J-OVOuL}$B9_M>^tJx<4YM>1hN$oThkq8|LlS;Tb(QL0eA4~ zGaWwT;%H7*)?Ay=1L)CqtMX>QzVgiOHd|JXw@5jx2;9%^%C+k^Za`~1CoC^B|Fesl zq%pq{Hx9)hpY)elYt}caN7T@xyaZv3*&*46)4|^6wO%uJrDk5f-(Rx`&MpM}v*G8i zTjJtIL?qQJYD?;jih9(QUP6&Y#{MMcGxjFl7*oS~iFG+s|eeLW|q z91n*c>{9Hd=k#!kYXKGRV{)})%a-h-)hFob=h`ou)OgyeK6r3TOiXOBl@^Hq`*J_^ z#~**}&LrAE@(nUOml0cw=E62rmIVa`D4vuqJ`1#No?aS?r}^8re4`w#M9!PHZfPhV z?U%zU;kf1J!vo}$*Zx*8h2khlNyOdL;GVX|O(({i;wxEnqT1N?ys=?W>zzt4`&Gi#wY zD?-4uIC$uwJ54@MIq{IcUQw}xk>PB|z=S^$heX9?{h;|Z_?~){;F}T%?gTEd^pu;1 zhDLGpj{ikbYlTi(&bR3NB{51r^+~F!*P?Kp{TI`ay~TnNwW#-X0w(;@$QO9@r{?~H ztOKKu~Q%$=TvG%bNGF(Qs1KVELt?F-IP09KLznoTrhNGyVxySTtOZ?~mOcc(0T zWoKi~8~J^DdfJkE1D@375HD(@)bE$r{;^1miAVEUlw?}**!>8MG@-yKl=qF z3H?%J;nj_`&D%1yPb_NhH%UaA*7-P}JaJ;W7iwwYP;Snv6=&e;TBwyMB8-vpNLT#? zD*`t#wIflqq|Cl^7MAkl_jSdXJ2~aIM01xkzhQIv@!=kPw5Igy*RL0WxAW;H&jYU@ zpm?<<9ll>WdGe$cnKXHdfH4&f3lF#UYy9@@L7F0hKt(=X>Kn2Q4i1j$=H}o)rz;|6 zorr?=uiw6vT(_G(D-cnL}lZJ#J}R z^23B49fHrqlXN)59=)t`A6uvR)+uPe021_!AD_!!`xsec5@Z-Gf z>^Apm!8>=%{OYavVl~f3r~q&0Pzd-0j8~kWe@he9H1VVHkW!)}oY&=0J`MsAn8sX) z6csxi)M!@DJoK;N|G3|DJz_5}aD>k}Zy5sr5RXXL^v11jXMLqim&9DBmvVw@rAqxk?N2^J{__ z^RC(>`VLyrq9}V)X$2&}cFA!?>gbK~A))f2!iow>xABG~0r$VH`I+e&h)v@>Jt#2# zm9r7Rg4rdFhcJ3(t~j1}VG9#1f@%`YrD#_^RPkg=O>y&wN?=ttDLx;TRpsXz*%@1U zS<>kPn@zAGv*M;f`rFW~_9E(|joB`oCD};YZ8#f?*wR7?nP^9GJ#gyJ)lKNcSug%9 zeT=rM-)F2K*9mTu(k=ID`TmGpNP&@- zt}Zknpk{?_qPeJ|-iI`a@VW|&h?4Z0*3dKp|9QPy+U_hJZEi@FfpZ*|VHpx-aee_PF_ZF$YmD&`GLVb)EG}S_V{PBm)~g7|uX zLK%n}>xP=$ttE`=8&>C*x9qsUh_EnC6iD^uLE8H-U%s4wr_Qyugm`k*tr7<6X@UD# zo%2|oZ?e3O1Xc%NDAuAA6_r^hHE`@yCrf}zuIR4$qH=>Yg zcPs4n7qTA^)zZbkJ9MbK(s&e-+hHHwnsx3;#Pp_R{#Rk+ zUh41&?`Yi}BKLXHJ6+P=VMw<@CZmnM#@U3XrG(b0HO2tZ;$;d5LL3sri%rcC4vC;F zjSPCcJ=&jB9{yze!lV0aM3ZT|i*yQ-WdOvSd-cem!_Y`xFrm;~4!K(*FrBNLB5?is zS1J-<6MTiUF?)C>FdL_wk4Cv;kBNanjCJ5lyik+1W(c+4RT`t4B2CN3JUf*YxY5}2 z=gyrA+8S~^n6;Bmv9BJ9PfcB%fk=2tYZX;uCn6g!%7T+Y7hBak>_~oIQSWw|c*mn@)YNwl_W0~O9^ccHeCumR z%;C0|1RU;AAiZ=*AHKV<+zXOYwPJKz6&$*B1+%xF704z>%Rn|yWsZl+xc)Rae(FpN zunLqu6u>jsr-#po3fYZHF^&7{Z=$#q7i6krPACPgXM5~zuT3G{5`f9lLk|P7y9*dp zc3wsZEZlY*rh^y}Ri=)5gL~ptKZXkx1+|WLu@g-Z&@45kF%H6?rVCVzsEIVuYg zV_0I11$Q4w{W9a_4A3O)zzST^Lac89YAFh5y4-R5Op*>Cw7^IeR8V{rr~G~k8YGE? zq6+N}*p2ZzBAh1Ji;QQ4ZY zd^-v5!Aq5c1DPuHIoa9s-;*R3Mr+GKjOQX&u%;N3qCW5uzVxxWFR4#8BY3^NGWF2c zW2z-`xaP@JWxCq4Ck4lFZ^9xX1Vci1>l(u6)`xUKq?R~|=XobdyTPKUJ+FfQ3|C~q>ZsgI>3m+r!!dZd;P*|_Fej5QS)DVexN(jcn z)S)xCW$kV9=+UEn7)<`k*+_2XagTRy)gwg!myibvjNxN-l`BdWaJ+!=uaQr-O*&LU z{gGSsSZ?6PtbOteJe+c}eOzFCwkri=R{97>H#Xtvv^-jyB=GciqjRgR0s0o8@pU(g zI-ddn>a$4wd9?Zl%wq&X3tWjDR`V-x+Lre1`1*UjLw)LudS`^E|&Lr zQBp%q!x&h+8cIO>H5TMs8H9%e{J3n8`Qg6#^5rX67RhEQY~JnsgjFoVvuDp1hAS~E z2#+8Ba$T9s)jS#~trJ5Pd^|AJMSH0MhagwGUci^ zUlZ*M98^0C`uOtH(yE*huR~9h74xc9%S~HK_NXhO|BDK1Ek&A}n|Fog5>& z+(*Ea;Lz7p!nY6D6#_hS^DcF#%4X$gC+^~y85q{d9=L$DM>bOKEqPZ!$`09Es$TT) zVFfM&viEm~aw10q8zUp*22{U(Y+2{aT62}_(B$5*ojAX=G=X|hR6V}H)-7s-vjo*H zcFaA|_EOo9E83#ky=sH9Y>W-^Z5#xVFvS@EJ1f+@3pk2W!gMFCV5#Yk9zT{s?)p$@ zSS(padzE$AYK2Bhcr+fEs7G_f_mc*=L-#WQAU5twkxBetC>umE>Pc6ubR80>fpYCc z5j+TJ@kpt_bvv7wJ&^VU#l#E&vZlDD+-p{UjIPLH6<7r6`&GyoMPOb_{3j9tzCScpaX0Chm-iqY)GDC24kXm+9Jv$n&0h zpcEhLdF!&y5k4U(=no3RwGrPF_GfQeU^3m&5pacy}O>Lx29MX(KZ<;W?cT{gSB?v5yl4U1|2FOXS&pvJyX!z!oi_Eczob^h z(lGw0u<-devSI0?W=XrY|IpyCNTN1~Wzz=_UV5~*w?tlc+1pwguJ*_vK9MDr1%Uw8 zA%9K3^W>|CY-gvYhQ{g)Z*AA*-NUUB51fuOb$JYw^9^6NEiKZRg&-8|quL4qb#G!~ zVz?o&ygOAXYomS6zI^DSfJ1_USF(QxiUWO0 z1V|G6dHp1+in-h^EYiEVZBmZ=yhu~>Au@UBMw4ed$!B*r7%_6*MrdN%dFf}&p&DE9 zm8`KZyu6w-g(Md2xL-HGt<>x{wwG`Qy(FIwJ8^sV+n^)Xr;a05crD~?;8vsBYXJ($ zQZ5Nx!K?QgV?fWA6tZU}vWi1(*!v!dIFy=QymTpG=m2zwMC$lWDe%?Vy|4uzxk`sF z4&u(#)D*PJ)f||Rh2Q7_xT*obYDh$a{-WHKI{HzSvFp9qRGIg0F!bSrIjE{M%8Z^X z;I2o*TDV6&=tBWf0@8-KZ%gp@LO9}9}zfhMR?oMNd?d9Zd_Hp#&+Mp!;Hifg1y3}vf?4nr^!%x(g`tf9(9dH znw{f6C~KyLqG?~bG+HY^G)AED=&+us1aLeqPZKoT01pI0mG{}`^V#G>4;uiJ z5ljUP;5>?@8T5Bsd*g^DfR5HGN2SlGfbca1bQy0BeJ%S73XQHDv(5`d+eq-)+;YLOK|lm)^1e^|O=Z_sJuWA&8^mdEN{`m!}~l)#)u@mFs_jmj3W zs%rz@zxNKWeR}=u!cj%Jr&?Cap=zYExpt`nE=~$-aeD2 z5V#K0zi9z1WCYMq$^0b`R>$g*^5C&YrMHBH7SXe)5wj+$z`-8zsJlY=vnydWJielz z^QubUyNXS?d7{qlR&Wdv2hzrKbB~zanekXTTYcqRE!RS$x0_d~Nii40lKU`0Sn`?{ zN&kzbrG09t&pr(}1B!$gdyMrBZ|C?bS9B|zmR28NB_;irEgO|VQ3WLIhR6{7^MvR2 zYyz}$6?h4#O~^gP^|l*BQM{VE$&&3AU6-|NUQ?B?j7#~*`!A<1;Q;43)0vnED#Dn% zDKIY${>k|+Y!fW~YAwajsDSnur_4TLtWE3QZE`+U=TreeJmg9McS8ys+UDVk=N$+d zQlMSc^P92!aCNxezXSjPJ`5hcHq|P~$ERkrOI&MhX+b-5CF77F;ESJ3wP>Pnd&~6- z&Veax(jMc5ty~Blzy_IMp#EdNiSAg636TtlkpYmVF&?-bHE;oj0Mh`o6oN8o*0QZE8z=h# zKE>0hGEim-E2&|OY$kxHWZ~i}{`KpjFs!1D3ey{>0`t+>4-lxpT%aO=5|fl5YLo6v z#pTYuki8U0vNSNz)Z7Se3Y!;1*8|A`63))k1;S z=6a{7<8U6pLGa#>VK%o?xl=1+wr5j3E}lRC zac`xO$o4!#&Q}YZX!klY-=+}<7E`7%xWlzpZeC4JdB08J^mLF%0rnp9w*!9CV)K}r~UDhY_%8ssDJ}J=7v|Mk(RnAp2N}iyG@Ug!AuYEPyk~Tjr z>MbiKMP6cyjxC`{nh7HPxSyRfUce-Fu*+tstrcPkxUCq_9ijR#hBiSYUZzX8@6L7S zWQ-u*4!N2D%Le}eH^J8d^lxsG9i#$F?3}qweWZM_8B1F=s)R#r$aA31fL6G-I=Y{< ziA)35JJ_S{%`Un1yNW06N4-0Q$8(-LmswMDCmyU?0p58X3%V&=XtCu`wL=NOBjaS? zOCV7L_-zVMmc4iqFfeE(DuQ>sc=`Ao0u@2BrG=pfb{t@i-RC|l*qVT`DbPL_xQ$c^ zK=NbGG{pcu0r1)z(*d8Za!#OE>cIsQ|ehJpeYCZzO%D)2>DN& zMX+Xv2h6lYWfpFmquOmG7vL&DL$v$5{|{rlKl=-GTb_6J33n2*0bdBBNYy;ONj}Pd z6wptk)rlqs1_mPtBrbc?ZyH>|P&gJ)8lW=~5D^g>99+e6rONy3UEtGMt-S6jmqv?D z+e<;;I6wCOA?DSVUEc1<@*fzaz07I&ot;!UH)zXXq2pPcbA8GWpdE1p6a65KApK$6 zH04@T`XUq1q}T*3Flj9L)fW;rt9x#rA=3MR6T?icdeius!xB=H81_X!G)153F@w zKh51p{}cNJCh&FW#MdyS_hwZduBOPl#k<6!S`QFzG*^b=f&;;_Q#-t}!@ZikI8+YW z5p2nGGt}3nCKhh)m3npagH@a0P-)knzbtx&EW%P_Vt!f3$YLV6!0N~36E*%x9cK2LaIs0mp5015|EsaYn144QSli z2LKP%Dc#>ly;PXpR))$>cD~615wbMQIP;EI7W)YAChlUGwKrXMveP&#bMz1CA*1es zu5W{OzVZhxUU#klVnrpvW$xH-U%2Z3DSU-;qnh}})H+aAv|;r*4+*esfhKmt*_sz@ zY=6!7W>G*(G1DHH9SkQ0OZ=?&_edCV5lvd$+r67?aji3CBMghO6H`FfBI57AW{H)* zid-7!=nU<+2)ZTSnU~n#hoS>l2ue(a*Nm1X>Dfy2nI;1`e1Yh`vA8r}=QY!zz=Yrx z2=<2NXvG8coaBr!dSEW=7RIA_?$7!%g)JROU~Qpv5>xco=*9NUT|f)6u(ihW{ImDK zNCqNV-MWDdB)4QmM3s@TaeUmSX=W~f0Y*kBKqdf|2=r(ovQZ#m43#_D1Ae~L3@kwf zw?0r+Z3p!U=n95J;*%}%SG!aJs{+{Oq$)N|h5Wx>TG65mvKi2D{k)$&JLpXS6YAym zue{-N7jO*P_ONn?Na7Y)6c=2mashk46b@R?MZbE`2hDXdm=?j}MQuwS{7S)qkI!-| zrIM#W*LnYc5|PM1m*Ob_%3jb$mkiAth*cQpDc4Ha0nT484F(#a@H6ZvuoE70(MHDN zZyrCIeG*hs0=yQ;(5SeW zm=6GJ0l)#RRqWEfzS?C!{!|*gOk1=;2aMGCcPG=uEP^IZfKjWX5G=qd!wp*LY;A=V znJf*JtJCOhu$sH>nJ^>)Pyol^^mmyWOyIGrf^*$zv9_zMzY8nfbmF~|hM=Onr)Sh~ zR6Sg}sDOYL$nwTum9Qg07_++S309=EQ6PiS*55f50t{%R=`MxryE)$*oJMT^Z-K^# zr{Xr`KmR>Ry5h6XME`wz(cr)IBXkCL&u#zX^mKRsQ2q6Li3@CT^5Xx^8`?ARpARXT zqx;2I?LWBXqWF(g=jpLm4F9Lvod07{&;L6cK0P-5?F)~vu4FMqa@9YTh*4&B9cXU| zx*RDnEFD@}mC=*6Ch>_Ima{{SYLBJn{dOO_rsTn!W{nQ^))~j{a$$&{+AGt)5~s9` zR=%i19veWhm+aRO?P;D5bX#O8E7ctZzFbFYKAg%0xC}0W{;R$*=D{D?Ehb4Kp=PJJ zgm>LCLYxfLg|8PcwxxQ>D|*qtw~yt~N2`T=vS8$BhWlYY2P5y(77Rf10w1m@5Mu2l zUTV+LZQ?*rSd7?JS=Tt`(uF0sWG{=!S0aSWkfuqsq&lph)1 zi^x4Q(7#9Q+p^GFJ;DBWa9Q36dkx;$gAGW!TT&;MR~C& zscVnn2R3zV1C-V6%Sax)%~Ak%Gea$7j6Q*o7^0|qFUY|uxpMYQ9_!t`ME*)>W^7^jz;{T z3TGoMWyj^sN~sEHzj%fR$xMGAPv#_ui&d`?se@K4^u{hL6#4iK2Qpa0hTBIFSpZdu zkiMDUIr2n6(J5BoK;Amz8^XrhWwQ11fUo<gl}m^#Cqym-X>fbHT)1YvuLk zBZ_OZKW_n5Tb|Ow9WmfM;5T--AuF3&u)7tZ2w$IdXvT5mtvC6RyNdJz?VOrMtn%H-mF^{J3+^KIiKs zPe|6yC1`8!2n6|4k7;;5W=~-Jbp%(C253-Ycy!a`pgjs3IwBD`)R}fWX>GZzS81;c z$a@zyb?RB+1m`&E8lJuJ49RR`WOE#UmN?7heDV*X&u9MjN^6gSqWn-Hovz!2bI9Jz z)H;1R{?@W*XoB6#DytgLy>#cGophK0Cri#e+~4`v0M^ScwPlF1TS#1b9VwDyWbcBR zaw+fYxzS#^V2^iQZ8+RBVXlM|AU*qWaXWG-84@ORH1j9Q@U?)cdRjzR|2hA&E!4$9 zHs%47jlrWIB=#eNG`Q4&*Swc(Bu~+mql#sB1Km>SBS`N3 z@`%LPcL_nI0To@eU=uXpl7gUrgF95Pjz#XIWa1HL6c2o$!lAh@7wor};36!-SWD^Ab$_ z%bh!9)GK~C8)5yR7pbm);`yyDiH+vQDH*Sfd>fDMUmC8o%iywf!>5Q2^kq_A;O-W& zN(Xk0eCfL`B@(R)a6cQ>uHei%6GhgK%|y<=felk?hytJ)Xu_uKO)VnOj#!P|O{($n z8gmRf*f3rgz&h^+HG4Tx#Ou@3aga{OhWxrYw)tj+4FT6pbeejmBKK)aqH<`vP!5Z; zZTyt~fuYn2 z-lEaHuiI*`!oOX4X6I9e(ofGvlH2h?{HDHl@{Z2x&m^B*ef+4KF) zdX(T>FwPs_QSF6BEN?LCfyUiYi9Fshbux^GhD6HAeCMVA#4~DSct9#Y)^%z07pI;m zTfT^_`dN_997jk~)kS?5FqMaK59s1s1&xrtSa4&!Y%nCv;~t$=&L zEmvR+656wms`HQ3MGL;ka69XjnX_;&6z#rg>=SKe(sXw_SeEAT-VDtEtNQ`;#vG zp(R`{7Dfp|#ow`W7Liutup6yg5X$XxyEg`7T#CMXVIh68?8*{v9>K;jYP|hfo)$YI z3w^5}s#b824VUuoxjaiFHLCR8a`qxdDrwv}m7JZ%Gk;sM&3j;+u4XMsNZ|au$s?`V zM1?skIZh|T_vQ>m7AC|DMYLF~KPfEM{iIqLskYdD^Ff6Ly&j@M=9)Ft^^k1lkJeO+ z(Ozb3)asfQG^@Z*{dguJvXqLmz(~A&G&g2+V5jLGv32t#+PmGgrD1f9#3rKM(v;?> zyGQxdvgFxu-qjHD5=w7nG?qG@pU$|!|L)1%a&*F3$XBJjQHGAmtFgz5`jRjtk`QU5 zM|n_wrQwF=0--1c2tI;*kGB{0Ve_0@YfKnw2CP|EB7MkhxTe?nCX|v-mMB?t9r|Qw z$@T@Vq(^o6`eYd?KdlBXl0W1^o*90lHM~rdop8cW(3WVcI*?!dXs97_S!GQwZa+fJ z2Wydqv@rI)UXiND8~6h8rfkotmU+?m{!TJFq3i39M9xM7R(kfiiZ${erR8P39OtFt zRTLC*Yh1X=`xW!+obdFcEk)$&p+%6>q8_KWYW{?Zid2w>MSQnF!EZKxngiro871u) zcj1-3AHMt?uuRWzr@kzEa<@Ry{UpYvH$`wl+1upGI3ATXL23a$`ljz(<7>g(8dba? zyRz1;P1NDbXB8VM5l8qig0q#IbU_##0%u z2cAEh5O9n)(&t0Z?HVWfe}6rc%b>bA?DajS0otD z;?d6c@NZV0tLAhh_Pw&HB31MnyfbgmC9JbRl}6FF%F*~G^Oq&X+nwJlbOPgt8&yY) zv){*jFR^joFI*V6av|bxaC7W>9&*=7bO`hex$S<~NQ4EF3U1|hAcK}R_BNAw^!wo0 zp{n|EoNwOY$gp41jo(dMD18^}AEPIP>Zr@-1=i=3D6jy%y}mQD#!Il<9vNIVoF5X0 z!wWuTmm@AnIanq4F$yx(UI`bv$I5~-cY~=5*xzeV8l8$$K?(BeHWta}-ALEYkhPfo zx-i&+ensT&9W^Gs;JKOJA8oPZrGer#L%LTlq_js!?Zv61G9@DPoa;)gHteH%zQv3P zC%LhPS+?<7IlJ>ZzHe-Qgp$H%HaVjv#P%P%YJb`t=lZac$K&*cT?M61KY5ni9C}LzVW|ut+Y{&i*r|+nYxPvzn{h~^Xe{@WB||R--lSY7qi@Kb{EdXng`-fi@31ic(uYHtz-N=9qn6&y>QbMEg0Z(f z(SCfFQSWkJ&h$&Vcz3fZRY8^uLM2m6?)B&sqU=<}QI#$&(YsS|@0!+h89tfMXxz!Z zz}3Fw)#)iWUB+r+=D90j8!n;g*lm-0X0+0#@$CU5l*e*=v$j8Omch99_My!0rHXY9 z795>B!!vs=_-wfeR(9h9?0bb^?_(>P?;LzRB#2~x*Y-_R&qztQu~nIwE`x}n z&QuVqcFA`kVwNwGb)9&*O-8X-6rp?g$6hBAO!`|7v)D_d5=#(el~r7b z!nbOX=;C+Oa6wFQXYQjVDTi5$Ap6fR(1*L`C*#q1B4g-%{Or$*IeRTfLUTXZ2CyyV za<&&u+UwFbd#Xq58fSgfW}PUhx2@Vm74o)NGGY}g3X?ZoOUyh(7dqd1pf373dQh(P z>r1_1Hk}F88GK!B)7MI+7$3D%g3Pbej2SP`x|ir|>Ml}vx*v{xGFU%M7UHqE`x3VA z{jo)QI#lBCqHwh&>5+hGS1*y@rikKVf(~io3k+nS3%sQq5>8znPS2bAZCSd}{Q1g% zr$=ba#7gt?4vcDbD@7*{UOT!n{1Na@1oDkj1&`U?<^Mha9o^H$6*&zqH#l`7XlbYv zvNFmMV~cp&;m?_+3}l~MJ1Ye$6}%(oykdFLk?Em(EKD_me1pe{T5cIXrmQ@Sb7DFR z!~W~IvYwnLaOS1?-?1z6Ms(R*ozL zAeU))b(%ixz_(^JS&!HyrOxz(s56S3GEhdXXH79*ql^X zUZ6@{u0T#@Bp-F1rqox)9gAaf`BQ4;pbD3=mp3^{a9Mi3B3TzI7+)Faf4e}Va%(|( z|H6T!M=7DX*|`&4o4oI?j~7KD_Z+l06?Y%nruqr*>F7fkcjaaNS#+TLtUE8q5UeXO zKFOeR>m*wi&DaxYmZxAotfMZL=zu;N&osb9EQasNh;a-L{)-t3ounIk&S8}%VDxs=zW z?s4XJJGl~jVra3dx+r|=N}ty8!y(HsLL`K$-Z)^L$b-yG+T$bVIqqWon=&MQW+c1r z{Ln>OjAEplL-7L-QaDD5=1?8Y09eCZ#%^wE%{k@l??ly65A^n|P(n7O=p!9`TYb9eQSQd(Zkc3G;o)6ozs`VP z*VB%c5Q;+$VGCxCF0VF95`}mzTz*E}z)RV&*#67)J;nGPK(;yZq{vNAVDBy#&Xxq{ zp7T#c@iui5I;!(S&cz-h9~esziUwzO$(4cqS_eNwO-#IBqM?y-L$&7pA>$(c2FAWP z%**d#DHueA6TfW&&MH7HyfPtK@|IA~#8C1bQN_i09{)+!qLIXe=IrmU-GAP-|L!N8 zvgIB0Q&4mH#_m!puiWF;MlZry?A~28*wSO-P__y!9{bSc!b%$K$uP5L#dtzcWo}Ao@bZD%?m~IqfZOz(mt4k=%Z$;T!a!}y zF7D$uAs^&9tu-VnHypC?Uct+yN3_4P?#KoHljM#sm4S>*y1QMLFvs*~FS3!kqDWu4 zSCq?bUqKt6WZ^}FvXr=o^&6M`^PO_!2dFtC+Rp^1r+=I z=21YTdn!V=wNPi$tRzhImG+AKF0oL4MP!gF6PoQ4rpGI_F#1}yy33H`H}0*i(O={a zy&Iu-lQi(e)8C;0DX`oy02a)wqa2Lt+8-tvhJG%vEpBe6MW>nwWL^+H9o2u-VW|%HGNZ^*5+Xc(agGPX;YTH zGn5s{2X91@BfXnWV2`vdBFXE?A*rJp<%6k4W@GZc`Y#^IiG}{9Wd4pXsmi(OZ2hog zsF8=#@{f`=lje$myU`})8c{+bVr~(*-8t{qySv7Ul+8Pl@;f!Vx})(0@ZWqKxJHo~ zIm{}>i#*>TL-`gnr1c4JkP)ra%gChSW7l%kHxynGGR*nWf_pJ*oQgO7MX=eNiFP>{ zzJBv)2g91asj+;Jymes2>v(CXbjpQ-bc#{hA)aIL@G7aRD7eyF+n*C{UK&qg8im>X8#oJH`|`ja=-6gv zEYjE{HzIHX)rZJR@=pBOPii8b`1L8rfR*Bp_`Wj-9e z>g&Othk!@c6Z(H21^g%YiONab=dNEl_l{FGjgg|YJ)x~f8ETP-w=*10{Qllkmzfqg z>CET}zF=_5R}|hak?GHJ4VYfIIXm_C_m}8Ai~h5H?4-`4NzF0h)E6NY;&FQBg+nf8 z0kO4dsrUbycm47+Q3E=AmfhvC>gazj<`-WR2k!14Gfc?5!`tRS;mEACfBK)aX!%$^ zI>&jTP6Adjm0HD3?AcM}5l6mkz03W(E@zKQT8r zaZSo5^hzsHM!V3A<@#UHd*6Qwp3v07E-XRi>1_Cub?^7&^R>S zh^O?V*md&Z;pyt)Gxz=)Anyc>+Dr%cP{Gf*4Gugx-J)IMJ;RH zvDz?t`0V=TRivr3u5Idu9)>T*feuc{!^^+y{r_3CgX^ zxhElau`j~C_K@i{cU0x-;AOc$(jnt_vW<-UXbcvM{1^?^z;oMtc}V*^8$e;-YP`n@ zBwp1X#E=Ox5CPcK0U;z~^zjA}qs9h&+0!}^^MVd#Cj3hC2s%XbCYA-MCD^#DW858Q zobhZN4f|<0lojRNKCdtJWRIm;L)pIqg`B<@$D10MFFaAh}=o4g-fD+R7 zA$-$ccPh70iRU*p?h4Y!hopQINR~1QMXnT}>>DUe$}*L?h?6Yd{5PXLqWINVJI1yd zPj0qj#%wA~yWqY94%5>1E0R|I@~k^DoYid@wXVg=g&Y1*^!=ZVcNfO$t7*gUK=G2h z1^L@=8|*CfbMM}M7+lyfcnQPhyUt@iw8!J3{UT_f{X%uc#^c9s+OKLA`M@gipC;Q5 zU>;Gm5?Y1&tWUw$uLt$WSKOx zzAUEe4FOIZBG<89$f}0N~8K|Teu+ly%=oZX4rYY|&g_SF^>qxw=d&z^kCkrzMr z=zK=FuPk!j6mt0#v&rZ54F*Q$e&zbPh1f6TxXwX!Xusm3V?&OxM6OE@em-%(flh;Q zc^r!Ylg(tH14x!|)Pw)dqo1|?gf;^cb@d+dJF@dOcVzhx9Jz#-Q_U2urGkYv$D6bp z+77?A&#kPK0GXo`SO%v}SwbtE3@JdH4D`to6j>l}1xw_4;O^ZDJ={l|6eWW>xbq%` zKq7$w${*U4h=PIw82>%wDFIIp;8RvsmX(*sXuP{l8&VUoZg3*Zfw`$%C7=dK$jHb5 zLpHJEGRzHp*0G1h)zOppPGCm2BwOk7;|T+p{l=aAp`Z9&=l-Dn z$76H8@n6LAu9PbU*S(!!sHeaWKb~0K?R+nmH99tCfBVt^n2{Jr|GOJ|nz*PHz`*j7 zJu;}Gu-sc@#8GKXHnC{v62Dxv<0s^vpA?ul1Ueo(82kpaMq-hd#J>8BwzR0x^rc`& zn^a<`3vC7oj6?(Nh3kU%+N5e4&@H(T3VVURk~XUa^lVyS(nEJO89YJ*3=QW1VM8t$ z(gK<~Dt#6IiQ%di8bAAs4Qv*~r5` z`9leO(WvL_@yZo=Twz(fLI1NUytua|^VFEiE}{Ne0;@MP%&1$yx-O?>YoQ=5qoKrh z#9iP^4Z$f+Y?oPgZRhTWNHgDnO1O~dQZ8M#Ef^z#+Sz5hy1E)61pW?^zPF*&Rpj9{ zu5^z8*)TXr%`sukjg%K|!xg=A8KLfkJaDdFzP|B1Gi~owz{4d5fQC#8=y5esFJ8Q8 zz+!a|#zsdsyX9A$fIkUXR23HV#9UL+K^{^Yk4Ruzoi^__+mpfVVJ&D_; z@XKHT3e0e#!Od+S9Hh-S(&VcS-KoyIV?bBs<_j!P&R}=7NIUD9M^J4IYcKI0a+8d& z>1xYrigIbqG-#GnAaq^I6m0buoZogoDV&Ch95faDU~3*ZTBZ9A#q)wobSDxUpg}J} zH7Rd7S>ZX$&Re=ILHAAN_k3~#`=W#eX|gDPPJZb9YFYP9QPuqH$GeUk3)4BL6>+KM zZ`Fnzs}g#h;=aV}A3Tjc|DXSSIChfZJx;%S92>YkEoLDt3yesc#`0Sk7Zwy01UeTT zVCw#{djHrfd0?`0eQiy1^(Ux9KGSXIfJCvY@L(K^sPmk7&f|P^c!0KP3~3;a`)Exja0?Z7al3$7vF>z5nyv+CC5HzHdHURAScEB179+Lv*!btAtonAG zm&w!IbDMrs1c8h(BUG3;Yhh88?i*#DPfQ(_L;6Hyub~vN`P-tKK= z5!P(kt7EejbR6s}>p`j1dS@qI+x^P{S*aQa7h7ev1MgoY5ztz-v7N_QX=~A&jihICt9u^9EI)K*WkL#- z7)5WHpdK9(&?|%OKNskihA-s5>e}eIuVyv2Sujqu5LD^!sSMgX>y{)B;UXOlq&Kec zG@OxqDrvhu-M+rLIRJ$E;CTRingGI}X;T3pyXS*A1)_N=O7yE&y&wVyz~g3s!Sud- z`2x%X0mF=}qSiwG{{Bqt;t#~c^lcb_0bJze?{5m8{6KqF29V_f(QCeGi8bvJ6b|3t zXM#PFKn@^)X*J)KaYdS<3QPe=2nYTRl7CUwioIv!IDcG4R_{Fj_z0Ep;6d{x%GfMp z5Rkc>X!Qu61ZqchkN^{l+$=9yfx5#YV!2%~2h@-SK(qHJ7Y0^|22VKX5_pvX9*n>X zk`90G^Nge9r%&m87X~yz)6fS7HjG?HS%qQwATRJsa~WALh?1^sMCVp};WzWD4vP4@ ztd0&2zbM1xJ4{{FlbEX)d%2g>`=V`jNUbI3v7bZ=FQaqG;Yz(+IX{;A@s4|&Cwj!k zVZ20f3<`IXt|T6en)Roj2PzJOL=mevS#O81moI5Un#pp$Xiye;=p#Z0Uvh)dQ*ckN zY0{pmLi5>~o(vnB4FCB_MxS4w{{W}12^8Cig`2}b)=7K(2@=d%dinVgfNqqsGnYZ` z%hBEi3S&^0z`$~L$iZ#_sG++6{YnLGI$1S_SOEd70hlnJo}64?Prv$ie(uSRcQIiy z=R_U5!OrvIp19UriF*FtXn*qBy^`iS|isrbF*)eYqxjY%tc%n{ucrN0E0r4 zl_SH$9o^j`=8xlq%#FZfM+6K%z7!G?s_-S7fXQ}kFfvh9pby4Hz}Qgs(ZNb+S!rqS zWb+#qkm){u{(Pd<6^u#HCQZQ{i_1X1A+3g0c@T?%L%0A>EGsKB0{T`dw_!;zKx3h$aGe8Ln_HtEd=o%=rJQl4AB@-s77#Z6YFI+>!&z%F0$YHbijE zvvY%!v$L*&{`W`U!LPvQ72l*(9C+_Q!K!@TbWPcw#z<=1q_fk}n2A9J-^`(Vs+An# zBg(}IA(_{=3C&pX8B`Om-P0N5wp>p7IE&$SYpS2(fp`o$cEw6W6(|OuU6m}1+D;k5 za9B;x5g9(dBypKYxF34n;jN^kqy{gZhK5F0Utcr|7(-k?u|D@%BtqV)mT4N!qgNV* z9E2E{+sUfwd(4=&AX8{$_~za!!&C{q#>SjXOiWmW8{#TpR3Xs@@VLrD(XVj& z^c`qv;b|5m?td&##GIdXdng8eop{ycgKvz;vV|>F85u8R--K209L!Kk*f7gzB%Na8 z?qjtiNJ_tg5yp&5Pz_xivl)RQ+27xPtI#lJ7fD!}y`|0i=-vf??S6?5j++J*GS{v3 zYF=r%nCmn%>Qlq2)jtb64h10^x>v7;!bKO06-a!VNzgosNb*sbNWoX72(NsQ^~U4J zkHgX?ubE|D6(x65a`T2gCuEcL%BWACJV90u!-zjBU{d4}aX8lI9V3=ly38kg<*Y0% zuVHK!Wvl)*pA+AsQD^_W9Qph2K`eSlC0wWL_!*e&nK*V`4g2Jsk@jh4cg1sQp?>;j zVtso|%lFcE1muiFQ^K9ps#bNQ08MSQ7v4Wge?xCY& zeu}_f_taAOf?1=XVeY#2a1QAdo_6oIuq9(BJqa^vcK zc=aKlkEPG5$I@z!zyLg3@6?lmf-wk7Xv%kR2$|>Y!D3$|mb?J&bGgOxSnV) z*%+qt5>%>py>Y7KYMTlIp@Fl7M&XFX7Ht#N0qh_4_niCv}Pgh2k zPJM#re8Ge7U;h%^P$WzUx=bmU7#kq6Ck_a4dAyD}8c&uQ2KNcFdG^Tis_e<#nQB4n%LyLTea`<$Z)X9CbC-&MJ;OP6Nn z^;F=MBo4xk=?56G3fT9hD0#5OH2k^c0H4kDbY6gM};-Z6~qjpj&tz8L1yqB9JcuWUN-*B-f*<#xfkb^_(r( zf+GtH3oVDR2$L8#<~}r?)G++GvtR`CCXNzHY6KyA}9f7Iy}TYxWh>bLk!jkT!h@+pI6nw ziUT8Ea0Y8b;(DP-}7qO_0sKU`<@?aNlQLZNaz@pKWpb)WWTmCq&UHI(9uv-O zo`AQhxnvLHYmOtBzFvY$-*?pSzqDdB%R0wtrpT%7J-h8TTHwo>{5}lxVmxs}nU$_q zd$rxXv_8)#-QWM$>sQ=K>4*L?R77MvWnFt@>Q9ztFT;N5A6A@Zx8D9qSgXp<|5vXU z^moqxW4;(wyLjleR1}SV1*6!1{nU4V@2XM09nnHR5K(;0Yw|RHeB6pVeE*6mtv)pU zU;n6eu6_9kYii`3Uu>%zxKkr97s$T+Gx=yL>rWkJx4%w4t_}R5&|Pub_dT`z?{{7C zFJOPgX@-CKL=67r6A6?1Z-dGI{Y7_=(9o-f6+gY+Jq*)W^gn8YJA6g#%*@QGu&``Y zhK2tB-u=Hv-~ZVN%UyWgu-_}Ehp|rErXc#vj&lp0yV|Ovv|ajY$2U_d$HV`QCcuDR z;?|W~tDS_wF#N){vt8 z7(I>u%a!C?Ypra0lqy?>VicC^N&($4x1%(bxB0g7K0B<6pt&J7yiJ&s^7dJYD%Kqa zh>sbMU(VgMV5up(%e}jGlcB9{o~=vXqDhBQVO-M4OwDqWqWsdOV@au5;q6~tDxWnv z-fF%UxueE``teobl*}7ncFzj&%-`*6+|Puj^k3>yDEH7Q+^-yThGG1!pZ|5HL*^s$ zLnZwj9}!U`bmS~G+_Yiv;IgZF%%HlGQ^re8UQw5-w<2#Z{KT`OL+S&!6uHWr;n|nK zN-f=TeOUrn*rt8&*?$UoyH_pt&v51vTf@dzVYe4vD$!qM8qm*)9EeOewC#zA(M)$S zO!MP-vRSnE-51wmv#Fj6F0xXK8QD4Nsj}%)b9|NgspD#=hBPDvQWr(t{2m!NS?r}m z^#^b6Y4N=JlTl5$vE)qcOi<>vkq*5Wr6El#I~z&PgHL5U{S$``{(1g)x~#*+_1?d+ zQu`y_M^LFGYLc$0q~!NZy^-wY^HbNKaxG*@i6w}NQ$x-kGwQ5rVIQ14!a%jlT889!?;O7_yHuI~QgboC<4~_wrEcFH=aIbGFm+^2|8~`LANA6!8g~|r zj|o3|%HGx@W2fdN=UM2NWi{o`ctWlCnaUT2xzlnxJd^lNb!%{}=`OL-@~b${a?0hx zj_#X)(6Uq!Wq=WE_@i>uYF-NDB2 zTN}#bYankJ#*8J&-$I|`JZyl^)V}cjgv&W`}A3jhq5IGiP*d`L!@I&*CDtyT5otOFQQNhdN z`W;grlCF_kQ~mSO5C(dij!FHu9hQwmNu!ZcxOT0VBeQLJX+{JnVlLH)2p|xv zjg3vbS}J>EXI5rrG(6r;_+xQcP*81pdKz8oq* z(hhrjqeu<>@uL__$2<(3+$qUdKW>_vosB}&1%c2Jm>Y#6pM#nCeJzP-VONNF+Bc8d zuCT35i)5or-V#8z1(3K<_;AA)qz)>aO?($?IybHhT;wn%;k&_XGzoHp*@`DFE}m~L z16fzjxq7wW>GL<2EAhoy#M|212=esjpBL*tp2Y6lQ0WTYtdLJEY+PM0iSs5=IDO}M+$=kLl zhg*()Avw;#O}&~FtcIR4iD)5C3G!$&GX*>@t$u*gxg47}ZwB!oRpQB6gU3T+B1keP z;M*ENe}?q{G_(PAD!`pjl6Zh2OQDUd2b5xuy%O=ELxi z74xLv_xaiJK{b5(mlgXsZE~nwxNF6r6?k-uC*O8Ha&%->nVUNJcthrkd>ac-hv+%okDgmCaH7kTdDR-n-md7Hrc5JO+os$^ zc^1i>(q_x6$njMnapa^O1YqX7cOKv!iDd1GN!Wel@*NUx4J@aqtgNhdiRb6`lms*e zdX%7xY6v~lqWyMzh~tMAfdAgTdpgF(@c_|lDuCdsl59!>r&|oS9Gya!6ahXi5r|g_ z()DV!5QA8ksI3hU5aUgS+JMU5k5~?XWBKNaacSofG`Fy#LIy0%#VQ*<2Im~6?{iFk z7nJ1B&XU+JWJvY3TC__IMK7>^I`LKEZdKSfx=+4Fue3Tbf3Izk#kk(floUlgU;dk4&L>X; zTQdM)*$);+l}uFtoy^H;e#cGNK^rw8k19G|Hux+e>Qk&IdxMcptYCBYCj}K1?}deh zuED{${TG~~z~j)gEA0Gwi%eQ^i*iw6Dq0H6u`#K)(&YTb5OBnRCB;@Dm0r;KD! zkfj=wIUtezmZO(FfS}Kfce`b0X9L;UX2*p%n{dFw(fDAJY)$f&@r8U{@R-=!lCltU z3>8W)mqx~A!;|(486-RwbQbC7#sU*rbRd&ECnr z8W271(L0csuLO1#_kzS5;V0Lib6poi7y z%eMu{0K6lKlu|xC9|o-)lJcvrU3xh#1Sqxm`2okU47=waop-Z3I5;G~oVf`wASpRn za+3cUvy6;PYo?hP76d+{ARb*O24s38W&EI(J@5cNBt54ZH!C61$paWH>AZ72@$+i$ zqgRo$#YWC^poSr{kL0Qo(qr9@!-u%JxdRZFAYYw$pBaJUsw6oLkQd@P=6+FLe*)oC zg66$1$WZy?^IOCAyxc~|i-FU5-*?Vg?gQWmba)t?N#Yrp#Yj+FCBQE%nACSzV!6Q@ zibov4YycD-w2u;k7RbnhTd@zzYy?XLXw4|hYE6?@J2 z)Knc7)9rM0EZCALQL=C19I#VRUYx#bnSTT^pIId60B^~LvlNO0g|hROXht<-SkQ_~ zN+dBl74WHwI8XvxqfdY~{(@_iL9P8fG&FR_VU-uF*6)bA{N`Tqv)}GRkEfI7*oA08 z3OIpqnIJ91?NFrA;7N_p`FHQZN(RGB9|aLq-xkRP_2dwtS?sC<53^fk1;W zR)DqS0YGg>ycY&02`i%YhPsmcTugc*YH7Efa3&j~!_u4>P(Wqib98(5#DQCA{PgJ% zw)DWjz;C1*HiMzITy<;+b#Rn#ZIPS-9ZL2akX(`2q7Cp&vn)me5nqz4-=sbRnqLl| zteBu7Fw~l@y!+VoO2CPe*uW0jY!Ih`;X`Vfb{_&Ii5N*3Ck5c2r9m&$U<_Jbry+ak($m|+Ex=}_i}2X+g#^I>-fJKukmSE&3m58G~hQy3GRwDo+vl0ke3Xt4IKv9X3!J(y~fD z`v^ti$)!mG*9dW^fMoaWzyA8H*AnZNJ5Vdcj0A&FH^5@L_R%v$id2FDM)?wZ{-eYu zBO@cM6q4XNkEG(YB$Kxv^E9d86GD42kFig&Vf22xk8QcxszIn^$dJV?`o>Zg7G5MO z8=|tn1rX*HgaHM&x*ocSfUbd;;FVApXbM?RR$@WqL#SH3R<0VZ;J79?(oR7skkStm zar^e|VMxtWM<#ken7d8;_dP9)0$k?VC2aLocYa!_h)^p#q{xB`OonuYdbu5BF{#jy zd4wP(Dh4W`Mjc#KKjo z7<;s#^mcRFq=EQN4@Gh>ab3Dswk#*kVeAedkG2{qaA9Pm11XQV!*%Y_2C@SE#m{?t z?>yDtc{S`jm2;-x+2{;ct3j@pX@Ftk7t^x4D5NPxp7IS>^6T2O1n-nR@Bn1Wu^t% zt3N#P;`R$1i1psp`AjV%=b_#{YGmw z$)ZM-+Q)%rAiFp^0_&??L>e|(G7a<=899xY$nuUG0C0s#BOxiZ~{;__(7?8Z%HDCA{-{DprYbp3Bb@aq%QL#U+R~{F=$O= zNunLN(ImYxcJN(@$GZl3i5^`YVl@>XZ@noQjtQ2qIKD^ht&&|0|+-3vj56sD5lykT8qo$ zmJo!Vtp2DWvBQ#dw5dk@AQr(uRQbq1iy$nQO;JJ`$bd$6BA#wIaxdBN2vL#LLH6-&1;g$^{nEA> zyFwMB3!{-#m_%^#RoNMQ(g85MQqqrU6OjeMJvK9 zK|`)B$HlO^P*XHiZree))K1og*VmiHjFzBydU=$*S&5ZMBo|ut7h1iK)x78p054e+iav)KKJ@BZAekuGRHZ zbF-hFcUMv79!R?h8HLbv)gWvVXfZj8<^)fiV4k&@NXBfg#EB0qA94?pWOtNkWwcX6 z{Ni3*0nt3FRGgPWb`p}aPa?E&z<@hswyil<3V2KIJ+!7zpQ51a^hGu`sSe2IhGMHi zNQ+R8uYSKkO5+HjkYqjn<H}N_?&2o8S$m1CEuf0A3<378$CUJYsSrYd_8Dk zNh3l6!B%hB`2yWANk^5*WfhND+{d))G%l8c=XDD=g`6z5{ragqht5vMhnHQ?4nEz* z>+5WRXx~jjq2fkhu~Y%|e5x&}%$2OH7;;k|=(Wi?45(pv z7B>iu9~$)tLn2``hFO1g_!EpPooOw;of}9Wf;;2YwSfNp!2LHb;MnE>?+7`#eZP>9 zWFc+VHt4#8t^a#kZbIOO$4vSVVL^tP#%d&SWaoylX>(~Qwkcn74*{f*)d;oL_HElj zA^$4Gguo7@h~-*)mH`VpSsO^EDmEyx+;j73UAlCMI&weEe%Os#JI311TVb`z2;ELErA+MIDSl$2;ht_}1CIc-#O&Qr25>Lx{0 zW_-4@f8d#)gK$rB`c#m=8Fq5qq)DYsvwvqDseCz%kJHNJ&Zl7Dyph1 zJEh~>GgQ;xz3bM>N432;Gnf1_#l2c(b~5(*Eoreyn z6Nw%UrPC!enKBCzOvKksBmhd$On4#)T=Hp~_un)F+?b|X!$$Q+GYUIUu%JKz786Qug zHWvV6TLj4hAxpd5WH43|aj7gB3#O=ufp^@Xy^xl#j+99xCnZ4{@%RSaGjG;*Golm0 z{O7lw9X(%xJCl?*_jNb97A2)+`#^ID1s*T8T$E4H$Rrv-{DU`T#_`dkeo$Vd1`o>! zdPRav!ID4LS~eK@?$Exo%o7XMKyBd46*=ZaWdO3D5-GhGvM~)ug^ANlGXMkdPXIQxgmUq_Cq6%vnMyC5Ot79M_aw+`6!tLz0CQrp0couIuQz z;viXOH2 zPBn8qAWr%Zk_-Z1j+|G6N8Nxbj30;%V3nDN1=5$MudC8ok5RG2m)&!m5mGOL0{2c$ ze&QSSl2}yOUEkHjo_cNz33Men+yL92NU90IM=11kYC2-PD655DYd zHHrrV>r23Hn}g*4aj2O#!cqLWx-J=O(qHR2fLS@m{}x% zIxaq*AhYD%NX0%qT@UmlCcVvCl9&Uq9;ZSpWjTRyoro_MNfgS>&FWacYJffHVA;lO zz38+)@-QbS`M$*KgfsWZ_T6{_PfHFs4${fv0)fjPBaCYe~ORn{J|w zKJ^+lC|?nWJOFuH2ix(px@ZNlm1{OBV#sday*n1dhr(-ozJbj>pYc_Z+fIN8((R%~ zy#*N!NhQNW;-5b`Y5)*F1cPP(L)&_^<8ENjN>>77VQ}E6J3bs6Lezms)8`vf4S0rj zA=acl&#*e|aT{djB)cCwAURJZZ}!W2=RE?|wdugo1YsIMc~loJNl&0dd}<9QKG9$h zaW$!2fK$20q2s0Xqc250XBRQQ2PpFR*Rohts^mx>lGKg@!M(#_fRQA7l1>dXR|@gh zo~TJ7R*oYj*Q)ZhxtoIQrhSB$X0MW(A5u&5m@o%e0TI=-uO)CgMgYO+$an+pR*CA_ z93Z$I%q~UNe>HXsUM#r=EGrhoGJt>KaS=%)>0$|*1_%$@CPipj-S`V|{suYsfe3oh zzTh-6#Sn^qeSJLQ^+GAFtJFsUl|Er3hWoX|;plNO{aPB0(Nt1X!>dZ?EDlWZ9ywW5 z$a3^F*6~`5EO)g1mh-0~!sNp>u@Ab%Gqqaq%l)SJ=JT$|;&9wvp? zp2Bw4WC)>2uT0D@;G6=$ZVWW)3>sHitYL@@)n#aCY*fWnj%0n>7=AgltBbfhFmC4*p60+ z=Z2=I9?Kg)WXM=?{ic{OXmLgZL4Jxc^Ko&cY3lCic!5s;;K@OcFP@)*08sO`Uq#J8 za?*izk#tueAf8x?{JR8*juz++IF(u=!G*XPudG4u8-=RQo5nIJNaz48P9*G*oXLin zRQ&GUyX#idVj@=o&yX?~*e~%U5rmUUo%tK46;fZWNV*3;vq zqx&hDM8sn|eNkCislz!pr+sT++IZsxQ@nXv8g14=$bI0mR)M-h<8L@8r_S6)jJkc# z))vNiRFa1|TBUyqzG+HbOB4D3|QC4(B^GV=yc}fXdv9GkHWZ&OkGk#-0{FC20 z=pGhDl(UFu{kkUvmrAPO{!dGG zbllV9UWFHIvB6dYA?o`4IhZto)8=<{9^*JJ)H74$Kp$`oQ1_A5hCGudh3^3K>)lUT z0)7h1I&LhkpCkDBv&CdhMYH+R*_T|uI2INc%UfYWyk^VquTNl8S8uKj{9@#)TSdNl z*s6tQD4TMMqM%_~&_Gs zR?$CqzWQC^z@ZD`vybWgpkkuw5M@IPzxs-Kp?HqSgdN^2Jb=A@Q z+DAc7hK6%e5<1U5w8xk7?ojOe*#$;N9vxZZ<@Qt~!vnff)e;*eh87-ctk<_(-fHsC zv$_~&;clVEt{)H#VE93QxIjqbO2Md`t&a?wK4=@6&?)w)a`4wAKZ{#l^d1W?A8vUu z)hx|%n9E>bE^NYbV}jrP|2mEPw3F`G_(7BMvb*=C;}| zJr5^FjK89%V=H2BbK4@g<%Gt{_6yJd`7+Pm<}9vCcyuXdUWzwd`{emW<)s6I{72ar zRG!_FJ|*38WxdC6_L*$Q%;NDrmWfi+p%bE#=caoi)ufcX4KFW-M{3Zs>4+jVs2u5Do5t0shX0^GL5}Lw^hXK4Z`>6QB@!D z^q-hkHWv9c=3Vx^6vMBwsfq53jUFHW`F5j>mh*QLH4P~2Qx|tD4_DfC?w;1Y$kAY5 z!=F;iCUsp|p}R#`VMg~A?fP53Tbu^-`M1AXDP~S;`!rTHu9{sl)Oo?PMKY>x2;i2Nbju>W^Dy4#fC zSXRT+uZNFsj`{Z|>dzKDY^@6Tw4vC)N<(l_FW4mJcNWT#+$Rg32?{|DZ7)+4Ca2{w zW2N*XsTQ|ed^mnho^_6l`gC)&WPl96VUM1W<^8%OU`;pa>0=b`KNei_|8;uGLffUz zqb@HdlO@v0iz|5l!*#LuX3THR_{Z$fF01SFZW!MA&%b$`p zLxV}S5qHl-KT%Ykx|g>0)SmXxck||NGavP5e_|Flc=Sv4=TGxq=9gaM2qdC`T-A(6MV$w5R99 zV|(VJ>mtjO?8QZ1(f>U4Zs7z!(?FsN-9&MfaOk*w_36B&MM`rYxz$Pj-*oZ%(bLWulf?o1p@@L%lJLzwpoz#AF`~-!5MO(}NTu1u?uJ`?;&slNN z`d?brv;UP|0X32C^A>DBcV?7TR8&m8zCF{&znho@wXopt%l=|1S|O+rLG$|L^^xr7mRjI3rcV)a%DId+opoo5b>i ziJl4qwV9fmyQik6q7aA&fZh*`cB}~j_QepKfJuD@co{V;0oe_Jq(vM>M)tVRPk=5tdfK#&D`7W_A|>qX3#K@}4a8E?u7Qi<-ciYyvjyU!XCGC>Id3`+n; zp=QTfO>biJT#oagPa)JXvBprB2*L*%6}4hswHZKL+xRh*LaDahhEm?AD7kqP3W*~N zd{<2GD8*^Tj^cE=Seb863;#!ba={e(E)#Xg1X@~JpfImX)|U|w6zm5PNs4wNOF`pT z)YPPgGl|HNd4$0PJQf8=me8cA2Sdn-MX302P~ueJ$#Reed^l%AKtRAJ0iEOcLyjv0 zr~pyb5?qToBSAK!5o9G@9g6V}yhC8M*-(xXbQ!nmW)Ta%d zoYH-w$F7tz$}#mLzW=l-(dfTfziAT@!KdPI37`TE=5{za;yn@{lTCfVj)@v884-t`; zmH#u&=It3enJ{rGX6tRFL~B${wu*_08on#|b=4}9>RJQdt~p&ZJNf=jWyT+xzdIJr zD2!}J(G#(VlQSl6wC5RT1>$62G;b1Aaqs?(<#1TS5SDd=a z$H0%H%Dao)#L(h_ilCx2*2x;_?~ftPAX+uRJuag%NQe(E?i@Ig8t7BIojn28Po ze+Qzd9C*qKgl#N%Ds~(ofzeEq8vw%Lfz~;)(;ZlEPhNZEQkETP^?1d8D9#(w9SyA% z6?7EcZx6S{k|#h%7cZD<*qa@wNz8**>wj)+`w_VGY(H^hbP#-(fK6~aVj1udvbhap zQxfM!)!=S1i({nAp)W&&ywub=3z!6~kMAj~B!JzaSX;iGUuAB7sdfU}D!LRZOk;qg z12Z9avch5kH|L?YDOX`yq4{zTW+ z%-~A+{W>60aN15h9JvZ$ksv3)rTE}FBGz&|U2>oqo^S{zw-Hc$8u(A`alP;k=ii*3 zJgMn%-hD_xRBUms=hj9Bp0oL-+F`F*O8EaMoDsC@Px1Fpin0$-BgYrl8=ur_`a7qu z==&HhEiFyKVX5Tg5C|85IeNn_6ASW+U^2u!^Hj8Rx2qdYt|xv|;sXPqM4)_f?O@v; zTjycLA`lhfC1H6yWwva^&#>XqFC)91MafaQ5r}nnBwNV3q83YtlP3UDb zgygs;keOtnR7Xg~0vW{}QYVidM9k$TrMY$j83FQ$h8-1oAyjx}b@lWPLL`LcxVWW7 z1NjP%t${YFc*3`5m_tP( z5P`GnSYjOhUhmg)*#5Mjk7qARR8wG@rf|O8H7t8H9NWEp&z{4eid5Cq*m3Bja{&oY zAOjN}aWtSH;^PSkoS2xX`OA0(T;q>IZ&n#b(5|q_^0ZCizDrtfJ+VwAcqF{$_3P7s zH49N|VxggYry$%qc4DM^OzOnyi4#B(c@Ku`Y_`3tuUEnr7eV(CIz1xWl(Lf~{%Hbw z0iI-ysQ|X(ZD_ss^Jb=EHpv}6K0c(ohdYa+lxwgMb=K?3hEsl54=g zfl_GS$&=aow2}7wd~&J~mKQ?0k(mryBm}qalm$n(0|DMam64T)2ymdS@WrX=uN8$jDY4a4&?!V)_^Iw}htgE;^o(8yxo>#(2Ue8Um9hMMny+1z3O0&Ne>2~N3!)dWof{SRyJyrj{un>Z ztV(2kC&C*NR04q}sm*{c8e));@q;O?tECtk>alLetcA`VD=ByrE;)nG}UN4-?0 zI&Ej$@8Ga}J~cqtE{oX!zGh+`@2debBQB2RXW_wS?dLb6cvU#onfuOy%EMEFNY8i-^mOx#VYDNp6qezGdJEe4Mz z$OQ2#^Xe^c9cfC?wAoHiANhsrgI4z6af)qp=Z0-pwZ{r^ls!qSa(Agd5#%Gt=+{!W z!jir`K=^qJO9wwbvk?3Z9uTpAntDxWT$vduG3hv>+WaYMK3%JLVdi#QzDXN9FE1~} zG^~#D8?+_6<9KCj9w|~vziRWDz1G>AyKs87V-CAQ=wrbOwdkzQ zJf&t;*6k*lul_=B>LclPc%@9)R$JxJ+WX@A9a@yAw>Pe^XPzHyIO67Pcgth+JBN0?plh)*oqv2TC&-E0%@Fj9Z3F0zR`a@(Saas{2& z_NmDN5M6oPDw3Yc3Ui;HIJxW4jTHSS;6Hm3?v8|9T=AMC8AV$FxBpgKjiik6o|4!O zso%29=bX5-GuhX#H|s76ki_hWiLqh9lFn@Lw-=1L3C;FKAH^9zJ&F9ZNLu28*e9dk z6=z@VTg5W_@}b0xW@ORr`&~^IejzMHr;;_Ar{vQkZm{?M8tEr?++g^GLs{E|*Y7O5 z26r3MJR<~~^uzavn7okBOe$Wg+Oo=pA?oID4Z4R~)$_})1@BSv&I@f>OE6Bpw&i}*NnZ!W3-jOF$S6@r)U9`!3Wz4wQt|m92->ZaU%{$G2 z#$>Hz)`h5WhZZln)#2fJa;-9^9LAGDf+<%53tR#c?b3rznNX}A+@^_MYdLFF zXCx;#$E$K^uy({($l6_RD0?E`<0H3Ea%pmmlZ?nM6AdK+W5&LMxvNcClloGsDKf{e zQoGcDxwBc+moK{RrF}pb*N^oXewu05m#G}_65)&noyB{zmLs0L-eAz(WRmsBQ!xDT zkYS8}vwnZxVWl7`?}!8z3(Q?Be@OOgsF?GqT5l`m*t+Nhj+G_mXpEDj z!7Y`W%Jlka@>Tn}*z3pJuO#YdWdsz=l*Vin=BMQ7u+)u=hfb@|$=)@8#5!YUpCCT5 zx@2nl=UYaD!7D zANhhOH*{n6k2V^hfNMJATHnT#g>M$pT-u%NI`c{=_$U@L2N*O?p3Zj^$-KSqj~7{j zT-70Il*x(Xv2+K;|0g___;WYlv9B6A@$b9Jze)?Zcb6X>5}=Ftq|#U47w;=%KG1G4 zr1&a1-D!AtT-HjZfe1Nj!a!4Bt{RKDM0m{JlWa2uc`-r;6YXba7|X_nA|{{oL>axC zGvptU5tnbNo1z&EYiYVZ4{H|8c^QhNGcum5V>@SIiE`s6Ceu*W?id$66=O+Yio$q`9 zb#GR4*aP7tm5}?~0cKBK76c_De=aa=6_J(gxENyaa;`Ys<^5M^`rsqDKCpsSzc;@nHX~S<=;a0~SmUagM>hwMmb5(9=}H zpU~BcaXIr<&+j%KXuM?az&&Y~8_gVY){M7CtNF1{vUHj9eE)z)z78ARa?}ea%1qrC zlM2H>YHq0Abgl#f@zmEE36BYBGbBdY2>n*-o_6}K=JHNnnT`?!dZ_t&olIdvTk)Ga@g1P$l z?E{3wR|;BGzUR0Rqs%2mUDfg9E8-c8n23yeOr9_g5u6`Vt_psVs`J!~A(Kw|O-m*v z%A8`?*uxqh*b}J~Bv?XYS$wbcpW4k6owc@c`GQKN9AfiNi|55x&e3L+jaes@qYg>C zGKsRE`|Rq`{zl7(C(?_OGru~VZ6LI$-6lqUX+zRcUbg_V3;chWRgN9drwbKpqII5M zl)G`qzQ6r+xIb?Zo3=sS5YL4tV)e2IlO5Oj2vX={H`UL&c({w4Qwy(fAfnaPd7Vg>bbh8)5$lW4bvh|ZlS4I4NiVXq%JUbP<8rr0nW#M&@M9d}yVAH% zVCQgu>F_OI0eig>t1-JV)5;4&u53e)rKIOPbcjo z5f9w&9r(!L9nI_XqWrYtwZkrT!5^D4UbODtU~{b_-K?*!qVp0j{|hzWu7vZ{Ow;2G z!K#`ig*=&(|4f3>GuCVLXVs$o+gFzvcQa2!dOf}AWN$7;zsb1z-r7z+{pGzMSU$); zQY@zAWSH*ZE!N}>DY#SSL}lKk z<8v&sm~TnR&LMFB0Jqwq4#mN2_8(Oqeo^>8BZo)%McW$U)g2c1&PqV*S0Xl}===LX zU5L_|Mce+(Tjp>N{`zzJ3(Lr^yn>I`lZT|g?vOcdljdnQm9S1FQx%{>W#BDdzTPJx z67%LVI_IqEOjg-; zo~>FrshY=EtQ8(>uaTnRmu8||$ZbbC*<&W%#9tAxmKnoo{04yhd1cC+D^m7Cu@ z5T*O$GC)Ngn4_MbNMTn=Oc!*|jCrwSc;UIROY2qe>Y&6IBVy_DZ4b<@ihrd=UD^0SYQkMTlf6HmMM<^aHgVNE z_TCDgwwOL8)mzz|@);kml`(0kZd%LOG(SG)Lw_T3Oeop#zKj$dx2pJ5_Cl19(kjJ$ zdm~r;GXO2?Pp-E$j&ZFl&&^J87~vXf&sKNbG%WifJ@lTz4LV+&4mU76BsoTermr|TgM=5NI7!1>%^7`UW?-3!xk@icHWkm zarR7lIjfzq+hRHJ?bG_CSH~{$9GrB2JN(dR*jtw^*p-*D>aBD7_8C{Dn+?Yd-WA=5 z(Kr>bf%WZ|sb}7E7e>@x_-H%V`19+gicDrmGm3-+_#HDC@%Xq_>7Oaqkv|l$3UkKd z7CLFxyNNfh)ju^h9BvjJPPXAVRC;KaGf%;uI(P2oYr|6+zs$d6?+!JvGc&UP_?7nc zn)jDD-dG)8k<7eU@f`QhBQig4Ju1Sc=%!X*`Qp z+$%anMzTlp+z^~fP}r96zwY8|o8NrONBR4wO_EavuRxpRuJD!mWobiQ-tG^I`O;i0 zw(bc&Ng3Ofzn+Ut8NKF>e(HcpZ>39U)?&`WB+*y7KEmI#< zuGBE-Xo`W-6SYs$5)jf@C`Ajh)1U+N;8a&a2D11u>9Zv43^-I?&gb>Wq$5xeBAG6R z!~~8&gKT>9W)+0}Z8Vj?E-;YW2j%edU|-%D?{%p(dEM(QANJHgWxD5HWpqz8efGRS zO{9Y7&)s#ucWmzO^VM4Yq3Xk%*U@9C4RGnM2xj8lfT{wPRA|%KP5Wy0F zGv?UO6kr=U^0|*ayscPfTC|x@ozf^#PN;uSq)<0-Qe=u&F0<|6nrk2WQ`b4n9?2}3 z-=_lv5(qv8f>XD+3Q#LV1?gfj6zVE05A+{q?f{)mX1t0GN_d>YQd(9<1i*0eGRsja zypCQiV0Kvk>jrAIo@}n3Q@xp$bNx}h#m?y4w;Za6KO8sbh|2f9U>hE&^)9vL_>(v$ zt@=0Z4t1{j?{wX_zxt@HwJopawfI1n?zZrgUeks}#VfP+49u1r<>%2(rvBL<)fRA& z!NhgRX}_`B`V-7>#rxplOa6nK?ZM2&_HO#1nG+5RjTb6V8sLc4H0=lZPJfrS$5 zk59bsZk7k76SUTpnV41+>w*w1sHj-$=Se36`+^5F@2b$6$1FmJsTd{u8$sYnOsZRo z=X*l%Ae1r~v6-x@L63bJ=8w$uG+OLaT)%@;?)>=&@(^hku{~k#EB1&b+yU{Ra;x(x zyb5{fmRar23Ars$*H7w9kpQw_teREddlk^@y3L!Ff#SAhB!X>aJ#%5X2H|}~VGHrS zvqfuL+fqQz52T!1uP`8SpV+&fFDms&2U!kWC4&KySV5=(sZ+Z^Nl8jd!Vq|^CF7=e z{YdPIn_nIX(|Qje)bv>6*$U*`L_vrw&Mj^RvcK)Sw`C!z&;pWo*qz8Hp)(%-jwE~c)AcAot<0%!08>!4N zOGd-gMr6-WC4z@D203(*h=n1NXO@0^9U`%PE5y&AQ4~^*2ab3HqDyfQjwFN{L7s<9 zOiXZ4{98UH2pK1dQw{L$y7lW7fMyaET@8B;_--R4W;Es5WdoGNL*XWpNl1giG1o%< zM-WJ2#Yc`l!r!XswQYYB2XyNNR}I9<9Firhj%@x2N+;P4&^V_cwo_p*lz`QUgiA5& zk;e0n2B9)OF`)oJ_>$T5)D#7pPU30_5OHXm>;cN&Qjzlxs_vq9@6JJVS_AcU_rO3b zM5@GX2hAo0=1EGp1f9Y>44Pn@<%yXRj1agRDG=i9FisL50XVeUjGI>#_7Zz22x{}s zZ&wi#2R@x@V5x)v248#u>RdwRA}BpDIv($Ch=fv5b&Mmlnvv^3xruX~DEolJ5`r8` z03xBSOEpp^&Pu8|BHxIY2#Vi&Fd>w*>#9T)39!{^W)TBY789Wg3+UAB*i}ORf+gZ! z;Kc!IcVC?twfx%sH)5&?iFi6yr!4i33aXW;ze&a!3KxQ5Vxjr%Tfy+p} z*(s{vhlQA=Bul{71e;wj_?iy`2Tq(IfHoEsrN>)Y2=fUi1M%jLH7z0qn+VQvh->6-2 znKa&MIF`X#V&K;yL=YhWta2lQM<=Yc{18(N3Z|IPliAA3s;IQ|64>;kWj&y-6k$LE zV{J&nMR?%ieTzs{byK63rWjRb61#GFZ(FhvNTF)t)A<0Fp2+@XZ|N!*eUWkh41(7rt3 z{<3K07KVL~9h@mVZXPg=68iy`vx6}%7y;1jp2JKeDU?JR4cwhXCsUcBz$VIjGDbkF z@SApB#5na$yOoc52_G~`R|gf{i;1*0AuS(rXF$B6AW#gUT_?&2fX`vLAMa20%z$x# z;-QlGp%INr)W+CMDkd%P9~kJh+po_aOnDLm2nFZToev0*!A*QWNkUcFLmvYr`q863}&<*@<9fttkoxApwzy{jePveWL%^s|2a#=;v?txGgY2fKc)ihT7jUq6|)S61xkw@*=b$ zM0Y>jp3f~#-26CFfDOS9L=te)V))V@{>1ABHSu4TIKp=K!An35gMH=D5C$jpHB5jf$dkmxN*B_};# zb{0V(jv}cZdI;hZMCb*Z789f@6eLoT$X)!J0Z3OE5bhyIm>v8SG2Vg^j%Y7kLJs{m zWj*XeL<)|l9Ml*yE%kLO=NQO5!o^@XFbl2qf!#R@*TIHxp|OHMaK^-H1!M91Sk9xV zA~K@|zar?RZ=zr$;ygl^7ACS*Z`>8E{gqq3gWAn-qRS@W)Gak>X;svZoEOBzj0V#7 zKJK5lgWg_%7*)|hC#9xRz{b)cA|6-c+jK%efaC}#rKNddBgZH!@!NmL}HM}z%TJFpOCr)@Wpe+sqbi!GA?eGG^G z<)x8jof|i}h!hJ+0hnymFjO!DSn$afj;nY7?c8eePjJK0K07jfnIbD9VWFvtNa`U= z7)F3i-wam$J2R4?JyY-|zns&?M1U2%U~+jLwce;6b!>z73d?jP% zGDJ0>9lb&v>+tRJtV$)PL7B~8uH=toW9S(oqJpTx$aaay6dyl7j#H-=9+D{4 zb7%vsuK2-)NVY-ZPcop)Oh6NM4mGIN@FKp2AtJJgah@o*NS%vP%X-!+Hf{!Xn4eaz zexI#}F$2#Waa_YLDKVM}(U{PL79quBT6?p zY{x-!?WI_hW8rk;j%z3?DoR1FEX5>lQ?-)KXDP z1yKwb2m&Gs2$Icu38Lg6pn{?xqJZR}WkwOXBmn^>XONsOpeP6kC^-m7&N=6v+X`A= z|NXyyZ@lgveMcEe;NElg*N`TW~t#^JheEEcUZE{Yy5?>&ZU8Nn9}Gj>Lx` zxw(X&@=_pd$qAwu!~JB&VqYuGxG{@^0?ZQizt|*Xxnjo*^A+-`8c|hO566aDi)aLV zAVO*5#C^d7px?yLa@zK#r2TxN{z3?tk8Q4OPdxJ)&G`)8e=e(6#FZp*Xj z_*AaFd%wrI*sLocg3|k=_KSHhV7HWYo~{YF&R5&62wbJKfCVmx0T-{bHE8jz#3dq zsJwDs0n>$*0=otKp#4XW9%Tfn1YHma83jDTA?0E0@6ex&rq%_rSB@NU37L}+k5{o; z_v%A#E*D{*FvR#`gb5`n=3I$nUJ1OESqZlh@x?w{kR;&bR9Mw7^akp@Z5{o4%1wezB(2@DoP7u?m7i z?550!l4b4Zr@rgF$lKrW#ca=~ko_K>F67*Tj8DpHxEMRp;HNB>?;CP~CB{F{hKP@G zzqkahcUst@lW!v~pVj*cW(gtdwp$^&)SCW958*aB^l{E{va_GSbyUIaXx+8C2o>J? zOk0f&8#Z)<0|OQ-`cxT!)JOEGnVFenN)YxO+&VgekRYrWH7J5*|0p#OVFZce@W(SI zT_$9r*chrHX&xJFI!c(8#GV^&R>bY4B2uj^p~3`+0+CbK)YWlpJS1LH_PaI6G6<^( zt5Ra7m;ZYouz^P6gOvz&2+8V@jZpYvm=ZbRL;`|mGzp9@U%rg}ww`}Z$goxs&;AQ8 zfiwpo7F7)8G)mvwXrEN|gFQ?5&j@AN5g_AOmdlzQeuBVxbBqrpt0b}p zSH*CylVl|Rfs4B<@_W_9t;L5B7>bo_Ks~^&@e^|2{Yj@LM~SYdYA0s zELMz25BwiR;GYPkF5s%9U-OWKdLcOqBGD|iabmLrx?wr!FUPXdapFf1hHS0}=`K-- zu0)tl#6$%BQFzGt%k>S;kzv5baNmeqKEs@b6U!E-LrZEMCF^lW$EB=~h=56&NYuX= zLXsU4$9>{0M=gJK_!pwO1mGhNO|mQKxx}goTxnZ&F);VDA9ner*J1{4zxw7KQ?`9B zwp=7yA$k(XrKQS8@Qq5yV+HWIF)apCJJhVud}4NT2+_6+Ne3a}CS-AHIodK2*AL`4 z3ix#kN?CL)sWxpNh#)>k7}NlSY2gqMFTXCD0c|HCmtt2SihUB;ps@#qG`Gq)ipeB6|vi9n2Vv|x?AADX|(n1~a z|IN+KxbwRf9fFcdh&aJ3RUo-4Hp)bJIhN6c!f5{^=Fz%}iSK+MLR3e*A4W9Ucs>{# zK_BN?Vjt#;emrmp`UtR+>{dkg3pRb8K~*4SJ$V*bYvou+L_=%~M)BitHjSifB+DYF zFQ`U@@JUW}1V?NX!pem1KM3#eU7HHFD>Ck_F>7#^l6ERkXDebYum3uX<5rDy$iTrc zn$O|)-Fye+Y3!i{(hF4&@b@1@y1Q5(z^Qt>&L=w;2k6R{&R$^4JmId&JYbz(OqJjfa6lSCzKC(q9+|A$o%^i|#FL zn+I-S5|P(z)h6Q(?iXFV5e>cjsvtxEV&3ObvNKseT`Y#!p5x@0YmcWt+au( zPXSw2)7WFUcHW?o(5xGbC`0j}@E?-;fa2)wHM!WUN0DNZ5R16iXT(lu#>#msYYSWr zv|PEtVDgl`rD9Atmm7DuF`E#j6tg=~Vz(;DW#Kr&Z2M}uX{)V=%r-infJxzTY{B`O z1qRl$lW#Crci(+GsBHIl$E!olMEfGK5qf#z8=#1N1$&pMn*MI>=%?2rqYl`geWe_u z8S}hI!;P)bg@?wgof-}|nxNA@>b_B4T>(dyxCEVGjlpOV@;TygnSOb#I@muPK-&ni z8Iz#6-}w9E0gp`}$#m)SUI|wopF8$2$UOJf`-o}RFr#LsvC@VO?FGI(-p0Zmib@kw zpp8Hux&FT4pOVmDze7gzg{n;}>9RGRv&-rqh}7mtE&TI+>F8{}eWHRW_s=W4=U?vl z4F8rq{_hcQ-d@lf$ZRo?n2~&zC71O34$%{RqKeC-loBVD9h7yH4Axha0*i3d@2^#~ zf3rSWT;FZ`J$bIfyMro9@4Z;NN!Mp&=BSTU)S0ypt44+nI!P2yq-9o3jL=3idtOeY zMk64qd3Po-#fdZh3EkjDQQPigW`C-Y$X%Xyu14#be_nh3B_mS(*A2%0OH_3JUs~1d ze`%9$|4X?_xAI@b;|*Lrzi9Wo>wdHIh_q-&R>ueW;wXWMk5;Q}K8O_lR6D-iu^e3N zf3EC5Kf$Vfu{L*=RQJ@_ioWGepPg+rM(Z=)bZf=m?lm%x9al{BU8K&Krk(e!x9r)s zp>Fkj*<_)6f(MgJzvf7{X~#rmp4;Rj&x`A2Jg%SMzBngve7546X75(p za~$#k-gW_7mnT2F&&v|D=(=>`=-ssGtjr!pI2Ii=oEhKTM{{MT@EvSdxg*xW7`O0r zU74)>r;=K}4n4#CXVyPNy0(-f*_QGKSO&4m@M(#xiOA8meddM}zF~8AwdC(yIASys!XE?u1%6>`akb@)toaT|< z)jXzUhcD5-@Sb5Y_vFcne!`%`mv7N--#ykY zuvIQ=qOCvmczl<`k-S7DpQ#|F*RzFOYT+4ar${Y8KiGy z?Q6VwX!R=5o~S<-RND;NqEGmE74)47wM&_vaQHgfqsN$O>$9Qg<@SZsEw^G#`78FG zj|$u+*>&vo1AVp1rv2Ufxb}<4i&pVg2)QhcX!*=zY*>1T%`Zwe>c{-uUgmq`n?Y&N zz+!>6YVPT4P4u?qaSl>U+~HJ4@6d^=kdsMXWnVB@5L12G-xB2~1Qv@!3I+wN;%$?Ro=`o~#);DJtiT{!?@qqP%L1W;= z=zQw@^kDq`ZjJ38^2ID=(G@0%qqTD`8ZBr&s&L$T_sS84e%cS}&q4e)4+d z)|F3w3{AHGMhAw89mBsqk2>uq;#|-6&o!zBhQ$6Xh}_N%4beYe{m*UYl1u#(q#b%H zP$&Mi#d$U(^7{UUx_=5T57~Tg6Yxz9|o8ULSq%+LD!|J=p?brSqPw{6ND zI&$QXrzdpGJe=PGq4i7k8nl5kS}aU1_%fv2_k>L}Sjz$J&;Oi^7|eW(HU|yjw5E=? z3_CFPC;1~4$*I!#r4b;u4N_?cS}Bpk{M@5a3M z?@76kz>fe`*Z}&P)WP$-98qF=Zfs&Atw$AgK7j2+FGTF10JTnJ%%fpy2<$;lQE^KI z=O?yOz@p_39m?LNJyUGQ?m{7k^Kc9yo}Lh46y!e0I`Y@_vk)9KWV_8&fTWwa8wCck zBIH+vRg*>FNJo)(2!Sqk?mU9f(uMUyUS6Rt-=*WJPCUZGQ;(gd6fEw!@6ik+HNKF5 zfD9Uqm2&p%m+7`v&c23t6`$GKh)>+Sl0yb9HF^dH+(0QutnnLM1f_|k@av-w(Myie z#2el)2G7dpO*OBRVU+a|WwrN@)}rgcOOoFKBgqlq9&9nlMDWw+h@A}jP$$#(!H0^NtV06~^Xfgop)_e$ z2NXukrV1FN&e2Y%8n>6nzWt>l@T@IcNZ^$Lr)3Oj2FaL^6_J#qt2PN7B5GCjxO2-N zKlWtaToU8z%0R$JVnqm#^hs5nUs0__F%gP6B9I0MM(mOIuS-$~Z;5-I0RVC(~fqgj!>rLL`c&-=+|EeY?MQJ@SSB@fFy5*hnd9mfakw4f%*$D+8tf3`l^D0%%tma0JnT5-TZS`T!tJ9`q2Q z4e@18x!FP-GyA@#D!?@KW4Z0aYb)Krfv6KSD#)pq?I_9X9NE7<;h;sBd(63jvpSid zc{$`zH#GGbd8J_KJSNJEO9`j5LMr&l0ljwMxV&Jh*=%VZk#)JHfQRP zTnI@k$^>r2Zxzr$O$N0ZGGUm;x?#5pCNl;g0qJE%eAXz$%vcd~o-{Qh##QzUGup)J z9ta0{^FWP=wMIscXjFN@us%(R*n~s2zL!FDYY>8sqS#Bi*R9DI*yWtp+WF#WjQfZi z>xXk0p^KLcM_87f;PaI65;Jr+d(h`xVlNdcA5@iKQ^CCTu~=~Ksvb$x(?heNa1_g& z?&cnuK~_oZ!+_>g0JS9T7fkBFeE77GDa|LTJ#m^KFGT%|JiEz4UEK>PT>x+?y@Q!R zTS(ix@0x6EoZUbNAY~NhA1(Zp$kqiu5Z?rx_ejSob0Edj}j4(V%=$B zKX{aG^4)JG`U)Fc8BB}VD9~E%Pi8{5J=7bBNQ{jVBIj4h9g}f!weUECP*+%(Ar+rtq_%2sAZl`woRn^?jCNgY=pj#|WzfWK4VAXZ z)QkGE;2k#kejsE7_&!Gf?ZayQ__+$O4UhnVtp0PH_H{Tt2*8Qb@+Auh8z+40L;z-T zqxeY*O|?meq$LRXLbRP{Y7#5T;ofsLJ%ZaYR|Vt6))4 zLThc+B~%(Lf3RQpvry${7Znv9Nh(fOnFc4M?X`}VOz@tlsieAy- zj?JuLaQP(?Mxa^!!Y6?IvQprtnFwFTt9C&_LF5Qu6ZJMXx81jCEO;Qf`%{f%Md)Gi ziWrqTo3916>Q+)CclG>WCxNLweyZab%CIOa>VJOqR?`shTg9LKF^6V+jqsch#~5Hz zq@mcctICF#xC(+O1O~+Lv0SoKYO$goKrGXU{PH_Qy(b$$iLW&w8vwN==HLX2+Ehzo z64cfS7J(9_2P;APQ1Gt;mUnB)IM*#4N?d9n@TV{!Hwah^-7DdFtA;|8DnJx>R{W40 zO!2Ac2u}{JuF8UXyaj2YL+r276j~U|u}tg)EMAGZye$mc((LBa2&oI`C9zM14PfI@ zgB$tYCp(JscJuJ?b|Dw~|+&Tj}iO<<)d@*$7#gUc7?}Wgjr4_>i0XjtXeHdF zEvrm!vzYbGaH4$&p+R7FyfFXnd-B{8GrdL=vm@K&2{`w@)@acKn$M@fuf=r`D-#hS zW+dq;0w?h2&~(23%Y_k@t4-6e4mn2_KLBPzaA0F&yJR^>tl05P39X5^5uhqfY$XuA z?w}+qi!EKYj2Ke`>?b5Il*BzRJOyAo)>U>AMFk}UdZy^JA+r#kCgWKuB`aHT=k6vy z%9exn&h2|uUp7^PiUg3YO(W@3R*H_QKZ3*1V~^LhtAM8PeUxBP_Ql&M)ARKJz^1VR z?wGG*jvo%R~Wnxy%}eI<8vO%xO0d0;h|?f7f7q{qHd#8 zuv#wZ>fz+qg@PO*%LJrIhk{aI%H4~(l3AI3yZWJSh^G> z1gi`i*7D5co^j_ItMW3J51 zfm0FYoP1nb55#!Gu8m#p9=y&|6SZ)rFRx#DPmkjrrqy?1F8$_oF!Eba;jNZj*|q{t z1uZRKYM^9*zuU`~ks$nqN#~7oDV1_~bANDo@4-$soIQ*DkZNQ<=?(iebNte@{*=G+ zv~8<3bHaF}{QQ^eEt+SQe4My@kNxq{`VP~YJ27pKoaD}Y96#eT$1i1n;&@?huKJSU z4wa@OykS~CUf5?b+C%q-g1+k1>C`>5z20Gz1*Tw= zt5n?xp%V|UP#=W0KVos6uChovkbAX6uc|yCpY`~Q6%l!*O5qtg38yE`lnPa7Zrp4q zmQ@vhev$7zW<4Bb5_yh0Y(~#7RHx{ou~cdxf8In-`c!=4c|BKZyjd>YH_x_KSLrBq zLBz>qfXFLbJ2Gx|#WP05^yXED8WVS;B@2c%BNpq|mL83;7L*U-BG)UWRh0Redef+L zHp5Q;94*PqK0$H0BRi*ssbT8IZ9%&?)6_#;pV`$MKES0|*)#BKr>bX3g2l!4mg*F~ z|4U>`vh>!Umq32s-lJ>z&7iH9i>Ze@?FOfK%g`S_45s^zoOGI<6N$5Yu9Z$}uVx4_ zY_toYd6ZtKC66gp%`2H+yJ&CwX8y&+=4j68&&-a!qOxCB%|rw^jqOr&3-)Zz!-SuCeNHxY}5vYyxXDEp-pxoO{zFpt-zkJisr)C&nXkXYrM}jD3mQ5|twwMM49z zUY36u+-F#Cc|@g*MPkE`Q4w3EmK9`Dx~Ok5|L`q!MWuex}&<|FJK zy7Q7lXXzck(Y#&D!XIR5nQyU-H1vpq8veO1)ipYF>~2NMVJ1hlb-^x_XsZD2SAr!y zq3hHZ%5{Ex$Vb=6H8!#yqxI#-)4q1{J6_c~lVpA?R_V3!uCXD5yixln@7%2XEwn}3<^t_pJ4!rz*n6kcj_Pb6!eI^6n^EhdiVz{TmjX}M~K zB5^9aT2`RhdiEX{_soI;-CcL1B5WG31*$}ERcc)_Zu(vG{(bBQuUSZKW(eu(o!+58 zW}45*c>*G>c;~_iEjXuXFtxRY$rOK??k`wx3>}ab;se<3Q*J~c_cDJDd z-W3`W$F{cL>+aU(;OJX*W?*@az_d$Ht>k?0{BIhOk9)?u`h3lwxrO@)_PeQ6d9DZ> zruMeT84MIOS((==YYIroCzwSE2O5j#>3{h6M}eVRXy+rx6`h-2(S+izI6#!Ov}pND zzds?bL&5aZq4wXAtLHrQ#~o(G4(i!9s4^)#1w|#-fvhAJ>nWqZ-O&B&|nRvIRAhzyH-jEb^zuzW*O#TYp~+-G={{z_kCH zAL!k?7vCwCj+iy>PZ!OP40?JU-$#5q=&+0FZZB(={$3m?5wVM0fxuGtU%qhv?fry? zte+HNkiVPhl3-f6ZL9c(FdfOha?sg2<4+$@to|~{up(A@N7kXfHCschisf3Y{<&(p z=VNgagNeh+5w|~hoe@18ane-YC%i&&q{x?}bWKN5`QUZK@)WNaDT}sJn%T`H&f)0GMg80B#{9yR9J|Bk z8W@gG)0e)BQ7@>Iq8??m`tg>K@z30XYr;4T zf`2L>eTtjyO65CY;AStqHL6BYR$uX~x5!rHb30Olk8|40FwZqUy74%apZ2bY<5r%|Zm22J0bT+M5ath&M&`Rs6qk(>R6^bOG>VaHqo6Q0xx zn>g=GqD6V#*XyjVn5p`)%hRdxpcF)raw^O3dn=MEJ{j{%Qh`F?A6{cy!mmDA8Ls{0 zY^|rFVq1Yoghi0rbY{H|heqLHGpQmY?y?fmg!9L(WzQu_t+I0{f9%4+@J7$uY)j^= z*6L*mbMf1H{H-dgq-NNAXSjRkqzfp9b(;eA#<;l~exB0MkoD%1(5z-)il`Q_ZoBbZ z#>x15tKmOquf;px)8DK;6g6jTV>UQ>g*&7_jL`CYQ#%zv;bSu>xhL2izkf)gqh7Hy zB`KUgD}l?^UX4u}oATB9Zd!OqgRf2J9m6iq=)SdrI)a*(-wqh*+Ohsv7#6x}^Yq#V zCF1xdxtu>Jaq3=6S^_|O7Y?NV+e0DVX zVX6NlW(^es*1^LZgy4JZ={lH!5}|%LR`S`w6>+8U(llk+cR#W%6o${tA6#O}%YBlf+&^}}c=|?S%(+vRz4UQrdtc`kaewUYI5pR0 zypY6i&NCpZ*x6enqF|H|JQ^9cE;`jM`TUY8$2Fg=1*ONvxd$1qt@jp) z%cXrAb@lW@K%Eh<{0{ib%TuT3fIbrPt+~{yH0bPN!mT;sO+@xDVq0y5^c3+d1jSLw?g^RRC*!PA=7(D?#_f3 zHQPE&acjy-rk}H&Yp&>Mx$gC5-XvB1L)WOB2BQGKdOr23%IYh9{ZiecrkN6)RF$NB zrdy^xHe*{-)jV2!F#9W5Yvux?6Uq*ESFjysSv{-z#EOBcAoX--@`4<#K_k?UI&aA| zG5*Rb+H0Gg#O)9xh(m}z1vf@4^}#e114|#&D|n$1J~uEg3bnbV>At0iHX(ueZUxsY(9aH1cc6+l@yOa z*Q2r$6Lt{3i9{lG0Eku$aU}%TZmWpt87Zj(%>$9DZwS{MB${@efsB!7fXG=Xz*<38 zatF3dh~yyq0e7nUIzfmxfNR0f-AfP>u(IT>rlZ7LkD^!HzKsHa*~rIz$5wzhkq#<^ zYj#dklc+wxdmecK?W9$Qk2&GV6Wce4a-g1aWd)Q(nq`2RrQeW|Nt;6q50FM#_MKlyw?hgM3dyj$ zoN(F}McNT|b#;-l8p?JTCG0W73BeLfreGqeG|CuyfFqvtp`;BLaFZ{g<^eho1Z%Hh zrFaVc;G)SINk|YNd~M!tckV%-A=i9eR9mQG;^Ls>->9kn zI-S)G?4R_BBII)5`KJOQ{pkZls{oEAdd^%V z?PF5Wo9sDwuPAl|gH0wy1vnT4K#xMj_=iGKM}z`Y0ixAG;Q@1#OFbc!K;5*FVo_%F z^fb|@Wm4*bcnJ$U{u>#SuP3^cmU3fuaQIaFe1kU`f zU?swIjf*nzm8zbWxU(9qRvJ4#d6tQ3AxebAhY%A@9LX++UC9ni{)Y|rM=ttj&@yx{ z-RhB)YqKrXzin^yA&+CXu5~DD*q}5og}SA;udS~w?1{%-Nz-$GpU1u`C=U8sgL;f^ zlJc0?j2kvwwj<em9Nb-Jlx5pF1t4vLzCg$}OG&SIKJ3J*?l3!AZF`GyWo4<(yxQLvKPYydpjHv7OnBnUpSH8kU*b6+lf;e${*5H%(P%)&xd0t%|^JT`z*{QDW#BUkh(CI@wl@- zC%BCeoDmruIWnBk1`-80;^#^vA{D?ai6JqZXbDsecHnae;|c$ih(sU)g1pli-m#d3 zL6F&y!hw^hI5lu`(gITKPFx(mw>pd200hC|i3t^~H1rV?N2OWj?Afzkm^Vk0i_QMQ zpo_LVla`-Ju0wq_^u}wCPkB@Yd4{dFr>8P#^Gc+q^8KaC2O~g>{v}J7f|EVu%dG+qdZZNv~_Z+~UN0bOT-+kelD|_ArZm^-14t+o*vA zY{ioFs%U%;^`Bo^mBQtZx_nnrZeI`?&7H%!Ek2t1nY`ok`bnZ|)zZZhh}(LHw}15i z1mj+47#88|dLU@D{0lH-G>Wv!l0HdOZxA9Oz<)rWEt@jIiIJ+{a%(+r6a#2B(XQq4 z{eF5E504Pmu9DCzY#baARG#ZDeM6IS8_jRm{}IFB3U2EaN3_{0`CIJ&i^nmlr|6?;Zdu4SFliE9=9&l=(~RZI%BR>8mHFq5pBwgYSa;VB#L zf|hsNwmR&W2r~FPg2s92GQqI8QG=SiS|P_-)NQzAse(guj6@XdG98^nWT)k?YnPY5 zev$rD=L5PYL*wkmNsZFzlr$IX6w<3u*1uBiAN0EFQo-31^LG}MS_}?% z*ChmN8GThT(Fwo$?vJK$w~hhT%7T$qO7WVU3&|Rx%8ts+a|Vo#eziwqssnqfgpGO_ z8&|KGZSkrckA6iBDvZdl%5&{MbKFm+Z_m*)LQ1^almm^MBB!!imBMyD!(^wUbFeY& z_?rvx*ERa8$?*){x-uHH`X0;Yrn(rGEH~}UIuD`l*<8ljzM6AUwhQWS3_b>kL>Y8V zn{$t~thy`aJ)tneUDhUfHU61qMsyF8edRRovMa+i54vmk{H9JkWlAz=3U`qbph%^M z`tUPx`k#@l*mKR+DSST7RmYC;qlwnT3FP=zUh6K}Rk{o7j5bDj=zc1o{Jk_$ow08U z6VDN{tU5*$7Lf_FXF1z@KdVJi0hG&?n*xWTBCZ%)yttkw^jRau?~B9j7AY4;DVq9; z)I!EG>OlIXj5}Mxq$E7$lpW>`wktd^Q#coSv8>lEr)fUX^P>5cw+`c8Df({AvkYPD zIplUOIlOU$lvH9d|tZc?d6PON=unP(~S>S2|EET#!prQL)R zj`+Q#c<8dZdf&Z={b!GPi=L^f~-htP(ehGfc=N?e)%3;bhx)1oM=j53%TMLnGVGf- zWK6cbcSJ_W)wJxeM2x%J!}y4db0!~aX_+TZvqX=(t>kp_Ib2!HR-R(HL~U+M7=M?X z%}=xb+hYs;9+kcxCk53xYk|Py-G#veSH`4u#2@PGvO3zd&=+dnvF&qhy<53? zr;V*VYs@tl-tNxIscvE8cx1j3KOr8v%Z@rXOC0l}i>vt0ZE;jaJm;SS4kwc3_qFlY z6Zy|=lCYG)f2qMi?eRB|eNa?X@4M4j%^zgz5&K=+i~Mv+R-NQ{LV4E0h>jAeW$GYK z`sek(i(mdfu^IpSwywqfL;WuyZM~i_@p3_+hG<$#OA7&phZ|$h8CKJ2c{*uU)WYHt7ZcC zYzz(3a;%eVCz~F^DrkR+&c%!Vd$luPcD*SpEhP=MAk?0ITt2;9CXACBsGwO_Xt67Q zcK$3YE9;e_v@tIcog|4(d`JA~H@yzy&-_w5w%P6Zi^)J~lGWZ0fu>E97#qOBc`xMQ zr0+aJX%V~G6iDiK6FYC{L6F5)A|8EF--d%qRSaXoOZ3k~h!++X1}FKUU4D{q20(Dy z4Wop19XNM@EF>5AspE*T@vyiP2d)M7&Llp@K!$=Bq6Bk&r?C#_t!{@UqNoPD)Q zK7PV#S@z8fr#coGw!L=NVZYLobmsVR9}w3(kXDnH13VhZJtJ?{Ggyvx>Mald3$3XNr4dqRZrXcS9_%4vO-lY)7 z2L#RA#H~Vg^QjO}HSBV_nf3d6Pa*qx8#Z?Ka7gN?*P|+B<>X4*!})lPaxNQ3SDhM~ z8QoDIx9V0h8?#5)vZT07O3x z3JpZ)jIdJgs$>!*AA~;Brah@rOYgqTcsGvOdIo}3sPXooZ2)ayekRRoEId%7DH1Kn zk^2zW0!Y)kU|oZWI*pc{O2C>-&n#&1%1aj{fWtU?`Uf-eU)Sgnnq2ob*pgBTh+U`L8opi(COi6n4S8L-qQ z%_HknU{ipc^)GECGq_8(d4%jEF*m9J>`(=u6IsAT+FAzz`yp|HJeJsDx%v7=lP*IT zP4eyk0i_9loP}8k@w~?tc9l561BxjvEp-Ko4d$fU>({TTq}#CWWCtJ~)_SY<>qu?j zXcRp+GtongTfjdjZ8b2#cIR+?#oitcV3PFlApLFN$=_KfCs_fBT{O_e!ujrY z=6mT{A2gny*mUZ>=3XiRq)Yb3p zHM5>ctgcptBaUKe6wBVPcF|)#b@7*yjBIQ&R)vzM8TuLA%Pk@VNprDiM5o zz2Jc?n)Z+R@qM-oHBKBKpXum0;F2GRo(|-cz^32c0^B4nu3$2eRg@%`ViM%6xc3+I zN0u=;O8fhx!Li~xc<^8u3zw~}En$a#r@{g|IRMn#yADuO0P!G#BM+3?%IIBSn@2#l z+GL{`)GQ1zKZsM$hCQlqSpjv8s9A|T0!xk=y-BE4s%dZaer(>DKb)ML6oMj1*rW*G zJ1rxlC?(|pQeqg`Z)zZ~DS;)*MgbiS&AVRUrXit^0dms=ygUs)MCd)D6fCm|W=mJU z_#bz0a3CHiKy$_ydh5+qE7}(FhB}()7IW=feGGI=8RiW;cJKZuZx4R11wbnh#|3d(oQFaZP~>e)(o*c@AiZLd9-w}mfbYa^ z?MyLjM~h#fy#;(-meWC!3(v2QkS;j%9JHSogDy7!UvrN&Uznc*8GUG~!*2wDD0rMq z?ZrNY0FiS+!Q4}YepXa)$K?Uj7KY#Eu)RD)FPKEf;4{vqY&P!RA!qXK+l4J#dczc7 zi_u;1;8ln{zo=nzwbR>Qrq|gHY^(dDWqxgxenkOfxHrFjYhrNp`2yN=zVohSU?sjU zEL{0$Ms^;Y!*8IS)?|tvu-&?KYga`C6R4I!fNL;2=sx4-P-NIGABYUqY_@7twr8%} zwtOaZ;_N^`!%3Qx70Z}Jx2=q|cD@Dt+#dTH)u%yzoOWJEN9SrGJ_$dSFe`7IfsPp& zkJcSOcI?=^3w{3w@uE$d_a0?@J^f&tb-xMymV?i+ra5M6QS^|5&0JSwmL$JkaVz@E zt7>Tca#KSDzA|rsc+vrmKMLQlnsjqsXj|x-l`EfZ7Q%nmaTCA3U*Z{)F}woDjA0Ncna1P9Ye09N^0ub-+w;@M%j^ zERkGzSt2p>6!U@)xG;JF%N#mH2gj$xdo}P~Bdv90{3OtHV;TdML#CR@Ew10q1&zU| z-2@A#HhT6)b%MfNTn-QhEV`*D3`sT>pW|iTV3}7s0Fx@$J!*b2t;;`5-&V<%mwp`n5J_L47}F=3(Jc5;%V)}Hp0a7<4Zg?4)r8q=O#VIMB9 z_ie_Gf%Dv>iDhi_%fe_gs|WM?#kYvqxVFn~F8iFmjjko@#izs`p%=5qbFZIeTD$fz zXuf<2OJPX1cCF<+dV%< z+W_4%>`{FV5x|X}N*?SeL!+Y!v-eYRzL1A}@%?WY!7!zW4XDp7MQnTk=YWN^^)F}8 zChyZ_ery77K5fe4N`i%Bex)BGDfG(DFZPjZusgVY`)!oY)RdQsaWz?cm5PASe;U&W z7Ca9xaSMS3PKWadop+PO;9N2|G-P?vvBV`Y@s|I{>y{`@miM0V9aqX@7d5QvR6_S( zj?MY__$+!MZY_w&B?L!biDDUg6@Nsy9-EYOx%Hznvdl7=MilnIykzH|Jx}r0CX<5; zNn^M!Zj3P+=t-cf`3>|2gK1vAkEjriRAe3Z;8A#9&^z^J%Th=+7B|1k#oiqU+Y*Ej zj~yLXAsP-wMarFSV|HHNL!9!^YAr+iskfm1PQe=xg-JfRf#ff(UwZcrD#!cGdR0+j zLNX(a0DT$xg*wZkX1g*g91rx&0>7cN&#@|;U+ZtQ%b6Kq?lJC5Ew}n=8C~+Ic zoovJcX@P8Co@;c0J5LcR+|?s0%_e_jX$|s>Fzr2EKAy*tj*^rsugdsw_uZKKJ9kpA z!>OqdSM%GdRc)xwq6OL=JX1I*d_>K66Y7xfw8$ec_2;)a4nEZ*ewziJZRQUK>^<{! zp%=P~1%hcpeY%$;=h(3meEOpY&Ei#UGqyPPu(AH;^O?ximDbJv~SuuUv~MT zp!byS=#4mt-kdM>Y}rE=uCvE6E1xFsv!mwNDA!tgUX|9vAJMQhEJD#t4VikG=)%Lc z8>WG_>6JFgx|^`M9tn7`DpWgp!!3iVU%wUmq(|1pI^)xDccY!-W)B|HzTe&V--ZW5 zve8M(Hv$1X=JgRo9eio7^b_i1;nkP&k9;d-nY>gal5~9~yLM(uR7QzURyHi4KDzU4 zu5ai+m9HYkpQ27K^Uqg4eL_(DBi$4aa+S9=lKrbwEV=bcd^)~PtyI<6%&Gc{?)VBO z|HHJ}>YAEMrjIw&Zji;6eXOp_Gx>)?@ z{2hO>kKxVIdj5s&``dk7DW7_A$$V`(Z$P01yBoLiE3Nw4O_~qz#ZwRVr7%qh=$WqF zIczMt$!br4G=BV_SH3G5o1b5&*n`N}|MX&Yk^e?^-5)P1h4cTZ`;)p8^Yhg_`v2wS z&{DFia^09UPwZdli0YM0@jIF>7%O)Ex2VE{7i2;>pC0$(zfiGyd||6hw#Mn!P|0Ue zn@`wg58nA+A)r=04(-!j}YV4|#r{aSJ)0@-Y!||Jzn1kfqb8phAm9b3Ow`o2+DkbMwr!PF|s^i zxJJJ;QAyxQNaVbBwRd*?p!C;$;x^4^{!rf45pP!~Xtvbo{8X!XX!mUFG4EQL>M0(U zsf?0VHSPG>s>Ipeful8%OC!&z?;ESCkkNN{GPcb`bm=!0>AXwOZMJ%{C7r=OMK8hd z^@B;8(nY6W_VZF;uZ zG>kCPH*s(J+R7s_Dg5*olZMw9axTv{rk(n<{DF{e%-dLA$yXoUoh(KYt1M_^s&?__ z9NJtBi*(+qa2ii4*wiNJl|K+=7#q?vrt#Z+Nm^Z@pLB8bOV#8>eGZ4fs@Ly-tUU5J zxxpju0nEM)OLSf*cPclTXHH)GMQ>p7o2qL5j`f)(rOwaXw)Kl`wRVu4PdP<@sF$sB zF3!UyNG#v$j}rfE_b>-)hJYBJiFl`Z$sN~)W(al zSEuf7c&fZ~_;PZvshxGV)#VUbHV&zj>L7&=KgMf8yhMDEO<(kh9rMT0iN@xRJKQcW zTMP!89qQerk7=c4x6OXw^jcC}Z#(J#=J##)cctFo6mdNMBE@E&Pnh<^Ip@Zsp8R#Q| zP5%8ag4-N*Cks}ZhIfC;lhtIL{c!-Wwk)~UwWUaV5zCu>GuC^2`ClY+wQ{68I`0vF z(p+J=M_Tz)@X4Az?|tVym(1vYo3L+Sh+7tEGNkY4oRBpkE8#C}59}VBJSH~&yYz;00TnM6l^6Ie ziGBC_{4Hkd)C(@T`dA9{QSD2#gUUO1b^Si>#UdfId|Q6qKG(QZ?v(;gO^_bP`2 z@-N(axH60P!l{KN_i_)ZD=3<@uFf6v%>C>XvQD7dH(ljuHG`=;_mH)KckvtDa;A*o z4-A5d&)UDfIlp0HtCUQBp5v9~uWML7H_l4rWYuK9?`6NPEt1V^5)#K~@jS`+IO}?c z$B%ai3Ia)p6H5Z@%L=%cHn*am8hQAiJ+d_`=bP47e#3`-moDb=4Ga}lThn9q{N7ivhkD`9JRYrF9{_1W z9uIYB81(U8xs7jhmLb2;L7ts}oTWwrqVFc8g}wFZLb4{5=``CJK_qc(a95|h_wb&YI~7aCS|!(GjD?LJ@RlSn)$D8 zVLM!Hz{hq)ki&wL}c z>JIn`uxMGfF#=?K51eXI?3*_`ApS*?locy-@uJSug(ZxUPY}!NbX{UD3o%WF^ z{GyZRDX6o0th>@*X(V|cEBz7e+s_BDOfU77ZP}fFk9L{={!7E5s1B(X35Lgq zE*@V`+qQ0cjkNpR$9A5&xAajOg4VCc9I=Am1SibK9hn_33SKa5ye9Z1K6quk&DS^k z*;mIFwkC(j*jYD>6rarc5HiVia0|Wi&DSw+4@KrX?2q6b)-3V=5w~RYM)IZIX5w&P z=^e8Fm5u_SStpgEr2;Rl`Pco;1=u{umL6@8==13P5_=?lz&wa!c+m=m-Cysq_(jBZz6BGi=+lV?K$8iq^^_)dpT$5RJ~cgUoX?6@Ft2V?CM zl4h6DbgMBgyg8}@sV0;z7U09?0tlq5j=QuHXm;Z`E9wm`AZI$w^{$`pcuz4Km|7{6{Kw>~O~>%avB)+)*}!|Dp=u>rZ){=?BVU%? z&F`Rk6lo-zaPAR1t`qVQ({!;p$6TsR>aTiZx%?mQ(?-%Z$gUe_*VPf&7h`bNs?wAa zx5V_DZ_k!B>D~|0sOa~4A^Y|scpmy9B_#+ZtZUw35|-WC_D_iIJUY4#f&FmY6Gun3 zJ$pV4r@+&kDgkIA4_E+d1whA+g0T1a>C<1E|0n|jm&b zIgN?}a>8a*FZ=;l!35(a3=@JazRpYxD8SMib`u*V?ZjZu5sVLYH-M*c5r$(UphS4H z=-{dz=bGKPk$p{&AFu|vkteTR+XM8a4fyxyK%*aUvembN0I%D!MFv0M^3_~wKnH+} zjIWp{5kaGJQici0VRwBf>DsELuWWnVtE;PTw07RSTs-CQc|C{lt!aAxj!!yu<6Du} z-rrE5*uQt-2*JaP2XC3cSx=t^Vns%3B=6O1|L1A0r5N#kaWBT>6t$fe0P5i00UJXg zg1gT8;k(t|eV(72;m3#{IdTNn6;l2q_85Rd6e6l=*5>{_@H3PFq)KNoR-s~vUur2g zcA%W_cp7Gmen{g1jZ33&xagxBHxmZ?66)rP5z0ItCP=$z8TR!!Mm`i5KLc`J3br9b z5xko((ZSCn7aj=vX^US6J ziz~Z}i-m8RJFTqUx~2E(*N@)J72)gFuFVe&E1T+Q<Jre5vlb zP&gErwb5C>{nrSaB6gde4*TYUhLbfLS4~!yI5n;bp^Hf2w=KGyv@PpHtk6hMY6h?r zz8l{T;p{pF6Pi7JFlsl6kQ0t?hv-F;UU579$3FZwvUiwRlRc7Rv<-iQNU7D7nR|M=D#l zh*>-%tfg2eBt={ z%f)j5Uqu_PRnyVI2>9H&VdJ?I>Pf1neR7!mZq>EwCK>0_zqoR)n6o0_gQm{Trtqd5 zrU9{+I}IajI8MJjg%!L0K5hA}fd(IJM@<6*MpsA1$1lTZWwfu(16Y$hY%Db@zW zwp%K64@bE!sgh&NVvMhO8uCOloOi^zltrwpP#k}gO-C{ zAJ{2`2u?UNL%co&s#yQYXQ<4zrE15tm>~>+{R{`i8gT6Ql3Q+y#n4VzS2W`w#ptot z?qtpehz^#)1w+Fk=z?yVnFZtjD7l=PX#(Jmzr$BS#K)HZ+lm$bDAmw!+_(KKG|3!b zHBX@q9;O(!4kj_!glmSULF>3$5zdIS!5?)1ceiw)+)@={awBM}5Zu|&jD83AfT@?^ zVK&%KXaYvf!z0@$aOI4D?Ci}JFBx{6-T>oq{yl{>35A7Cg}3mg*n>o#G9Pfj)5Mib z!m7-r{3ePQ@7}#jv6~ZtlIHZNdhtVU161=516{_dSkd4AxWrwy%T|n=$s^;Xi0uPd z%8@BAonCW|9Xt57<+T{EdCnlr%x`{s?|a|-zV7R~ zgtOV(4#vZ-M^+uAJ>e@G@fDca#+K?#!8T}prtRdr69n*~GTQ*K^HD6gytB5JeOMT0 z)SrW`7DJd^?@>l2C_Dah`|n7{VNsk8Y}|yBwB-697cSV&=VS4HP;SS9T@V|x!P-b% zG4wg4Q81S0{;PEDzZm*I3&0qy_tT~s*!CAU_8)x?gi#I?VwkU z&E6_21>s;Jy2U5m0XG_8x8PX&_eJT!Fqy?ABrd@|$_UaYdaxO}eVeE8T7X_MsI7BD zK^joRmk?(}rw?FN==Wxj!w#W6>;*;uw%N-(7m1FN#|`G0M*4DapD z+BTJ`@7`Iz1P8CC*=v&*I8BB=6&7+qch7ZbINDY4v7e~D92pfw%wa$Pp+hu;C8iLD za&@h*?NgbRM!Q1~Ma9aHr6K}}&kt_K?IBVDVz9ZoxERu$0oIh7U>V_J$ted0xNk8#c7lNacTkBC2tB$)4ZqYxROua`e%%D5| z`XEZ@3)0qDxEHk09)g%6^UX_;CF@!0)9M0TT3S9*)l=*84C5&MwV6AT+ur-m$BQyW zzNB?e9`8AH$OSX1zkUCn-_hR6Swn;Ap=#UW9Pj5-y62)M9vL+{V-_E!g@rwu8TI2U zl;Y{;1c@+gZ60PrfTJVeHbwwoLn8x|>h$%0Zn>y5Pc@V?SkqxWU8kUs0#4>9clTI8 z1MjPq-DG0Q^kif};1QWmoE*AjnVQ|IdvdJtZBk!!KXCK@Bcd4Z2Rw-uI0ppAblfZs zQy(pFHsG8)6FRn-|7xEBPiKVp-U~~e%4c69%Wseu=JdY2C9r3#+D=P8@6PM|8k$rt zQSgOA^3k55$_wneV$-qHfKO`xh6QoX_1e7`^1sW%^&Z-)pC8T_$PoYzAr5*&9S&0S z8zfk{$~0(Ph@xv$v^Ag6tp9vW#%<@BJJV{he2D8Qbk_tI?|Hn)Z7~~yHs2B`6t$nn z`vg#KIN3~qnbBlx6xNUdn=wL$7i?nT_RmPi(?pHY-yxRL+jT!I{rEvx)?o9hrew;V z`nip}db{COMyDta9Xl7sWru&$bdgjm9)+@QunBT=X|9&XP`&|OeWDp^g41j~}V>BTpY?{tIQWRZ`NiJOvSNeWf>5 z*Q7RIq>1HE{I*q&n>eW_xW(t5sAL)HMD6-9FG`xW^iAW=DX~fO3_YZVksWAnq4@6^(le?vcRae%==A-|aA=B3b<>8I+~3zFDZ9rGB*6nT1Y19+IE{NK~CtoiRNufKSmvX5G&J;MJ_g-5@alF0hc--f8Xi5Qyy7uaOW`kyz= z{QUVh8bq}Pbq$<2+e`$-$j!aWl=gaUvts|;xWAu7mlFNC zip?ABuv!<{GkJolS^J#=U?*g&nqs^S)2jy@>Km~GhH7&5<(yW^_?p{SUps*H zXXdq9B7{(F)^>c;_Y1p$c}{kvdz>^*_?pAs&+kYu6-fNo4Qx=RyYXPl+kSZ1@XWb`KjfKRIzxu29@68B!FJIJc8QMt>5!@sE z=0e60R_RRpM7gZ`JIYzR(;L%QGO2|mY?G?mUtzJI6XQJDCRHZFPZGTyQAeOJyjJLv z`>I;%3VB~kYyR8j!z^4K^Y4{b=W;4K-pOn@YwR35lJ%`!S9j_Q9pA&7cHuL^He`6-EtA^-H_?o>Bn2fFsRUb(5usK!Xr(yOa4s+!Ac+DjiQ*w@e!{R6{$>(itX$9WtXKBd>SSjxnrG{ts$x-y3&+w0V)0#L!L zW>b|nCpzER=AsYV!q*DETI3mqM+W<$sNiPMe;mOji~lS<_-1b zb-}|o>81w-9 zkYF$&g)tuye;Jp1iZ>)AWYg?UmA+);$0=&u-V-#>LpWMH6~50pj}y|*c|s$VwjLY4 z!4+AgDP>vQ-tFbki=WF&^{!8`=o(4$H|h7372#fX6)WReMHk}~BE2sf-YsCA;3sv@ zAQegSuVmgcOIapgSUUXQ&b$-;; zJ~+|3nr|Mrk;ar#*dIgtXvXbSno5(*?Miyw3PsWPPZb8nUtaq1CV=q6;P`PO6XkZY zYf@a;tg;3fk3ntKHmA>^@1D%`r--NF##sfNiUSgUTyD&IRg4~|M96jpcC{{GEMppD z)l=7cN8-o??R+s@DBZTb(onBwDbE?t*QB=UI+lJ=AkMB1K96g3i~qFx*T}5Rz$$e~e9rr`?76|b>9$Ha-&~iP|ZmPv_U)2{2+5hM3 zVV_La*lWDa+*`g32d(kb>fe-@ozF)J*tyzWJnH*X<6JJWMe}W4D_d7owLkuCkonXc zF@1gdz%usa%O4U-D#?9j1Zr)$%Y*h|V;kq1)KZ=X)DRdF&;iT`9ud%=|;}OPde|VnG329(n^wl{N7^V;;7gf zKeIWss3=#yi80~&tg!I-=Bdtv@^;Au$@-)5PTH-vkQL3KJO1{Hz?kM6&$AZk61 zaxK~}+Yqy05Aj*nZplG~^2HLJIj!~TP#4!oQ#wm;g&)o3{N6#@k>;|Gs4iu1^a~cU zy#wafe3Iuq%YJ^BeJ&l2@_Z?MuUKJvj&jF7WNyZ=Bh79*Pm<}sLO=2J$MjXn@e#Ef zj`pGIn7PN@_hMELhR-y|{R}_e0V;7X7-*jFjAs`DzTa z&*?&Oh3~LT+I%eJv6t<$+2$tHzh$5Q)?wt%JDm-M+=cI#(}oP%V-wkg6(VoB@EXMs zeyvELWYLRvYu_j^@9~Xs_u}d7D+=^erx?BRW{>f8?9ytQoOhXX)VId{D`UOOno;uyeChceq8S7pSw-|h)45Jdd1&{qDV$G_ukh}^mK zRa|DX<)po|Tt^QzRPoD?Vf3Uz9cDiaF16I=n%gr+-0=Er7JT>6=?-I{=LkP8=8fq{ zWOgt=Z(){+$?J9UlL(7B3keg}=BMPyrhP*~V)h+{B{uiikI@2m}*93&V*S_ z7MI&XDojaujY4uJQ+kYho*Cf8mlN$G0P#XI;zdAvso^R1UtBWF-pnLdTORKdK&m%&ini(zq=wP1@A)W3$oPy+CxWLqkK%MDtsg1A*1qgv|zo<+|i!VLb@lT z)TH_suudd{uDJeQY{M#4g*w=ef%uRN7S7P!7YFUD4clO}a{&}%wV{xvA1=jM>Mh<$dA!5dT4V%`(OU4mNO$ zeQRZ|rOk7MU1N_BE$Qg>9p(7ih{w$AFa;gRxjkaPGgv=Yg56`9(4w*fWucX0&bW(L z_Y|4{jDixjWXJOKbntXGD4N)=j#gbifS~h|(~#(>87vGD2GWg`Xca$^E^L0Y$ArFGnqg`{kWc=2V`fStBGffzf9L^WtSNSq-xO@=qa@@8TTd$J1%i(oR_-gjiaIi6vAbAS6X-;%Ar? z?Tz52mo7aF5eZFYT294*R|X%P(d|5uUksPrOq=_5pYpYlr`!8E*1aX#08I2w*+x5R zjkW^s%K7&Qm03gPKMivr=Xqo!dHN3u*FCqDBJ>-0j#TCI~jDxR)O`| z`XO|ULv2R4IrP+TU0wY;G7^og%0Wf?=~lX0Io7ex4{H4&5&>Q2_*mdlbH^+O z1#Q}Jg)lMzV7#q*`59ObIC5a_4A6k$v`B*^-<;l}=rOmMKX%9f*>`$!PNqyz#+!`4 zHnv7QbZ6@^Je2~h-(j=r<$Cd?&PLCY3f@KO_CD!r5O+qGC!HB(sRc$Q|GXJ7{%yoo z)r0zi{q-CclF;7#zxWMHsQORyn6_DYyADe}Q56NZ0%oWq0xwA#=t(Ky=mw4HW1w|0 z#{0Pg!J7y3t=l>?I$YU<##$bpN-pNaRDAo z0ykv`aJqE>X=%3%b!gKCewHTqpaGn54tzl1jr`M-z(oVPFg}3l6pQ1&B@n5Ea&;Rr z>!IMp0cFOQ;BM~;R0gQ@+#5teA?iLd4+v30rclQ-=t*k;j5~~7{~93Dn$SxD)yaDE zWkaz=OeJMpyU5Fzx4_hI#)GNiC3rFTELB2(HSjg}pau=`gc+bhPAnq0`SzQU`DXEv5{K_x>VM{gnFBv&H$qJi6EYR2$VcG1G}M_h0)Q6rl!n*n$tiL z@9g>Wxj<{A+KK8ou<2;)3oz;@x%BX$hXFiD#_HQAnj<#|ztBjT&u>A(A0Vdz2-C}PC#&k9+lH?6x1Dq zu>S*Bi1C@3kYo(tmKX5wG@;54P1&T_e*gA7eObUXp8?P3+7__%?qcCwm>xL5dIDeY z2&$b2ua{N8+xF5!D2dIq#b6QWE;Qoe8!QbaP8c6d=UxHe`xS zq%2z4qqO$vH$*iw^w~amHXy$TuHt?GHq+qQU!NZ-=Y?7K4THgSOfN09fP&HNU?CF( zmjpm(0BGTtwE7jxK7Hc5i0!ZFFf}#(3|d^l!VUiSWF3~EJPUlROW;s(>&~5bu(tSE z_&yW`6@v~D0Rxh~!4ME(9KhJFlam|Ue*gY`bYdbP8Fjb?9S0>#wt&&Q_ZxxZD1UGP z@TN{Z2g3^-&`l}^Xc%34`N|b)Y+Fui)Io|P;D@__z6UtszmT2jtUUssj6ImL{S}V9 zFzh^MxX^`zKyo@_g*BwU2VjmE<` zn7z$@ucxzmmcR`I*HYVA!`K~SU($DvO@eTUeArMVPS|DJALKd_w(9Y6%p1`d|!Km>=O5b)(?*=wP}q}X!q8q8oU*w}uf z9S52cKw8ZzGyh}}` z2l}2v;(?7kBcm<0m81}bp@#OOVC&p|EWqA>Ef4bIYd0uhi`LW+XLI~-Jn`|+{3@W- z0YJ2)*|xBN7oL{%>Qzpz_`Z~#lJ1=kS0*(=s%SFqsfw!p^>l?3k;z3pY@&4W#qY z2%HCw)Au1W{PuAOr;nm6AfZXYmp+7z5`RbBqHmF(BhX9uuB>hM-A9@Im%hJiIGd$fTx9sY*&TtPhgUxf%soEn3Ho@c!BjT-02N07h0veo3d_gXGiVf)3dnGo_EQS z72hiyr}Bu6PQxuL`}9wOtIJ16Z*Tqa*I(}@ z7xrw1&JS6%?L@ZisM#GJu1!Z;Y|SH}g+&!8k?ZU09RNBaVE@TuWKhjQremcW05`V+ z)*q1FQeeUZN!$n-5`5owW3tEf%z@!Rvl5TldY+Ka0jF9_$VH+KQu2Yu23nnOKY~Y; z*lijT2yQm`UV4%vHgo1%m*YO|(WdSfD%E~?7KQ(Z2eRMrraNHfferqrt)4|yfUJq@@@!cTA`u8Kn4YbiF9;)gl{F-CQMbq3aN5nv(hQPK{PT{XqS=CvBL^Usl8NOLhg4%CIZARYv{prnT45 z{;}6Pw~WnlB7MZpT+-%7;zj=kAyx?)Wgb%Me1;fFHlCRPjhoWt2^M+4c{GGw4a~nf zfsq#oMW_D0ml4D-d@1Mg=?E3ahk zd$ll&)*gNCWqxKnla@RqnCC32iY`u=od;j}zaJu*68cyzIh$jvwT48Q7IXfZKXw53 zyd;`<{!xa;Q`}POh$5dp4OE(;5a(9cpg_OuY{t*m-a5(^U4RRExWC$-y4<+@D$XRg zmBzwe@7c!!`6sOL=bcfy#TX~&rkPnp6)VB@Kk4ZE1rwddfi$zmqHpk^fn!}k2} zhj(i7J3mL^Apv|es4;nJ+{{6yn^n}BNE?hEm?|%3{=Bl)J1|1<2VK<~D04OO*@bG% zPkC@~%=}x>$)df=WO0R3ZRx=DVyKM0(U~c_)2|YkPoY1`(Figy+_Jk zMv=T;!8D}iX&es)uOxq2WWiM=uyOE^PajFQC`|V$+2xJ|@A;pPhju~k&?OL;^i-xcr9N3DemSdEAwVM2&-E`i)<&z`+s0e}VE#EB=gRf8E{4txRZX zR;;^3n;3kVUyJZ%D&1W!!-M)pv(~ZvbWX9--m*+qPJhY%w@7E3OlqzcbQ!AkM~pE3 zLVdG$BRT)4hG&Er@=U>kmEP<#qz(k?Q^^!n@{1bmw2Crd@=cPh>e`&c@Agy+@yjLhQ zslEMGhqHN#&@p16l+D2`m6;$yBPDV z&pcYftVUEADc?6ZnAUCycw{@j+4cP+spaVEsU-}vvU=r0TqKK3g;&}6jGhWJd zWz!6*2}`^^G^->3x>2YX467}p!;ihb-7s7pGi@MAw#w~ojI)&`j2qS)}Br)gY6~W|q34f}9AyUT- zeMkQMqYZDgT5$o=IY*QcCD}76rIsDcGng!w9=pHMRM-{o#LzvPqZr2HoGMLP()b7F z^QkGZ)Nd~Zhq>4D9mIp>uC9zAZ(OUCw}_Sx-yIY|Tn?uAMI zk#7fsU(5&)T5UE7>^tnqs9q>Z5kDXD%BJl?iCm=*Pi_31{P2i?Fx!<8xp=CkmZSYO zB0(K*C)DpR950@7ZHKsjVRoyeST23++|ib3nsu%cm$H11f;}f4JMMHg^1i#{ozwt;eUkra(vQ`frPEP|rQnrVXQJw!{KoFgB^DH7Z?mF1+^7RA?W!IjI9 zIJZPrD0M<@{xrK`itq^aP&%J3LbhP%G2v*Xv;x0G4!=pnD((fZF*W2!zt5}IeZ!pZ z8M`r;k@m4M2EmPYCTxBj*}K#8t-3s3t^_5mUyqI?xj!5v8#{|WmN~7&UaKxxFiBJJ zTuk3u=(vHNDG~KJvs}oZiql~b#4#@Icv2e?ZdT)O8l!E*6ctZ<-M(rvnQTdO<7eeA9BFMCKf?OH`Us{F@z3emJd$X;^@FmepO)N}hM`lAfx_$ZRHDatg zyfOH>pv9x|7mF@EOhd8a8uFrkc-t7XsxwBshcB4^ywxc30vH{TBtyc9Rg=D3rxOiND`L{_1k;)hHg&<(iq+-CPJre z->scLLMsJiQ}m$izZB{;AYNonw#KF{o0xlt+`m z@v3ikq_KQkx#hvtu*uS&J06U$?=j!t*Iv^XUOihnrM6z%)V@1nH&h3UkmuD666F(? zEbZ$aSe=Y#;W;uZl;Rt;4;`xw6dENxp13dT_2Za3Apf4d0>enfvnJQxKym-&Z)ih9 z7qoEs!n9)^zgweEX!xb=ZwsN-7~RPYI*I##+>+dj;fe0K_iIw) z#EZ9qYsNo6;(TR$^eY#^{^K0Mzm16EW&f?W=qm+j%0I7s$n#%%zyI@voPQ^KA~Db@ z4mR@oQI)1Ly=P8a9&8&crS~L}bTVJU>hedVrmjzJ%Z)u&YXpu39^#kW{0YC1K;}2; zod_iy8?oag?F6eTk&w;Y_~oJnQQX?{tXW*e(Pr_%iV*#+gTbn(F#eyV!?kM%T4^}B{*N5FYE1| zeuxKMT|c0ROW98YHI5*(ZG}?0JSaDEq&GceT^h<4PVGqxsL}N6Ht@Q>EhH#yU~dk8 zkgis$#`}877H$9Y-QJv#{H>M+{KO_^&$E=r@1 z4Zh%tpCeR`qyJjW$nUH@>^9&m1L2{K5#88qb zF8bU67vgTiUU7HQA!NF|PHUu+jyi4J>Aj7n?tAl|!*596@=UB`g7nWU)G1h;RW$OE z6Ci17*kk6C*qeB$|MzB{vy$PPu0q;t38eoXb{q3a) zYKzKO1tu6`y&98EypfIsvBp9*@-PWAu1gKkEr|ZyVzfiQLY_ej4>_NuqKgK|b0>(* z8s;POjq4aek?NppXPf=>X_Hs;%}O8w=5NrOmQj1zk+T2F&->JN8eF&E#1`D+yz~TB zv6l#%ZVWI;P+E}&`k2ogo89Is5WAxXH5PLV3t1>85`yrVUgVkcSL#939Lk?^0DXW3 z@W|7dOBXK4fNeMSv<614sZinpmu#UjCr~+Lz4Qe9$$hDyi~p&M#;ft~ZtI(yfnZ+? zwdK!HiA#n0FxJ`=PQ;d=^(fi_g(mHrE>IUtfnzBRauBMvpt^VuyJAoyW&5CzM?g$W ztmg^WEQIw3fcqEBq|R8)YzfC~2GtTd7Y!(kORdcpBGXAg2?}I089=EXTuiY7dMuet zidhfgLVyFtVhrXXGEnXwci@DXp@8*&1{)bD9q2;|1}+>9?4tXJAf>bgwSIc&8Ey=q z@d2_9LpAf}i2X`U>K;r}=&AS!k|(iHJ8l4j4=8i&Pe-8OE5KDzbiM|z6TB1ncDmWd z0Kkv{t-Ot3PYzA@ZyhZT`F(O!7^YMMQsNioNL)*W!gX7~2m2xz3WlQ_0MohAF_CY$1MIa80jCLP)_4csEe$wRRC`Xx#|J17z>xrTCNvB%*0_EibP0Q) zbOcTU1vYxetr1+(Fx9%C-UI{N{UCp-FZ;>X{Iid*!DJ*Cu-X*RAX@-W2p_gutkDbn zWC)laRtFaA&OSbV2W-Um_V)AvgkPIK9QR;ihq548Ad3P$K@J!X;2}Lmh0)7{3&vVT zBTKE*LHj1+WsI<+6V^f%s?FV{uTdZmGw*kM0f=TG2PA-%90lfLIFL+IfC`k4!l(h~ z0E#Ep5*Vx_1(!mVv9B@Vsj8O+%=vEY?@L@j9aX|wJTQ3zF4e}CWzi=O;?2~_xGp~BJ*d|Ct+{h2u_Fz6P85?=?D`zKTc;fugbknoqw zP~nD(u@rnmtaZ!8CNH?y_hhL4ZDtYmdc=%9&A%?ZXlsH zn`b%qVz|Ol864S>ur$E11M4CXDgg0I0onklMB&c%z$Q*53e1eZ{el6I2#rYs62`p+ zM-k&0rl;vr-j3ENr4?2mmHq=}P@K#e(aF>3oF?F|=CU>>qz|;8H)B*Oi-!d)GuW$K zOUxvEg{xd8-k0;hxHC|njeYj0!o1Dz?zENoogF_#-k-~t?;=C@_2_!k-y0K43-%9}+9UeY@FQB}d z1HhCCKqx?gH8bbExJI5sR1AWq2?nK{ zr~X(e-;XXX^RSAsPQ?)2_v&-ML`6nw3V6)66#(;sB{QN$H>_n^dsx}v(-oV{^L%}+ z5B3*Fz|)<^z6}1q$soUWkK55EW71u9uv0PH+)E59f3Iv*hLha!i0G0Lsc{A5>iY+H zTXTXUCx;eFYfG`s|01U89xZCrP3^Yb^=TZSL zN;M}pR~Ax{>%+l3NX@rV;I<9AS7_MpY3$OyB)d)saO0obg!s-L3>6qFM`m%bfaE7u zTNQFRpbU4XD|3O&83Jf4vXB=ALCk@t;t+df;J=7?5kIQ#v8kzhq647l3NxQ!?$M`x zK9`Dn)`w6iyM-hA^pTuDQGC`M+a3fsfCIHhR*jxfI^JHMVVdZMAmxodyKNWW$P#ZAf#G1)K zD%cI%6jpB=1eFu|%0uW7Vjw#JYH!t8k0l_S8vv&127;-e&kItC;E5(ApNfEe7n>1K zHNYN;4O1U3T-VN4WY_%uiKwC@xI1>PMQ+MkwtV(zY>gxjJEcq1!?Qglc^0$b^_ZQqFM-qT@bl!e1MiK zusr~4Lm!xS*u52+Rr0kPuIaRpI0tD#Cc|Mi{uGcwN!Z?cAf*bt+Y=U(jh!$MA%1{7 zIz&kt;E6FeRBZ&Tf_il!#wp^ZG3<}+5J!Oj@ddVI1HexA0qp~$_@X-RgSn)bMi@J| z_g>tooCR2H4mMY0uC5mZ#1xqRDbTRTk_o*U>OlOKgIECLd(r@w$EI8MWE*LkjP%{j=dZd+%!a*QuJ`XK z`d4(H`;w8dUi(%0wOPKoSB96gh*CdwE9_93TzcW!aVIR+B)C@wunj@OkPy+B0Q%mhe+g`k}Fa$Ej%F@dB{m`B<$sX@=KW_mb3gLaL-t z73~7;0s&6pcMdKQchSnnd@nH3{Ri<#aRYIjH$>myW3e%l0TNk&l4XFI3)v<_nutjj z*sqg70WujfaBMJ#gIBahO`9uUG5-W#Au>U=bf;c3o67c*M@=j|pQW{aFZIG$8eNsT zWeG~SOCzOBRlS8KgI^|SfYRXv zf{Tk0GexX){-sh+|E#uQDNLMvAioEdJG1cJO_ol5nNX*}{~4^ZfowycE9A$rQ9K19 z_ICzzf3Av$NH|TsIVpk#_1&ob;7+Pyb}ZP;?a67u8Fm)#1+ARSzAw;s329YCoXG`m zi@LWO>?{s4rN*DD*wTDA()cr z#FjufG1pw9O|2o>D(|MFNoq#ReF5|rufDtH9{R+K6pXw)trrK5k6Y#NWqqgraCkc-ZP`aken`RN@HDkIBcz*za9gNQ z*b(JdqZveSB#rlkI^cFS^RRk9DdBSM@v;7C%)XUjsgSexhOA+l{LMR*_ybf8 zKaLD9)m*d-Xi5^(`SuIs`gD)51EGu{E`D~tuE&2kuB5*>)pSjz(MW1;Zz(7@&_}Ga zuB6*Dh{K_Uv&Fgeb8Hfu-r9>PcQtc~stsIO#OWP^nAiQZPvg8$w-iy% ze$3sSDyvLwAs1@st(0x(-+y>(7Vs@r&*a|bok7n7dM7QPupyH9-t=^L!p4NnrJwif zVaYZ|eO6yq5#>poZWN=?TSY%6V?0KbueU^^%75dQ2UUZwR{E*^>|r$yr=z^bcRI61 zXb4xL0n9On<1}Ox4qhhoO0mGl-u$s0Rcdv(dm#*9R^-;|-k(BUxU5vf)OSnnRejzu z8(V35hLzIFGcjbJ=1vkD-LM;KQ1-chK&TH__Z696FD1n$ioX=Am37Zza zH*}VmuK8!2KRXL~s?2S(u4nGY*UEExnXlnHYPC&)&z(_5Mq|LB?8l3?S2usbR(IoN zMh%nrw(MS+w)M)bU=PZJHW{3U^DZum^Sl$!E6)$5&?qK>6HKC{pkbraWamTcSVxU7a1l!+H`Nq&E5zr@xNT>N{&_dB~zQJ zY46ClPm`esrp{~`PQ-mY`v93DvS0y=Gg)VZ8 z)LE?XT#R8)a$^Zkeh7(NdWOqNOOGU_dXQVt?!csB5+${j@6!5hyYz4$gNnU$ZvrRv z&N|K@VTtblC6XclV32#)9HqL#+6(f{rv3=zKzW)w0e###bxK4ec|%0zOlYcAVs+1czT=iEq9s7BMN zX1ujy;7nP<59m();J7Gd(AOpDf!RXrxjW{ue-{;al4zjW`3J-P=O70MlDVh`y{*Po z{ryB+#xR16ql>|FF=Cl1Z-vt~@Kp^{KT5v|ZpI|pboUX-wy8UH4Kh0I7pamX&|89z z74#tvhDCx8CZGo0eYzf!|j}F=3Hsxf-Ijo~1_(9@w)j|#Rg7U*JY*V8D$Wh<9@o?95AWU8ZNMS7nbOOsY-wBb*tlind8X~)Gmfx}DYi`^R`rq|FAZO0oXbpEO!?W(c2Tm+hWOa}zJ{5c$^<R%KDh3gHbF$JrO3S|*z zOhgpYXIbf&8|6NC^4Bg;`1<*LkRF|qklHb6{;|X2Xb<|0$mYSJ z>ilqiiu>_;zPI8=ifHHKfemGK)hrSG;3YACFLyhlUauCx+4?BgW!j$Lh%m80nX$Bc z97gS8js&Fv>F<|S8&b%qY5#g_m;RQN!}yW1WAQ7$`6d3?2^yb;uLWBJHnZqu>OIF( zIPo;ug|EGj1*~l%`hr_8@Y5{*DzgU$?Cb54WK+21i1m~XY(Ep7yiel+0C4vcd6k_;k9na~3l2NX_{pCqi~d zlBAUS)*71y<;HC4e~ha)9)ng|enAx+5}A4cXY zW>w3NTOJLI&XSCBTP9d{Noeoyj$lH|FCB+Bm_wM7BTRx2lY3rV3dEd1b$6;>#T>9CI6X`$R&|JGpoYHSW zG+QbvI0sV8baFq$FFP!t-=b6r@6RSwwncXqUZ>F?^VeCEXFSc6(>KT}I28P&r^Tn_ z*U5Et^To*}jQ#noCN5sYV)YtHM_#-+k6y5Nh5P)2)r%;CPnmul|Tp0Wt4uy zyYJuhQjb*yWMr$63lV0h6yzxd9qaAqYYO%Y<>$CH1vcw*H#n?=pDk4!i>=7he>Q6E zM|@UcyS#6ZOkx*$olMDJqJP0)_xl$E<=dG#eZ;K1L|v&~O^ud|pIZ6%mrg~KR<=YZ zr4PP_`k|_${cD9b6Vm!i7_CXl})2nJtHJ6c&<>5lDn6x7&i%rq=i2Vd{1;^Ly_t!kq9u2HFMA)G| zz8`SNSA-H^S77u<%iUZlJJi^{n%3ucr5V}}D_NWNHt#U-?R~P`YSr*^E-g*`*wdY~ z(POydGDq@ak1~D4a;s{ux<`3{ds?!bW2(f#MWQ#6e1ZMMqsM#)^F@p6I_2QCEMf6N zq})MV%0SW1n=9!DQpR`m=7+~pE#H6i6*(^zv(~%Je-Ox9m~?J?dEFn4y$k*~B8 zLvfO&AMIRQ(O1~GxU|ds5XE&f?u?re=F%;J6WKahtBM$>u*CPSKCiyomxIcn8u`J( zhl+^J02P%SI@UWc9#vHt49A9{6;jc6ohdrwV-l0YrFF_ja%|B(7k=&zII9ozv%Ya1 zWtmwbGGZk+t9TQvET_rH9iH}Gymj=zY(5bCX6hAX{#55(Jq<>c6+lx)A*A&rfGTSH zMA=_1?bX$Oz535kl220lwTt86xMkZq0KZC#G~b5Q=P1) zKQje?y~{%+)&!6#l8k%Ea<35qWy@N9z?q!v|TQzD>9ym-_3VjdLrduY>sO#U%3tJY>1` zd-+UNrozR3xi1b%q8)>%3OLzm?i;TSW_U#$P0r89y6+tP`Xn4aX-@~WkLmiVU>+NB zk}{DZFjKriw7K+w(vpi$1&$CY`V%gV5j2HVbKe_ek!6ecszs(USFR9uXTHX_waH3g z`}I~f$A2Tq`d4?wI`S7zg>za2L#dfV+zT4)=d7}qP;wy~ zQkS1|XvLpqjYLe9Q*JJN4sSeO{1r~%gH|JL%oKLRbLxj%gq&9}sTaB46a49*(36)z zD{3T{r{i2c5in8Zs#hK-E^MuT@Z}<&C?(gg)B-BbxP9?fr}P_`sXVqCdbb1wzgbxy z$sGmu==}Z?B8WECQNssBorY1o$!`mj$d-q@f^vnY3LX58`%W^y-D_Bk?U3X}tCn{( zE@-!VvDxRFw;C}B+R5H{W_*=j+gvs3(E7bOyR+no$?<;vjKY8euid#^Jgb{=(j>)A zwYB%-X4=9r!?AC=V^ZoeooUxuCdB>yihW5ejWUI%0J|rMXX8EjVI3gq?&(NVu%7(m zMHCXD;HGi~?_?*wfGH*&(-S3=%(2E($ZMNwmb;IB!CGlvqAfRh*?CEY2O-KNgAUd*^BQ-d&5m!vUSrOa&$fcmK$l? z>B`Ar;`sAO?XH98yIz^>4;Co*$B-8|)>`itrg4^3IJ-GT=1A!+XUY8@bej==^|zUR zN{Er=bh=Z5v(<%H780XU*F!Q==52GEw!c>5DNqMFS!9h^4WeU&CA+g9@=m4vP*FW_ zrb=RRHJElQm6-?~l1o6!A06sYdf|IIyz&=`itLxpY->yiR(HsF*Hk*c!SH&yE#|}8 z(t2w5C_Z9lnpKiwKVRzX0A0xIGgfw;&!&f2xbF0#`Ps{Y5S1rZ`4Ar*)@VqhRqNt``{y``OMymHed;vw4fva0%}6?pSdq$MNHGfnAN5cj+=miylFK zgMBL;Ha!eVY((-A_~+-|dn;^>__$ipBnbYiyN7mukEa?Ancj=kM;7Dp86TA`nI^-f zjG~e#i6{}t2-Ycd()LvA>`W$+;mXHb!&izECl;K06r7(n7V2|zlkC)GyS!@iui+tM zT6D~-^FjTU+O}X|P$@N0p_b-IWI{)UeYda_Vy9F1H+BaI`>aGbuX;F1uPT*OEU&F3<1BIKS#ceskE>q;vnJ zz+L4>gpJd5|4)1085U*MZAo{3>L;e|R?L95pa=?zpiMR*7C|yd76nNnl5=Rg#Ry0N zMGh)Ch$P8iH!1>>lYj&zXAz1b2+TSP{oOlrXXeg4^UR-_`q8!^#ar)t&OUpuwf5TC z5m!{iFYsv@3WNu0T|L7rpL3xk;m%H5o4u~>Z;8nAeH49EdtKrU z+A+-xM(kyu>xIwd^Q*IzTTXlPSKWGV@xXbsu(HB+(=LjK{(Wkq1B@drC zWL442tEJ;5eEY;!KWE-t9)-5+nj9MR@uu;Hevf?SB5j4)$v*XjrZWviwZCkbH;8$v zm6EQlk;K;#8JBVQP={+sSl!&a3G<#1e@gOJjeQGQmtLMv`RDmx9ggqbY-*!A8nK`{ z-$~io=wAHv)~<5@3N7=_q?q?*d44aqJLTD9;_dS5#UuyIbDNFzX@#W*>S>QmdmW4; zFYQa>7EFG8D2_3FqvrQZ0{m0=yvOPF_ioh-*u7L?t=3ab%?aLOH^&!lofvCiD|+u?b(-dJF;wkp z(g;z$@`tdGgC1{2pjn)I)S(NGs?&`$Q^lc>tzPe=lGG^s?HdQ1)P#HvHJ2AI=CO@& zr49wCbkT~{-amWubCK>bHT7nxak}CgpJJ1Rgf^?U%1T+oEH#6{%2}g%y$Re|+HPNF ze$(nVb6ogyh|zIaZq7X8!uw2LsWK;1=Ud~7l&LKMwK0Irx@uy z%DHob{^pF-xzU=GKi1tTh_3I?NIW%Kr|GI6`BduNLFWl|)prV%=u2m_zw&bRE+zCqT+_b>scRO2m_;a4s$f9m{m85jp{m0)i z@x@0)MP>4S-6YhPEiFPRGI)~bHz)i!BYiI?s#AhJaX2+gslItWQDa)RE}SV`Bu~fB zledm$&DiuZOS}HJ$^)wLv-xxB_e1%2=;zs4Dsy{Wy3Tw-sC%=kQ=wO@9or+d z>l^RojHNDw(o9BE;?_BHQkpm(xHB^QT!b|rg|{ZP8KneB{wAE|8vJ~`_$RYA!=&%+ z<(eLTdOcK=82Wm#TJ^d8)1j9|8#HYOy_2O4t{4srrUd_big!G-c_6YQKu|!zi1Fo7 z@t1ung0afc7m_uie^_WUeOqn5C2!n_&R*F2;kPZ%=pq~L8$b1~l&E>B8*G^Ccwx;6 zmpJ|V44$CgLM9J`uE70d6-U^ANyBV!2j3kF&FyzLFNH5IdSxcAda=&5>@PytmLf4%q<}Q8|#j9s@G=;Bd<}{QYM_t!!*tsRuVx*MJ`)aI#T{A}Anv{C89yLdI zqC?NGXn&mR4bMHgoyI^=O5HqhIWL^75}^#`>zAyCLP-s7Bue1&d%L@zps9}w zd{eC%bVpOKcUi>4qX8N>6;PW~wNmV8YL!_#jSkKNG)q2$!brk)rP7Iz_^K3*UM(*# zkGa#P(Ab&iNZ&+%TwHz#%1aWVS+5C!9E7zl(~#=cK@6rtYp^ zT&8W?#=_d76zMH|A*OOie-;uz0=Wm9|X7q&`0FUv}-1bu1j`W^f)8?x;d z_NQoMp8|{h_<}{Ia;|!Ta>{YhLcZcJN^lsyyLtMx8s@HXc=-ugUj_fFafo&x@d_T` z*8#~8D>Wu-D8tNL8H$-m<7A^j^a&FkCkXeQ2S&W$U;BheKQYmetP)|nk@!!aZg{bY zX=3}{!J9Y1V8y|ld{WT(0epJmg%Ir#k*4^UPbDe~S4g2<5F|d^vso_a$Om8O-UgpQ zN)o3eiEk|Fgnd}obqih8sIwc=u1Jx4OQf!7`_FP;aw8fQwEbe9Ug-V{VcV_cAPnk7 zbU_gMst=72%U``=C#DJC2@=QHVO4yNmHFNDCat4J56)G$z%V?h(Hk?~AL?%>9|3Y? z@b|h-gE0B`kL{>_#hFTPSd3!p!a2d!0^t zY~|J6Un(0c;84Q^rD;}3O>VVBF02y&y;-Cil?>!)kZ-i&Z z*?9~*RfN8>4pE4cg2(}p>8E|ElsB**X~dveN?iqCaWWC#K$$A9D~jjy@GR6Pu06Gq zSm&w`J}9OA0@?c#FM(K+vFYL=3W4Y}0=wl2j4j?^ggKE&U?ntSjAXcw_5Ro;C{Lhp zgOck|;o{sdnVT+X@a9K~61E;3YKVx)i&aDo603Ub&7R@mB$!+U9mVW~Bq(Fdp!4B| z!4zV>O1_;r3C`8k@^GKIqu#&Nl0W$@v7GMJ&1hZ!Ju}zMfA;>tC1iNfvElcSD}6!t zjJ*StKl&@H?LH=Sl{G8u_UXzuM{bv=H*iS4lHT=2Xt#3h?~@+N%p2ciy1QR{fAcZ_ z!Jj&-ZAb4Hb{00}M`Z2wi3sg?g)R07sHlR7%&Q&NBU{(Ec>H*}Kk?yS$T?B>@=g$P+FqFWc| zRo8O>j9Dw+~ zf`2MS;n7VD%*|g@6aWC}q~f1!MNIdSaSkP7c~PT{B3;$U81W8hktlWWTSs)gI@*#U z1!XFY13O;^DlD48+N7!ou_WlMh@y248`bvX%eCem1sYh|R0EBO=N6qs00=X6lvs3lEglvHpte zfl~MK?&d5fy6FP^FgjgJp0ztqLt(3E8}aPWuyfyth8R4oOq*i^-d@d!tqzH+td%HF zHDzf`nU7X;$(7D$j5JzTKhNnem@(thNRJ*F8L^q0GJ(e0aK1oZeD-B)EgLIq3I@w4 z!xtrI-#^UjqVK}7zg_kU+)#pbm!O;?>RH%o?82=n7mlFOViCKEuu+OZRCBW7nI{;V ztN8HG$-2dfHNImmLfp00DHUHrYDfKf^X3fJIZvb}_$UCDU1BHv{kLEk{8U8yRy^I4 zZaF*Voq}}R7AH0h&qJ-|$H6LW^G3LDXegw+71FaO&}}EdtsHq>rrpW?oSY=9BMS}^ zVJ^SHCicM-jM7RnzeJWUO>_5fc5pCA6x)Z@NntFyT+g$jCl-{`vYPt!<}JOpZ%zK= z51u*w?)?;{S5qg;A6-1^7(j1mvF^{%kzl5!q@?UTaXlFach7(*#vfznkOWsJ&qgtr zpUePzEaGi#Wu=7tl3xtw3H+=XkSCLq03$QrOwEkDFC`(m4G??&#LCLb!}av^p?lTq zkQhO$EUo7<@k+;sA}iZq-|1?!aO+-Ngp)KcFE0s%8Ma>0(Hw{MinMV&<#byQ!VW~I z2=~>&nBQ*ht9VXyJ5v9LGML7KsrEF)3B*hUBs$dlnw{9N`5fqiU=&$HuA5(U0v=hI zV9$O0xEjVSR5dsb3=MH(n7gEulo4~XAC;Q!x&bnMN_pngDDn^VXyjgNbd!LjUSoBM zS+P$827War8d_%=cf1t7o}T7AHsRQnYJ_YylH3A0&6!C`p*nTwdMAOD5yJ$@_r7o#1u3ayq|D0HBE#(^dM;vK_*Hy zu&_u)K0g4HgrZ@iT$tXxDlL_v!rwjx!9NK-9`?JNF~@+U)n#R6vkvOGIWO7+4l18R zw5`P{ixDeKOl(Xvo7EoIS__@CF|_G1GDDL5l53`;H>4bLQ8DM~1~fa6wZAZ|w^Rpj zFhNYW5}WPuL$2~l><1ml=ak$ClOGR#KFcU6YT{t?CxI_;W+{+t{(^dQRK0tm2q71S1v%#Ih@-cp=@j%&rybD zh0YY;q@*NdE&l!0l_@HeaADVT2M-<$roDE3WEQo)MBx>4HZ8Q!*FPH3uB>gi>LIhc z4s@s_!4!0vxgjYjnSe70mOEipkrJs`dB!+y=bDQbV<5t9wN z(u0OEB%s{;_n*U{kvimhL^{mEr>hO0oMw#ZV%fGW4(j$Y<$1_kzWvJoVMJ1gpE$`k9KxM3smW>QmK0Mxs>R$>mS*`0& zun`{DNP`KHxRbII&k-dUQQvaN80W*M-)sBP(-?pif+D98$)hVqMyIEx<@VQ=l5|2| zVj&vwNC$sTvHms=4YDBi2VylTpX^8I^Fv9gq~ig@mWqfJm82WM-^f1Wzzx5E=Ua>8 zZR*uFy?C_b*f(H~4Rhhd>s_eGXiv3sA43j2I0+Z7KrwN%#>7W--(B?K@uj%AC5Kkw zq8k%&@ET!u9hMt@_uRag>+oT@EcVoZ{^5@wAolmevx*_DI*=0!J5MJ|(+(wIO&H^p zOH_`l601f#L^H;E3m8@HrjNC^%~gB{KfHRVWz$8CumjNtpbpoxv5S4PvG9!=f*`&b z1~Rb-F5FrSOe!*6Sas(Cw2yEOdU~zWGCOZ{bd(&FlsfmtNp`FnStL;k=!bf$SNs&A zD+!4n@`H!C5XaJ4(zAnHnq})&a$=w_Q56S1N$Q`YwGjS*P%DVidN_RZta|T35Ux=Zvqd9}dJSlAu(1?am`A1Q4jsqNR7BrEz-yM8lMNPW8d9*gqJbe1{0N@4TX z-6`pYVw{vx`LG65=PHlSt}T2wyh_#iZK_Aos{@{92 zvW@9e&95SX3Z)y(*X6Brt1>MfmtFNQRuAZcJsQfEuN*)35Y_kJfAcu~?>Ic>gUH+e zesM+r`yWg{Jb*{{La$kB-L`EuUn%E@hp(L5$Txu9$i)1IVp(&wc8w$xSL@|XW8c2` z?Y)>e5q^U3#`g>dW-l3SW%|rD-L-g=_q&rDU%uot z{VO|;WuoGhOT=uS9_4}TllI{}6BjMMn=32j&G+ay`Ch7P-4-_e+I~TODB61WZPQzvQqiub`Q>J)c%7a5Mmwz%af|^bmqg;Oyz?>e@r3 zyw~0-AkS7+Qc{ZTCaonn?jVvmgULOK8X2+Ziit)$tqgVRp^JM;j=)Aym{NzpZn)hO z)igB46CzLB;AV;X-NVCUjl18bT0~*wB3UlE%dqm!Y*AJ;yeb9ns)i4z8mYi~(LR$pOVzUEI1(%8P~1qx}F-Mf=_3Yr=tiA7c! zhNN8pes!)hA8t2wubh6kz-F8qskKj}NT!K1%@nD7U0vOnlNU-ixo}~@`giC$$b<>s z8ausJy?_j9adB~%FJETP+d`>B^kL5X<2LtxUd7*k@$R*xwf&__;SW_U@(*9!eDRd? z=}3jT+jRdaeX-HCuBVsQCP#ga8L7*62{<^#y2VoEsO4S9!J``Cj-qnvu<0(j_h5&^=v7{v+U=&ot{edvLVmr{Kbd+{ceXW-{`LK-!|$o zo!`d75(5yU0*5ymduyU8e*PgLrmiq}UN8k9dv6=xQ=lY+=zIg_ga2z}{hG4CVzd?q z{W06YQLp9r9ukT#zs|wa>B@wlYjtD7`fPs zRx=oRiY-jllO8pW!GyR7l8$ju(C~+jXY$!d0)QkU9)%N04|QE8Bw1mm=k45)VdR6< zAr^hN$f7AEn?w%7@5;tkI7I~>*3;7yjcI2l$eQd68G(9k8t4Qo>}T|ib6}1ARIL5B zWc`UBI@bSY8NxZTVYu5`>RqUBxYwg+;^ITiHIr)UH|Df|D%NfI?O5}qLUTCQsQ{3U z#-}=3KH_unXr@DRSNtLOrz=k}^(JhhIEcBXX-j$xYHjS+s249z!tdA^^#V0|-8SBV zU?bR!wC4+=8FB-wus}r=BNi5j+=7Ct#L@uvt36;N@J!IZTm4#OcQTjf1cjvE3l&R6Rn-O5$qFa|X^gRvkz}9{{R18AG6F<+dp}&W?&}|% z|4g&bXnr3NkSZY@U5N&-{7lbq@rw9_z&$U%c352@MUst3}f9xl-WsGbPEk6T6j^ipK_=VxZ( z;SsM<)f`RPjF>@6u-3FIw-@I-mxhpxHzHZgy7A$3;Z&W>?9VDBktQ)`zkEb80drvR z$*mmw*0tKKv#5~Nh-GzuqR~i&6xv8f{W-)Y-yZusJ>7&no=*4^1S_)HG4pmmuwx8l zT|jp&@=NLGL+{ZKjShP{_LnJ05o$CZSQ6GrE9zzBY1}xOkQSHK*DJ#XIm7NWpl>bG z=FTyA_dvumI~F}8ym#;3sY9jT@rrPSAyo z-{6ktCibU6LEA||=~%@0i+AYc>5c)n{!usojBfK?tW|_cbTrz93mGI{5%**U_kY%&;j*!&gM=O30qQix~LO zP@)~>Z^f%uuY^%9i*W)YL7U+{FRuV3&{(q>VsW9Np`>vPjq>DK2wU~s0XZiCAeK_+ zCoi6W#g1aKH*XB*+YRpiDGPMw8<~zMA5AaQ zIMjP6BU&KKt-!$BC?m`-_JK}`v%HQ>@!1YHN8)9U4^7u`H|YWeq(!&fic?x;OFxJH!3PMuu5i-VJ+Zmfc zT2@vCsM|(%@#GgTUR-lO-jd5G&d*qTaQ0YjWlT`z)mca3o*5en`P;JAJ?iE-c3N{R zp8y23s_rIGpoxVMh4h~8mko;2bDNb!MNga=u=c|HSB~dB2T<;u^tFVs2(EF#dqQ2; zQId51Cx~O;GobRIs*3?YR}Kc+42a72N>NULc}4AsRCE99jN(G#Gt>s z;xkCrHW)+eg%%NINq&13H)YlV zbzNU%aInSR3$77oyiB@{j7;4!8%fMk09LC4HUb-WEL_x43E_v}tu#j9!-r95?;Swc zYlT0?bBS#wN5-7T-|$*{iaA(|NY2fkTO4WQ(OA?zFD;!n&L@M>p9C$HzHott42>fJ zyjT-w0|1H`0QiJYL9@C}QR7N9VVvn&7xm^>@a5+oY_}|RfJ-*L+I>+O0a6uB_dvn9 z0E&XUJB2)z$^kSHp2nvzk+}(G2jIGGSFKuQm^<>Uu&~f=e)I&PH-P)po23>aER!P| zhQo%;mie~B`z0hK2&+$ag-vfc3$h}O@q$Tj{9gUh)kTpYSitY}efCQ`l%fsv#CwT|y%;88a)TgdbhkyV^W60MFya_Z$~c`nXZb^3#l24H6yW1PTHeVh-W z8F4facaSDCSnV4l?2zY-d_d*NxebD_r=W7mPEJiV4Aed$AQr;YAZ(L;u`@Xm0ET?! zw~-PMAp@d>5~lNR0UUyX733HOAJ4pDLt%HvRzBTi5b<1SKt*z>1oM1v@z4hhwjDdZ zRwf|Nk#XhD#;<#)E&{~-n=e2(f7NqgSD`?LX_b>k{|`n#t4{icEflc*>KFf@La5@J z+72TT0J@@wvqu5oA^?SpyJ})Zmyr&r ztBcN5S%@<;ENmL`-Cg;sV>`u5XIi4nU|+B-^;$!6$o-~b&BmQGeaB~jhGrwr6>wkt zlFvqFa0hTHo<^bOXV6lDm{}A&y|B>r`=;cimHxaJC8uxmF&QXh=cDs74ulNnfdgOB zHhX(@>>lPkfv(?7KK6&*f5Bi|Z*rl>(#!b6VUf_0RheZ8S1a#|^0wQ}9CNFhRU*U? z<`VFrZ(X!PEJ6arGJv{4Z&Gu9eRZXJAiGR2JF=cE)NjrtBK%B@!Pw@(}3o!b6(}f%&A+2DuP-@t8cS6q^-M4tI{p0sqD$la4&O-uSj1S zgIC5B?xra|CNncL6(BKYdndre$`p|x1a(BBTBYua)a26@^*JG5N%;)i1s zBR0;e_-%n37BY6i+%a$5*o49WR#{IG>3F!grExk{Wt%O3`jc*#H|a60direa!@4aj zDD@H-r`e_DD$4YByv-i{U`BguLKRY#mdSb$6O-sNczNgh^v9YKx|(i=hrb*9?buz- zjQN{=$r6vRY`cDOdQ|YiRO)dO_ad?rE2=9f)R=kk?0LrWf;*`XSRm>JKB>Da!3Hjs z)$R`SJvyT5^e&XPD6A@7@u@Y;)G@+WpZZ`5cD))jYg#Y8fQ_ZGSTR`k6yJ*do5$mF zl^UQ{9>qnA8`|krlRiEAo>O8j8_rM%)gGE{r(J3`2%W_nSKhc_d|Rq9^^uf#`wCvL z#chP>&(nJ=`^(Fp{E@O+a(Bgt&sFJvJiofnbyr7du?3AL9#PaYS^4zRj`YVny-*Y+OO150UJ=e_bras=tVLYBNEFAjKN~_<( zv%l|w^IWXN+di%C!7Io2pJC%1QE6{e^hy*?@^h!m2H2+C(XYC^Cke>cR}>dab``qN zeqPLH?GzO{F+A$?V7${m&G|%9RN&Z36Zo@Bc-l;#UTpUS+!8iu_IhGuby2pD;g=PK zzT^&ammQ+6Z*sQ&tgEJH;l9$4wfvo~>#Os3M0R$JI?qqoJAWnbzFt5!Iue-1K9v^z zK+sPD#wH{rP&u?f5AZPJTeDp+s3y&9y>W}{!Szk97I}KN#}{5N-^(bjV}O2x$KAIx ziT;|lh5}O>k%gzYt|{_4g_-H>3ewlEQ z=X_Dpith|rx~?`ZEZ;?@oD<5YwgD{GMD_%31?N805Qbo#5aCg9ncB1Y z;)@#9z<;WH`upMOa{yfHU`loe#+o!IPZrL6ybp$}a5pP!FH{ORYYg(PtEl+HCW83uuKTY)!f2_G9h4=k$`@X6g3icE9%C$=g{jK6j6 zRI=`+>as5+mOIgEU(0FP%*kp;6id92^VI)w#dqW6M(j>5ew>5^86b%X0ZhPLCjnnd z1o5qzZg3a*bpt9wloEpgD@aO(@dQth_ZY*hMcDCk^lpG!ldeD^2n9fF%b8iYF&0c0 zc2?mWgGjp6aO4~E2WJ8WTD5vLHwVXA*sGPP)Dx#Lls2ZQM~EN`SrsWXQ976*KMxTw zp{k{5#sHETMt1YH>W@d2F6{OtM;ucnQ0B|xj@D^e6mn7+q72=~$0cQ`d3Ix5$blEq z#y$Gp>P7v&bieV#J}E&XH{|3kiEyzQoSio3q-Z@~3}RUPjhep^L^Z2m08cSp!sR+ff zrEJc!$F9CrKm{X!!k@>c)+SFkynDO#pH$SOpPZJ$AR5M?m}H0kdI9jS}mT8$qA{|3Do`~%B{p5S+T+4lw>oL+Adg{pvy5y*4P{h|D+A}r0^Tu?MHdlah}iO;RR=;9 z4V(hpzD4V^ICP43ryjGk{5;JYTDa+N%W2UcaR^4e*$E#y0>cvQ0cBbuoMJ$!uiD|c z7kd}ia}ryp2KaX`RzAwx1n{fU6pF7wOZExGD7fA@_4rB=k0wMeY3kx^wYw=Q7v{BV zYhi>(TukA*!v(@xD~rTa069;jeAKkH!ys>pC2~dRSE$hg1I92URYCzuMI{sg+q>bS z7Yq@`^@zmDKQ3=#N6@S#`Wsu9?%2a1Yn@^hJ!}tIo$>DtJ1}f8S^uv^cJsd`zpOYo zIVCZtX{WeLPON;SZcT4bD4=_+9KsfmwvE;v~%B6R~;sI-nJnihj$+vsu+}vNYma0r9 zC8Ck(;r;v8->LlFS;2pqVnou!!-LveQ2Z~XSrmJ-N_;gUf=sHHjnI=c^+xWZRXS`d z3-2yAOg)Mte=e*-v%>B>cV!y>u#o5Z70( zF{fW-t8J^MxEArITNrW2e*bVFDBZ z^~sHR0@oMmq$WT;4_d_Xgf!Yu&w+kx4 zU)7s=&%2L^M3s$K*F5vRBIqzEyUm;T5)k@F^AR&<=x3snO=RJi$@nR&z^4bX1s^CPdM601} zm;w^c0|#qdFfBB+&9xfxGZiHzDxHpF-|+ahh=4_9CFQKkz3Q!V^~WhnS*2f85-vMZ z>OSl%&9qqht};kWPcBqIDQ@Ga!S>gMu_Zc$DJGAx%1Y}U4`$Jn=eL;MQ04>p8vUh? zF(CG1V7y>HpGnp0dSSbx6#n%ULi_zV&VZdvv~2u&sxez!b$rx@nT60T3XlaCj=4i%mmN1}8>U5E&_vZD<*fj*8ktW+I_v zX?>=DCU!Q%+9z9Ns`%o<=izZ~^)W>+RzdBe0N1I~q6KEXbQS$|61D8}8{Z@d|xDFco~WjVJfny}L3hFZs4j9x18xIC17u*wwYpC37?Hf118(V048m*~bUv zcR?ddn7I1O)NQt1Z|R~9wsBxk!1bx%_BB265huMRP_9aY+bnAyUVbVk-JtzYFo=$c z83dsu4BXP2UE(QG!EWQ#er8vKyq*GmT%*W%!4n9VO#s)Jc8Xg|4L}0l5OwMY$#drt zVeeqCy$@V3@sUOSB?y%jXkS?(j!(Nn>%GzGJT695=_mr4k%XbAfN*V{S$$9g5=M!P zO9CE5;7$CFgWv}0Es67_ZSD}VnS;W@!WDB?W>6~;^Jbz+00^1@z9G+H;xS==2{nq6 zDT8;_0$d&mrwJW@dIb89I+K9cY8JZ{LOIaz=**g7F#`sh#kQ=2n$(6YYbhuy2T}P6 z7%hI!`b7_DzEGmjWJ#uDg&Op$venrZJe1aSda292?7e&cDOT`cCvDFX7T^DX63kuB zgFL=cf(iz^q31(>75o}md>pt9BxW+czP@vxN;VOJ9YqQ7bK`S!v~(8fK&Kqdk?>83 zZ9tpETGQ%uWrPzF0Y3?5J;c@3RS<+sA#~wz>P#fCJYpHpJ3=-VFD>S0otd#C6alC@ zdZW5tnE}5ExI!47djhkk4gQKn0ztA0HKy$ZYycVTDqH^>iq9`4rPuv5;lqhy5L#ML z2EL>vg**p5<7~UFYgVn&D4E=Q#*@^%_wL@URhAbE7i;PYP|_1BLGYX`0Gm;U5ZNyw zmH|+}Z2KD-hZ2_Xc7$L6>Lu*(Dav*uK+yau_FcPpD2eK+5w^)FT~Iq=8{8(2@Hzz! zia?3^AI#s@0lrQQ#v2d~f@QN8bBgqSoZEwmvi||4M~wz4`Uc22*?R~)iQrbQ4viIt zqafy};BRrvE!}m3+M|}}Pq%%=+}qffYs8Ypsc9{)y+=xFjaYMCU7Kqrk9}{Ls^N(%v34A+ojb>fvpJwJTo?tc@_(m&`499B#J1eViaSrWH z>CoK}u1up8vGEb%l+m7pDd0~D^N4NQo3H|Xz1bk_^4*pln3&hp8)~5*Bh5mg^^zKR z+(Dw7EyupQyL%snbR{;V8_G~!s=xN_$msMMi? z6^C$kh~Q+)u)Uy06hIIl5Y7(ReG`!gp_v3u)d?VVK~ZKAJf)#Q1+8}RP|>%9 zoZw>&40J^SOlUtkhWjO)X?A$&0Gs;VkFQd?Ph96n2wEuFRIv`!9b>7bnieW^G_>;b zq|KX&LAQvFG@%DmhT$9b>6yv^!vH``2RYw4nZpaa_^f8#3W4X)SQE)Fto&;MERYaG z8tAZxE38*!g%5Rrxp_q6b`ocQ%*RWH`@Wd##H(#i80u@)kjD5os%GFPg!U)V4HRZ0 zBq=C(h+8Z6)z?^t+LjpcgGw_Lio_v_8jj4uBYIu*YvN=JjBpw{$jM<~5v+r~Oi_Z0 zhYmrwDH0MJ~Bu1Z_ZUf+;G;Axt&Y=!aOu7*4n3odBZwrZ`So z`Ftx`!w?BVOx{CSAt)%Q{LXpSs-WuStFktc@rtEivfu8Q?y4N!63qBixrqP-NRWVm zj5~$_+QKE=_L*+YYb{W zZBa1cNlbJhABYD+G6&6z>Z6;6ku8`XDuY^@DCl9qy-XHKDBtm~g{?m~^54Rx#d!j- zAwx(8s8WGuYArgJj}%b5Mi7TnZ5G8Qn8n)k@c038%A=A-K~2C4fGzbn5U;dv&c%%* zdmEc!s+=da2fd6P-O9ER3L_u(wdNeu9Bs`^_qKSSDG51$wLo5$k7bW1SA~##Q<8!syGt+_IV4147~_GYsl6(yS??nqYnOrid-CTMQpa@YK6&yKDkiIgQiL;)5T%Yb z5Se+cH;`MWRj&KYYuFC=J!)Xs*Iu4GweXBj{I*g1)~S$6s!~j*z`?_+OM{vksaZ}O zQpYan+ACcNT#)B)Igk8wmta!gV|uDu*6O0%4Q{xthjH60BPEuemcAd+jznJ@M+F;1 z!qb(R`{C#sdPl>c=JU@o$hS-tmIBv0TR*z1H=S3OQTed% zEStKO>9l+2A)&@(A>Y!Nw$Ja4J|{d!pM){MHngG|(#G9CmmL5|>j&=Cm#=wcIevKw z(XoeUFnIatvGyd=SpmJjdwla#g`%*I?IO~Vhwj$=7IY@$%i;qO?}R>e)k5c@J=+3Z zHuB!T^Fo-DC4I<1{H?)x&%MDhSGN5ODoR10Pqnfs_|Fe(Y)4K%j)7&a&9T|h5j}qP zq4ber5vbz|LFWOoR$3OwPQ(RntQ!(noLoI5?E%;{iXk4nbPl1-Z4f-T%v z?vJmtZC7cm-y3O41;($MaaFD|Oh_3`DkSMbw{asxd8By)$K9;m;pHdzMxMY`FVb%F zeQMsiIWT_6V(C#sX4sd2KV90`imsN0?GILt$yBMhsF_$E$Q%3TVV*>KcTBH()6ZRm ztwqg)Az%B1?LK%KcIuX#Uozc?{)th?q`WT$VH_IMW0j+eE0mzZI|9Yu_(#zTJ-v%l zjvFpo39KZD6x7blZekxoPvx)k5XFBcX~qb7a9}e6UR5}lEHJ)Gx@u; zLyuBPzp^b)s@n#!i+}A{zHR&@?lBo?EqRX3b%UeUZ5|buid~Z)d{bjzNzUN&+g=9m zVkNHe7dF!e>sp1Lh6s*iRlT;VTK@4DX#8Vl!>;7!}`3|=)?YjS(YX3<8Ko&1y20X z_JFmpWv%5}r|+2VOdp)Z8E)x}~lk=XB;Jipt% z-%DcP^1BE@s@EQOCbc7YAH2rGJ6ABof}<-G_~VtSTOM5G1C!UDasY&1Uw8Qb5BxJz ZrpIsX$1*sAv&nl>BrlwOdiv7M{{kbBmK^{9 literal 154146 zcmeFZWmweh+b%k!A|eP6ARr|I3Ia+>qo_0};LxFTBi)RJff6EJDkU+1z%Voj(lOLf zLrM&tGQ<$;p66ZfdjESL3;eo2><{M~-oH;o|L~aaJ^k|QZ{M!GAS0!`^5st^hc+4M9ltdJPT6B$-sOZ>q}pClopaJV z8|6=Sk|ae^B}bR0h1NcVr>2@Eb<-$7h)7TQ!J$N{n+w$_bskGdqpEtcDMwm2UihJd z=+s|=JpVvJ1F`8q>8$cVaG#^iFQ4UrL5NTOVfht8~<|C$EFhk05uoILWs|E{Gn402mBfX!WmsId748Ofc$ta*v_EUdU|JX(tf;+4S@ z$a(7AIU+R3z~>9~+)Uy`h2D8w#edsUMPkxArr%p}i4-td>+6@!p0Y87=^OAWK$1Lh z`2oU2h5TX*lz-WJM?Ubm2BTnWAqA}3f;FD(FT*p@azMIhQkzHBAcfPjKch|cyq9Yk{4VG5BuAqv%!&#|JaOYfW!)#7%ui_Piu=&MT7t6 z`}*~(;7V-Q-v+SzcYxpVv=IE>p32r-C%5aI|=J~uE%1IEIwf(96+k($mt@(Zng1<(?L*rh`f!q9m9IdOT6W(|Iz~$=1 z;$G~Zt!DP%mW}D=YK!}Ef4dO|9N-3#%{%*~V@M{yeBrPgDaqd5-{)aInyS}pU1aWD zv3bqa)%tD;qkLFSmp)Uwlr-pM+hcM;*WudsOx~}Mf9O+NT(9G%M1C{+@jNoaqEch6 z+GE~1Q)nojZh4UBC6qLftg#NdHCKPgK^uR;+mz$P!q*{6EtJ^J_9?v#;Tix0D>@Lagzo>yIW{E;((9)udheYmV{-es19q zB3H+6*k8YXwYCe@UW?|}iz#)&w7p(;EvI4NNe$Xx;C6V{E`U_-Ob}bah84foD=-z< z8TT*v%=ehDXWOQu_{ED&Wp(urznYp z;UanJhGe7V?lueEUtj*2@985=>Y=sId2Li;8)>V)f`IdS_A^~vX)+hpKdDF3J#S0(-1CN9?~mzZ?OdopPdBEAM`QnP&HiHhJyc)!_G?B{ zSKQugtl7vU)M%n6FvD|cAhwE)dELIRT+pyYnMX6pw)tVTpC^;JW1RaDN@nZBWx0Ue zPk6c@SM+1ORz+&rtG=5vKiGNoBw3l6`KFsbMk&6zkTIn9lAKC?H{i{^d#^AK_4R@+ z7?;}UP~4*%FGzKO2iL4?kWqenzV2kP`lv<{t*j{=2#*3I!_p+Ys5 zInn&wc+L4OI&#FB3zuoA{0nOK?U|%JzHg;qQ~WhAB=C)YPJr_6tY9WcTPcw30}$C~ z+a;@+tn~DDxfw{8i4;fghBxwd#F)BJ@r_szHtu_^jR&@FGehJL))iO`mt^LSg!PVw zOFK=mYimN)e%s<}?`yYexYc6f-eWtjcfAxk-76dOD?pYFQF)7&_n)-70D;Z#<_lCd+oJfcH{fKtcPv0Q=~VTYJSYkX=(1ae5c#^Y2Hl3 zKFq>$yrp;tJHvDQ_MS7dAdeF>WQn15tirSM()bZ0#DRM??v_PIOhdV&mD7i8nt>%+ z@;I=Hmt#2wm~5RaM~=KFnWf5f3TH@3mCJ_R3=})U<@Q_1lfYU^jO3SWMuPAw5=@eA z>4)Vus1&zB6K_fWHwZ(Y!~N~v+Th^wy}6h!flu2bW0gBC72DQo%eUFDL-XQ9Z68jA z>3~4jtg()Nr3*I{Lle19)xWm-wS`j4qbi6a4B5Kb-;&=zr5sqS z1~xku`rg^L#|ZH6$gjih#W^L=iNh~xI)^%mRURLm>Bx9C;NO)ryor)O9&*Y>-y?aZ z)VGqQm!}{3-R$V}glW?BdB+8qOV!4Mo�)uYD>-*LJ%4EB(FIAjVZb&kSk;Uc=|r zFF^|AgP^O%>qy%|Bc#tA1GB0G^a>j-zYslM=yN|x6xN@kH?~`Lc%(2)Kg+*&_&Yr) zC|HtZiT`zgg_XyV&eO%vWd8K%j8-QiIDOq%h4epP%E zzZG{ZjnZ#e_T8E@5l-gONEi}%%j$$msh$g%-guY46nuQBonx6DiZMIhiS7a(WCDS) z;dKO;{ZDs8*UC3WL1SC&VEL8_&{u>@7RC&r2S)RaMC?wqAHBu{$Go-?a-x;b3U zJ8D+aYrb>Tz<;7|lokU1#U1S|P1X&x>-N)YEKc>Knv|t2`%zzM%}X@|p=n{uJDpr) z9o&@dN@{oLR_*&B5({3v1F`anaYIKEX0b;5kY@F&NWkxI!=euO>7qgUXF+4fiz>0rYOL(d2< zzNRfk!26kLO_6mcv(EKH_iut?kO7zpN*GFwsA+OkG8xqscQ`hr&%-<+1ztqe;r1Y%RrpFzdR4v>Xgu=HZ!AAu&`PTYY<*Wjp4Rf+G9UO|D0V$ zalLO+UVE`&B+=YtdOmnN&qRkPFS>re79ksOrQk+&M7wU^`_WGmdiB2ptaNNYs|&h8 z+sF^RHw!FSY^j*U9Q&QVyHu`-*=Pad|VHi6|(OgKZ8*K`U8GY*@^Lv~DCd zr4kR)qpSYO&S^=1khC7THh~+WBVixjicY_q&^AbnDRRijE2WmZWYLD998N{JL0|Uv ziywyGwrrunj%uHSEe*i=tNnMppd*W3Xd|Sv#>OqbQrA?uz@!NWM9K921y(85l@T!~ zv&C$5yWi2Xz0KJb-J^OO^9y}P@3O_vPcxvj1?Mjxg;nFHk ze<6*yVEpFPbU)(Ozb7GKSn3#GtgoYV*u>(}GH4c@yhveNlU2DJ#w@0KbcA2CTvOA6 zu!F8+IzdOKrZQ+#;he`?4v`96y|dYxZNr z7H-+t$cg3H+$JNNybf-N_%zPb@j-;hJ&z)d^=na@3zdENWyw#=pKcSI!!HjM-p;@+ z<`%U5ZVaUhgx{H0Vh_G?|NTEdPy_D`z|~Nf5A5_2G=5uPdNlefmxvKXIbQ0NxK%aT zU&W_Bk$+hr1bF>x3y4o1GYQR8BGmMO>W6La*d7)*h0CI>0HE zkoWV*Xl8RWLY5HwFRIt;DRJUdk%;itDBgAuFOm0)Vu~FUax_ql8Y_|weu4E6{lf=g z0i45-G7uizl-^trKY*GAHqC-kZ*Tjug%WiUL(zwu^Q)i6DiDVblD=6PuE=X9dP|dm9Px`uj_Wvy4k}UrgDJTh%qL|-0?3{q^ z_lVBsKP?0OrDxa!x=p35+Wy&_dc(f#T<7;Wo8g{4dTo8GAz6CB{_OHu7y_al{uKQ| z&HFI$aC^{dyvkSm>s>P6{e`R*=Ta3CbAbRfvAPqp=+d)HicpRymSSfvlN{aa{;6|r z5(xns;UR&$Q*S^GB$AzMT~Xlf3o)(@3f%i0Dn$D_n|U{|5+4`f$D!^Oo5R<+)zlUr zirvMXI~QdwXS3q_(@zdg{@gFHw-7gl_P1L2vGv=5Pno|zoAetG5+5fPDQ8Iwqjh>@^a#W9k+?#>-w zr|;QQma0YyOW5nLV=Efnv$eUMT38tvVr==XSz+WE_d^*F7AnUK-E!g{!3!C&pQvCC z{on0^+`kpXR~96PhIE4xO?jiS9Nctxd>SYY?|7|_+I1bVS_e1WIi|IrtO`C3@((H~ zjfgNedc9)n$51k!27KR}4mG#~*jH8~W$Aoss%rDn=P5~th!|Tw?T8YjQ@aDWl!Ji3%c5$FY!>SQaf*e85x+}?Ut;fcmeLs(1tQv~V8kE~? z;SvT3j^wl7zoMPpTXzJhVV=5unHt4s_@omA?|q<F6NVH?)hStH(<$EV z8~Ah<@n!ncM6)9*V57>cTI;1@Wk#_|W3YUX>FTjX!AibqhFg)i%XPf_aTViC0v7Z2S|YhJtUGr_+_#g=H4f>-sj@Mp)w55(GjoA#2+2LyDmK<`oJig{a+C|H z34r8rFIRY1aCyl3Zl+U(MmgBu_F1>kuJXAR{lh_`-ti3F<^U8RedL`K{8c){k^#Hl zsXP#NL-`r_qaz|O7H6u-+Wk$n@`cRAbk%6SQYpj1^~s+vuZJw#g<#1~FSd0lEgwMg zDzS?LofM(xSV@v({Yz!=%f+#*E)5}6v!F)%466H~r57(>I{o|&BO-BxR6U!1z;f21 zaym>pT{}ay3o`TrQ5=*-R7hUT+-7gOo6!mvy{TzPl(e2wLRzD~!AY4U-KY zbj|RGj#pA>#1}|#t4>-<(%bFmr$Ik9tFwWDnvt0-?0dpm-lM+hSQKhdN z@m>RUeksK_CtpN4E?oX70u)uIPxGT|ne{P@zb@q)RYI-fIE;6m3Yk=RTv=;3u>CG= z*Nb)A|6^wZ$s5GB%?fQ{QBeX$r9+sta;)hVBY*dBbV^2*!(e`iu)FN(i8eW@Fc)M% zAP$3Yh+V!W7T&j3@mXVPm$^C>g0;jWdvqT%(p3bV#pl?z#)$HEhv9)1Dj=UiLHJ3C_ zUsThy#rea|Jwa%5WIR;O-jqC`tbE56eJxikpZ+PGmpwyEF# zD@t5eZD*gHYPngdb4P_Aw!I~2QZb}zrPSv;@4`PXznD3@@cJCRxP0@FQ<5kD!dJ!A zh@8i{Z8Yu>&p(Yc#)@M%1K)kRtXE~#&eKEdDI0#!J4$rFgZ6~ zS5>0{^SM<20tLmb#y3%p8P4@0Fa38`or?27`P>$zC1>|?65J7tqdMiaVqu6EO4X!B z4@p+XOq*|dm_>1PuyXd#s2-J2_&vOGC5i9V8(m6m33W*Qhp>0Sff*HM=`u_nQaw+T z-+zddj#>NU-t$AdNFhk_QBCH(gr{R8M~{T%P){~QzB<;we-aoLl2#F2m}UnVEwWPc zU(FY%y19*_z*Mf)@PquU`0ICE*7K}sBzeiESU2fsX4R%0cC*1G*GhXOv$O{viyqgj zBtjvB-=CEJ65m-JItrJ)Csi(PUZ-fSHp7x+W(P?ul*|d(E(WOQaMgjlSsnyX7sD|I z%?ua6?)=gdJsu3OJlyxhIagbobw`nn~~cVdDMSw-mR%kd@j33~VO9a`jVg zLCwg%Jz0^nPO`QFCZm09u5j56Clq6P404v?9}5iK%;^r?f0iYSt+Mviu(r&u4pv@* z#rI26=mPdT?BZ_)R`f|5+?2a_Dl+UE533|G@1&OBpHFKK(<_!#(!>_Mn=K3*Wq{vZr+xc+z8@(rDaeEq1xm%YR<_7q%uO@gU;>oSs9jLkx+3&(C zpENYhX>IuHxQvusAC@bjHA?Kd|8f#Bcktx&EF6XhJmQ#35|b9-KJu`Y;tm7LpCH?= zjTJ=|Vd>Lx{;pv*rxA7ZwAiT?DTyG@Iks6AcPOSQao%jfdd~9x9?ZJd-txf_%R{h1+36q z(b4>=c#i_)c@p4Rms$B!?T8B9$j+Qm12loj6~>DfIn`orM9$CK#{N;_Jst8tJ7Y;i zY`&&{s7?io&I4tvcBXp#`6W>OnqFX&iJLMZs#E`OEkMxxss530!uP--P7AdB0W9Ir zUpc;UZo=%BR0uy>Zk%rZG%XQe$3u2y=B$a$!wt@n|BY}RAh7?*)%>Y|QdR}j%;{6- zNF-2AeXrGo9SOUkWc~b9fCr`!0zoO0pi@sJ^lm>17aS2}P+cmgKaz-FzD zmkv^?9aZzUtT5V=af|(7AfyTVdHZ|F%Cg$_223f&##rX6|^cZqX2>E+Uq| z*0wyoc^=3KWS+b51oHuktgGtG=GD{onuCsT?nLoQ955Ib+%W&~gqv4R`GuA;aBG{W z7Jyczh@dYKHx>=~aLW4h{^5YsT)D^yhL-?0(tkQfnD><7IUsYazztl!4vwEIHd%%1 zMABn&8=RqO?Jsp2BGOM_zvrq?bI2}W8n~v%R+?uyT{om+UvQbhQBCcGZ$C2q zBH~@A7W%R>0Ic{)!o_rBVvJkQt7EfW(rfBFeSLJjtHZ@gPc1Amz+c1$I{WPp zT+qumz7E*!=(@T(4^$}2#gh(RMh9p|L;eIP%#sxSzH>PB$PVzr>{)D8%Mj)O7gyTCL zxx|ws*l+2BD1Xa|VP%T&chP4S|B19=jw$2{UfJQ=Y|NyiwNSNU&(_l=M~72;xWtz# zrr16-4iS*zz^Z@WUwb)tH6a@c$@&M9)sO8dv|X@h+SqyLTF!S{fjPR1--gQ~$w(o~ z>IU7h6sJp`6d&U^iwvKKf_oK;WY^;d&72Rt-W&vR=;mLiz1G=27c1c+DDd1RKX(1A z=gKZJ`9s8;pkaP#EhSY~XEWcl`*wCloI{dW3_s(|=sug-0NoEW+dI((tKT_Z6CY|L z+8#Z6RAi6lvl}Y>3Y0UMi)_``>>zomRBST(QeMl~Iuj*gySpFy0>Z}%P`IUL@blns zvsTPrqE(`JS^bxev%Mi6D|C z-5;3xOpy&&`y=luDYct;k8uO`{1X6=wK6r6`RMtfpAYu7+CfUEv(oVT#3Uv~2;>lZ z4!_P`RH&mOHlT;d|5%6VUZXM3_2km%_3t44k+FquFM(%S9aLBdXQ^4ed;c*ik;f%U zYa}|FMQs*AwEm9r*5)?rY=+y<(OBE`e;{W0D1V#P7*)>l+1X6)l}mqok-FzgiChhv zi#F!TSgtUNPAx};QmcNaa73iCW?h--TJvhl3_>Scc#+mIyA2*#hEfG$62I)Q(~(a& z=DZGGGeG~Db-`Yq#PUyg=Ul5OMpV$ZtnhL%H@=z8wo4Ru`UEO3)m$iCo0XXNJWQ;6 ztjf3eJiFW|c_jdOqxDRE8$*i1Wj5cwdi{Es1MJWmBD#Wk6wvX=O<rfJXc%i56 zf@X@$FfNO;&{;?}_$VO$mPJSvezj8k3p!_%Kz!N{tY#l zTh-NfaMXF%IeB!^xX~g-H$IsWawHQ|d({|r=A~G(r8V|_EEQ~!t(+s!W-W3%@Cd`^ z*L`rPee0@1NWYLZcC^6j;)1q7Q0VV$zp%|(8%sC*rkx*AFEg=p7i6F~nCr?F$JfjA zJ!u(+h}~!7fDyl6UTn*%fwA0@n`t*N)l38`TO9QdkYwA(FxQX1E2g7eq5IQoPL%u#z+Ri31H=+}NdzIZDy zpr2R$R)2@te~e?8d0%xXP(hb5O^>l z6@$i6KB3N3uf)Cxm{yg2U=nG99^3aV0lJtQ&?Yz(&|!K}+Q2t8^ojD^Z84=z->_2T zUx?O=tlE%t_PVTFQle7v~K4F*P(F`a@s`nqpjmmqkS@FZ@x+GvP~tzjO4cC2>_QPRi{^@F8X*&VV1 z#p>JzP=TFYJLJ7zzeXUtx;`It_g#yZ1IsdJDldr=jC}>3t2J~bO#`u(Y>jbfW~!^* zt}<*(*m@P!B?%`gOo@~R_?77E+Wp0=R4~LM5}3Jg(1DMUL9Pv9bNz4y)8{7vBx+=c zbao;!44ea5hf_gM8|G=_dr1^T4#?Ty`)Iz&bM$;eBCu`saUcU2BX9GwE_5b>+x)R- zXYRtSUY^#xGNJDCj zQ{O12WuQQ!)c%fATezTh)U+IS`P|0>h-SOKM;^25u{&SAv%l<6(uB0tmI@2R!QXA; z&R*}q-hk@Q%?$yo#*2_8Z;a*xI%fSWZ>z|vo9!LnSpGb&AnNe|AoNo~?U7;4A1U*g`wY% zVWYxxY55;E|K(dOQf7jXfko`I=_Zwj44;6{cH$CQkMpS{O@oA(2Nx>N7z zl>bT9*>guiP5zM~^ed=ZmPkkGA(hUr{=7P+3^|)_0_uqs>rqfzm;(9pGFfQLKc|#M zle9!g(mL<)9@EveL8A-ol26&=q&bkoY&w?XFYa&dF&ZGzGlM!jZPl{-L$~N9u)=cq z+NsU?h_5&lN=8iGx-*5JMf}L>_yEnAs#;J(a+OcYaHOQd0C*K{I$n(_dH|XT0ZAzC z(`!rS6Wk(QOQLR%;(eIHcJyj_6d|p_x&GxjbsOG7-lMgM`#g+< z#9eJGQ^Rt#V9RMjgIVmeK(I?R9k14iyu;hWh7JRq4Y7IbdLIQb8vz=6)c(`_l=au$ zCk9&hB_B#gzN_q2_z;iE0<)Yq#|ugp856MQ*)>OQj6COIeZ61F>oQcM)pcBxV~)xh zK!r*G-nKe@ubdER2{m+?8HWyu`Iy(<$45e~fz}RI0B)S;@%>`v^6ulusL^r~%ifs+ z$H&h3#zC70#mB{Hq{AQ_84Cp7Msq+{c5rxT)sjZ7w!Qe6Tlk7L5N)>%>SSTo1P5u! z>o_^dW)QKa5pUQ6JH|Jc`x<8O>^7})0>K!0-YRJG!*BN}uG1u$F&mprINCVjip8Cl z->|o@@gJ8hiy$Ui&4Rc{*!>jR;+)b^$?`%ZY;2Be3xQ_dc9@C`AXrlIP%rrqG|jqa zgTJs-Af=^H_mVUpZlvV5z0A{)8q{@Q@>X56=!=Z+%=ROp_Mr{-_6Z|oxko=}dYMfZ zIq5co^lqKzxjH2bvG2Xx%_;uNWCNoreYl9uY3tLHd(t(<%45@&ba5=kciG>i-NB_w zK-%lTpy<<}U__)Gv6Ptj*xsRFdZ)wSW{uwU6Ame>oa^eGNog%sH4VRk3 znj6Md{az;N%1@Y%e@buuDHdu83--<|OJat^-Y~e^6#OUKaiprz*JQ`PQZ?f}{p{?Z zsl=3b9>p8A_QW)^$dtg<*ze!3QTxhXA}3yDyk=PPHeh2xspz=w4%6*j3Y)WeQ)^1o zoZd>THF_yXnZk$^$(E^9?Cc>45-t%{f7+IcY*hka zIV&%{CrY9NjgW`a2YH(Cgx+$UGfcNO>G1}tM6TNMa1I&U6bYB3`hV<10I3>ZM@{V~ z^Jt#0L=^o4A)-Y-p*yXMiTcfrk z246nmh>#=>qw$$IBH3(-`1+|Gg$k(!(zR&09r>Bfbv>K=kR(ZmcAZmfhQplz&cVnyByKk#dyl#V-&yBP&Htj9&QMxA!fo}F*R70{ibucL zvl{ucru|QcGQ=3Zh~7LJqNdw60gVMrJ4Z&n)p`f|Q4Y)yA=3)un1bE0Jx`z%vYdRm z({7=ds_LP}MaMH(yE=03$+Nkd3o=yC^XQZ>5o2$mYgf+)d&U4T5;-6Pz@rmqKFS*b zFmBB|sO@BtEonP%CEks#cJ5FtN*r^$pwiNdi0G)x-7Sg|78n>fTk7SMVGz~ydwPir zWQf{T`j6r_4#S1FF>C3EaHMi>j6l+1&Cx)m+xq0!d)WQV?epei-}Z`CqJ)^1K4j~p zZ;i=n2UzzOzec>|`N-UM+%YgH?X{}fZ{{o#UMubtL){cOQ$wF+Ln@* zEj0!{HHHS<3AbA9X|6JwI3CEd@9}CSaVVw+JP2cx=9vh_N00grR>vj>yAiFcq2&27 zolHN?v<{)5Ph;hf)w#Aag)@)HRaJnCzV303ZYkz5uOk^{NXxvQ>E|J5?(AzMNVKD4zVu)}A4(iMOVid%}$uq#CxJFsyPQW+iFbs{`NqreeLDhx!ZZDZxd zHBlHTk4RukmFTVYV_9B+yJ$VzH`6*&_rsN&eM_@Q8tjOdY|y>vt?7ZH3zy}?ckX=! zDYA=8aITDf7mvnJNn4p$r^C$3PE-2rqZuHmV8a)OH@C{l`q}gpZGjr*zo5&LkKE;- zmJ*0Q3Ktl#&PB_}$Xmf(TpA)V#v!{}ZIlvV{VGY?dwe0+W=X0vh;_S7**|}6#FGWc zJGs}^vr?i1dDhXH^azxktZ_}e_?Q#X`WGd(qG{6#$Jc?GDRwfrYNX}lBrjl$=&skW zv;xu61_)Fm{!MW?zlVnO@x{yw@=u;T8Q;yn#Y-xq3XrR`n3q3K!M$Ojq`{b2gDzZQFqnp)Y^}fef_hFRy+teyee~LRF_<-z28p6a`^qkGMv6G zHVT^>Xn42buGPv2)#d`$OnKY>vDFaWOYXU+kJm@GSuD3x9Wt~VH=QYAS}9TjADPF^ z`GOo0T7PLdbd-f#CHvxL4ONK>$NTU#tG$bJ473+7K0y`@Ozw>BG`Ech;!R4bJYGVG zuLH<>%lWirNSrIFOy#CLOHy_D6H$FtF{MVEDfb0&&)G z-=YN!4oHH@4=lYTr>xT_1q_dcQ*eRd)d@T-sU_QKTw6SWPFR?c0knn89dKfF(C4>K zyT7^M7MKXA27}oM5CD@#;akclhZP!Suv%Bq6rKXIbo?wh(@1cp zl5WHCq9?D_lmJa`t96h906~)Qd#ABf>kq&eF2;`ucK1Id(21s`APzW-u8`2C2r&3G zm-&BbN7|KMPFn0d)2B9E_Fh#Wb4@5Ky97?)KJ1*ii-F4AX%~V5 z9I!7ta6C%j;$d5?YVrbJ=+oiq)dnRr?;q|=r~$zEs>^uQyH%5Nw~Uoap!0ku#}3-ElH`L;$9<+hipqzw zoNTczs=!F|`ay!g5n)BVdI5&tUgC}Bd;AGRdr8m<3@T|QAOqkAopL!PK%3G&m%RQH zbO0UgPS@NPj{0vc073q!2E@C&;N+M)aqhjBWa6vF`2 z2!wV6CJ}4pA5R-;$8w$=CVP)x2W{ADfA&C=o-N5FDA#2L!hai{e1VIM!F_Cxg%N>P zYUtTAX~@y!OX~DHFUh;IP0CzNWr)5_ zF6T_9Mxy0FozE*KC3e6*J}#zwdV$cZfPDyf(=Y(90+sgO5s%y0mOTCGq6J=t+Nix{A8 z9jS>CFtWyG?rg73m?eli**E9jIrw~ChgR|*K3(OI3#@#Gv$+;3LjIrn z%9+$0?K>D$`TT&>2tLmFJnyre`pOBY{We>9Q-=idwkuv~G-n#AH8(Z2&9p|XjIhfE z=(}ONoKnYrSSS%%jEiwXXSI}0A~0J@38Bzy;nHp5vzGTbib36DyD|VzeD&&83t)PZ z6$cmlz+^BSP(>3UY~FlK(n#QrOtbEM4XFlwJkh{zMz-udrR}OU8`R-^12npc{KV$L z!NDxXi|!@w4Nxsk19|Z)@?6vCLXm6NlmV0xI1KUm_4eW>eytX>7k$G(xY23=hO5D^ z7NhG4j1vIeT0rmfOsrW@WKWus8t996`ktQ(x~2RFOsY$A^ift&MbALc%9RdyF`T57 zcycfy)tV@EGBUcABxEMHw>@a)^yR4b_)tsAW8NI^nx_N0g;t~%#V4g0t8s`y(RhU~ zWBPOTP|yxGDUTND$1XsPl1FYY4I=*lrEv5cHa}%RS!mcmvQr&y?gB)&4RCq3FYhXS z$AeqSn{ysrsE=YEI5K8}6zKoB3z{>yK(msM%y}6(3Rg zRC+8tZFop!9VM;=%XwT$fxgR?5lW;l)gz}Hn1<79mh9waMp5&aF)6PV2Emc_*E;j1eRM`6@ z`+xocY=O;a1RHSsEa!mrYpN2uu8n$f(WzlL0tFWFkVIAK;r9v90Al9FG7TuR`@kDn z{_cQTP5V=!zmqblZAk26QU7=YIx>|PhAkmcP$g#0#K$iqBX!u6k`jOzuh z%t@w_tBR24jsifesgEQY*m6g7CQ0@|UjuuqbnDZQkQ@^oLO;~uzZ2!q7|Mvmjx2hD z;xY<=wZ?AoDQ3Au7WTxTW8n-2{&u^=(8&;yV?!OzBB2RnzEXIDUU)d2TsQr+2D5$^ zj{E(G4<+bgsUW2cCjtw1DGz(hGuRd@Bm}B{K`I!Xp=3(DC}aS|`nbjL2Ye|lx(zhX z)3UnvdhM=1A@mbjHho~iRoTe$4sEQa54>4AIcmCs3SmnS1fQ^fS~?)Nd3YXFc&|Ap z*B)%DVa2-)53{kog8GFD0Qb`|K0`%HXp}`#; zFzA6^o*qQSwkzmW>SKjuc~udj?G3|{`A=}T=S{!Nf|No341q6E4c!k-P})b}S%&37 z0uKXN_1*V@X5?;5Jcz;u*4^s)Bh-d zfO04zo~V!*bR_5k881(mQ}T8(pvFR>>x=SX?oXnO&QrbJPSPJc(nm#lmY7s}4b$i% zuX>T@5FUGw3*3JjQV9ALJWTxovsTIa3?u}-|E>p!)MIT@e4CtIk_vx&;&ChAusEK} zF7{?}R6XoIlSKY(>wrgIaWZU`dG`BWrK|^d@Iq&K5x4`!u}}WJ=_-VD3F~R7kvHAf zHt#%E!CfJ5$L-;U?hRx41cf+6U@ANv_U6;V)4|_mCx5HfM7|&JHF0)W7xipuJTW7b zB*^fZ1tjT!V&~sjo|;WGH(u?(0>!-jYSdg(tr&%&1T=;$_=!P08dnGBqafqpKYJ7b8&KVx-Ir!kC$*s z0lDWqQbk3@`>F0gLCo^Xq1KP5uuD%-A6}2nv|*kA{ksGw$P(tTo_pxfXJdD) zCA0Ag-izMyrDV9_(+VB31JYEgkrF#DiBZ>=pt)JYlYbFVtyj-|*><#*P1^+xo-qM? zo2{#3l}@&Km}d?!t&>xA4q4!$79LHrf#L^8Y;X}mkOYteMlbhq?Z}^wLA$<8f@Z{4 zQM=jq2lHjA{GKJz?tXy?Mj=y2MNKNcGv_aHOa$TKRHAg`Ip8sd!a?Vu!aJf5(Gic3 zUk1!QfCV$1gXu_{!u!#;Y-fwf5mMyvBXoq$%hAuo=2UN$!r26t1`8PBhJZo#Y`2Av zR)aRW^amfA-;K+9_M9{K?14vl@;f0GM$3z-(5z0apSW@H_ButDDmL^#LKgkVi^;3N zlNyn?ESiQmBmtDhe0%A#hq_8)m!b*iX-ZxSn(yYs${^hI7462v6GBc-_5DC8(U;aF z3o_^xGC=Iuh<|W4%j2~3>+0`Z{xSCoYqy*G1n-0#vwnF*p!DnDM!vUmog?JLsi4oe|YAv{6@3tcP+)-Xd6sW2Qn1X3_m4SHqaIsB0Xf3;s zavE?-29F{*J7Y@W@K>uCq)W`l$TKJE0DX`IEc@JSdmGhuLG+c^l!*cw7PIS-7Mp!Z zX57NUd}H5c+hQV@3Uc}TYE!LB_d2)*5=1|-R(iE}nfUjP@k(5QDRdEP&-kU)RRb5$ zIpF*l4ye%b2EPCDYF|RP=klCT&2GK3;wg{q)UgsfQ^0afO;i#joUhdi8ej3> zVWnYbkJRQ?Z5cm2BP{wx3XvcAf|Ja0qiDiWzVSUnWHGi&&Bw59g;4c zA`VTtdId4Sg{p!l!ub3cic-|_p1S~Fzl`e!K?argb^Y>5zVP|wYm+e$ABBmO&7~LI zMGmNOOBI&5w#fu^?t7pZrFc!<_KmxpyDjO7L6Og`@~?MfC2j7G(YQN5efW^gpx8Oh zadB+FZjiNzVS#sL99k4Ks}ZFJv}12 zsrGmyD@n%FFduHBQ*&$==6-|v&kCnmz&4yBj4Qp571@@$BtGO%LQFiwUbPOmV-7~lhw~Z*frF> zWYD?LNW(#0iRa6X@7}+UnbTl?pPE{5L%(X2yl%&9<(gSAzWTCM00=XIexm}@@_Tm) zsz1|Y!#VZFsv?g@Rm_f)laUn3B)-BkQnFU?vqiJkTj)7PzDrj^WAY<5XY(tbLXvp3 zz8uJS*Cffg58yDl{(1%#0U1<<4g=qL^z^c?O&s2qmaQ2DxFj1v$NrVFq44AgtiYj2 z3|c(fKMQgTB#M1f$*dPI_rk6qTQWP1teU&Va7%TgxS|G*inRA){O4 zm6l(JRrl+DHn<&ndagiw_=C)n9U*x}x?2183GS5hXotaexng=mDX?T!ycc{$ye}-1 z6XDFn%FHk)GcP&B$AGZ4$;Rsp1~S~RXokbw2cjAIED-CWzL zAgVhV2(`1lKS3}3Ko7b=uNk}yK`_&`r)P~hZr8$pBWxgpqBf935zn-5olJEa;J=!n zx$~1&AeL<3m?C*|p&iF6O-DqQi1k|Wc%z6CEqzHe3PzjJ{M8&g|xt zZ20YG0XcYrf;Gaxea>KuWpOZSWDZ|8q|ZVlWL#9QyY#^78pQeGv7w!1*Wt$xAByg* z9ld-r6T27cNWcDZa4?E5nqO6ah)mM!;MM2qoD%ipC(t6I!Z5Ao(IQ_uf40Kiw{WPb zc_|zSKwvur)iTOZnC>Jm2Wrb*EkM${T3=ZzuSVo&3%UoH*uJz3J~r;1o3k#mnFty# zu`;s0@Y)8z;o^Yv@8Y3sym?}f0(F=MvFSy~V6KtTi!E$)V|x%X3KiXsa$E^W%rmZT zg0l4iQ+}QfJaiS^iT4kLw2y>g2rmvL#&ljyS1A0I;FN)II>NILuMb+g7>f|_P(gqb zdl6kRInm;PTE3aW=RRclSxiJ|G(w?tP?fpw$^LHv1>00`oT1m@tFS(xOM z?JENv8F5Geng0Jg$NyuO{=da@tY8@d0?5^#8yaCjn&& zbz6i;zyyXCP8wobPVxbJYdLeubfE#xdoc{ybtWzW$ktHV( zoBY={7+(R6A!=YU|I-*!$3^(&DbpoD)%_p$#Y9k+-sEKc-+us0B$W|_2+~}>LB%qD z^-*Sy_wXh?AaI|lkhz+#D%g#deces8p8~~`Nmr8W5>Lt>Z+vUn&P?MYm$vb0eD4H> zH;|ZNz&|whK&=^UG8ti$FP^^1j1Z(KMn3iSv%3L-f!tc=U-E7ex%3KrVTp(B-t{Sy zq`M80Il&v09pc>!c8_y)V|ogzIIyTUBN!p$GLCk={7=`-G(@n}&FNWcSlRsZFbvXx z3pp{MdG{RY|KjYe!=m1|_wNA&L`5tNq*Ov_kd#tU5r$B@Q7LJpJ46ACMx+G^VHiNV zK~d>uhAt^-qz0G)erwL&`y9XL>~o&$dj5kgd*&1GT5H|+YsK*gG?%~eY4k9Xs{FBa zDB#&$utXYnPjk0f#tD|oy+wmQWQKBi`O=`vg5aPvZ`mUsJ@7jb?Nr?s0@2wgTp>wM zWVC|UqZS#k0``L@uy}KTLcS?$+-!y6$Z@Do~ zAXmw}Ne&pfA11RCKYkdGr+l;j+3^s+oX;IA;xMnG7%!;3H0vG>`RjUgd$Ply&uK*p zp4zgmswr$Z@Tz=E4!Rm3tlBm)1wI>GV{tZ>Hz1$xEr%FNl{4BNK2QY8&qVSCQBG0x zs}z!z34h8aUs=Y2lG7Y&_Y%+Tm2&Ct#Sug;m2@hQj#VIgzgA?~H0DP(*&mkM^w~d< z1@!qU#R`B^#1(S}=gQ!nPnND3fAQ~vWIbMj`t)I14i=GN#|Vk7F@Kf9$M>V#+VvCC;Job6s?-0# zdmLq$WYG5T@!8F~mL!(wq@=Y1yt{KWU|C*KnC>7Q23-i2-Y?o@b5NSf76z&l`Kld; zsk#Nu34|)AxZSjCySVppiXCrx4XbZK-lN9QJ61^7eYCf3&*R&a4lq$YH#XE}otKB5 zGv20-13Uw7vJop}zuBVw>*~Hiicw{;4TJbz>+~6{u+0EVSlIm&XW!}4#p1H8`cXNh zM?i3O3ALwXJ##PF)6E=P_={F%CJMC|no!}4Eb%BeDBk}d8`7Ba4ap+NrSS5d;WaBv z{`2k!`+j6tJs+`L(DGy3)JYy$^iIo*(pk;b@QUkXKjFEbQtDV*RrN%vQo~(Qez{|6 zr_)7!^wQ|78#R0OrWwHu;S4$!WDmS%u;@W%mm%*s{a$X}J*D1?u%XcplN5Ilo@pX4 zmj7HjZptJ!qiAY4*TkslxsT^D!TXTTTiee+0G+Gth*XC4%~|47;Z{``e1~x zu89B>h*Q|o;(+$ccl+-Mp94=W#0p()pX>T{^cT&+&|#0wroIp-4n@Q2c20Wysu^D#AA*t4;7=63m6hAGo*$!#+t1r+Ouf?8V`ZeD;>zD>yI1Mj ze+XnzEy$6G;_njy^SKdc3_ULOyL^1~ZB^jCEiV;6rrNAMLBwcF4X{DnZ*0nMF!CkM z``nXsn~Q`vQEjdl#o4Q`+q+|NerNrl+xj@AabPB5&uuB!nWxACo3~HGn|j@EzrclQ zMg7zwV?EQ|o9ycbsV=u>yXH9$K4pGDa{FGaAo=kdVHf3jPv1I4a-7!x)a+Av{)110 zm~Xo;C)|_q6>lGFlzg@x|4eZ;u6?HJZoarUKH<%D;=a_?@HRDi+ikNrg_ylJk_OVR zy^1E9!!e$<;4)xaGcy%)Sc-qWyP~@GnQ&nio8~{+8qw@I7T2Qjgoggw&73%S02eAj z9>e)ZHXoZZ)#C@{P?05ip@<-;Rbcn@J(vRr|7H*C#RaoH!uBfQ+B{N8MGFz7P=``U zy?gxpk$Erkj*mX+SNC`A-4F93uz5(C{GLpeI6jwT|eR+lf8VR#>>@OjotQVJ)gC_3hR_*_!W_deO9q3mVT)ya`|+v#tO^kVsgWv@Qo1_Y1U4a0jq z>OPK)DLYora1N&i&QkeaiWRF4`SZy1kf=hdvLooCMpEuE}raX7a|V$;5qkmi6JDbAXRa^`IS*0VpSuWE)crq)pLEgN?t zP4%;Y?j;htk~W6al5_RmCx2QT+#i28E->3{E_~1ilM<8Z-FP`kX-B291$QTQs-R|X z+GBgrlR4|No5j_{#%|NTYRu}^i-^vc(``4Hm08~yqv@-Hm_pLUgdk#@dNH2P4Yl$X05 zqSAI$>YS8k>4E^s`^4@NTSNS)3-xJ~TINM4Our>2V9x}d=F+kY(R)m?0(D>Osa)Y* z$@gI3T>WyGw@>~-MoAEdFAakfms?BMfT7Z^yn*??%F5#7=h*@2Q_WFT%csxq=v*{u ziY%Tn?;Z`w(JQ;C&WPd?QDm;PD5`YST`%?Oox5UXvxiDsJ0HQY@RDPm4n)~ z$TD<@+a6lSjhc$T+^6xSVZ6pdM>wkQpiz>OnixTz84 zLk0S;0?Kt76S^x)&*k?Yvbfr&Tk@3U)i$n?+hPCt;nPP9NUs%o^qyhBq77}Qh3<8| z8{OVFGq~FkePTEcUyxk0wl`h#y_)_Bev={7v{?L#)gHrXYj7h*ByO$u zz68s?ksq_BjHof^of`kML~Rf;NOFZ(q0iy%KkX2Sw03%kY^3yj;3J*;D5Us2r8KDN)+P`NeItwJeQk*whf2mP;~LDLm?Ekb%93(qY6lzV{Yyqd%O^MQbE1p z&Y&WWhhgg7HuI2r3`+fzYRicxZrvKkQO3_YWdiPfp<`1~G8DBtB=0P*m{JFiF5=P) z%}1}emVS^rYlUMRvEpF7DXedwcYBR(RqNZK)tT>#t8YeWghO-m3(9*0nS755+{i~M zo|SG=BT+8$vvVXJb9b_o+b5BEiub7UII~7F!>CQ@wx1oW$SBe_m%4?;ep4f35{hbF zYYl7*QO|lHwQj|FQd&g4BRw`Kz?bN3Pmq#%&C5ng_l{B2j)-i1Os0?iii!RR%)2Y| zig_uYtr<@y3fYe6$-m_?k0w|TmlqY9cYlB8kNMi(-p*B1F6Yfh8(iL=8z2EDqCy#q z(dw#UoF>^M%=rV9wRDG+!v)Sr$HBD~fj%iw0;pQ@(xA`}V33j?Bj)H_c`TND#jzgp5>G_-6X3IRC z3nS|;l;}#dwZ?%@Ui8^Y)za(po`6TR>%zgQ1x>vEIUQMp9 z*`}4Xc}~&5fslSE!Cu@H_42c8D$Grt;9QhRXdZ1nebKx-RTz^+ZjzH3?yQbj9;h1J zTk2_0!&&%x03X)Kb+Tqu%#s`@vf`T-iQ#Im=5lP7c3t+d2=11$`{Fg)1&bP2b{?e!#CU8j z6_I$kD&{&2JX;&6DGqg@9f4ibw!!-}w}Hi%{loTnthJnVV&F^DDLOCn>q+Cb)d}Xg z(d^1L_(#4w=8qXWt14=0$GjIi5~-=bRJFXKh9IqY|7eF%VT!))V` zv=CFvBwrM{fq`7>sz>5sUw!2D`M}JR()8S5+7Y59iCbi$VeDvMDnVg{bEYk&v=&ED zxzumsq_&@-%P}-@>;CDL({!m2xc`|>JH@ydpZsA+rAcrzp==-EnWP$M@9V61VHTEp}vaLZ+unNerK}4TZ zD-n44>2XwgKouR`{5wH}4JIoJlwIOIgCPotY>*_FWMkTtY&)raH*nPG>YlO9l+0p% z3xl?-d)4vi_X(yPs=6^*n!SuwvWn{K6l+~rRmG&jsITZG53J49dqv!F9$StYhq%!v zdWxv$K6-f_0YP*ZE!EK!QO^Bl1u8E9;4~gj@xELlSJ>;E)ozkc?5JGqEe?c%b(T=k z4;9V_Md2kMwVMVp1n+m&i6IWfKMK5u%aeH?qLULxR0E?2`&pCLVye)?oNuIe7aZJ* z9P06mWax_qHRH-2%5*E`l(ny-_gKCu1nDvR?2WAzFZ20jQWaX3bC>OY;J>1|bYFK| zVvx%ajmze}_jqF=3sWP9RK1<$F&5BUXs=_K)nSqq{nP0cZIQ2@{Z-Mc zCQmP-ekw5w;XF#Fg`4#H&gC$)_NK&&T#lwdI)~?Z<%7;Hd}6eQvb2fxWqPqqnF{)c zOL}6~LFY99_iw5#{?Z1qK=)Nm^|^%Ax@f`VqobKtSx@zmDuMKqoV-Q+NP_<190@Gz z2Q#I8fg#rSobM2>x6YE}1q_uhIK1C}zx;B0%;g)${yiB3^qq6-n{nw$H2!rDy~mE( z#X^Fh+w|ElSC#8z-3)A%U95>k`NDCTY>wnIIeg9_IobTiVeL);*SR%$E=oPu0C1GE z7Zu}#i<+fr=tVmyO3KyN!da14Q#l^wd5qA4L{e8j+Zxm>n>*m}(J5@8=qP{gk1`+o zc)=0C8&H^PR+zW%PA+Ny80Kl}JJP%Bs>&+gBTia2Rw30?k6Xf>ABso^rZwAgHf9 zy_lL}yW5jx#89De*j5OY`ZK(HpfIJ_pGu-=N0rmEh{oF8wmaR&`b$T2>$2^usjt6I zZq9SJBz~=Vm$&8fUB+tt zu+hs_ko}AMib3`}Gz(@-l`}=Z*9*&a2K{jP`x~GBvaT~+Jh4^Q$hVhmIMX-Edvo>A z@UC45QRvfH1v#o)J*(cCm}W~=m|L6M4179{U0#q3}l z#YdBiIsoD9E%Z~}N3+`kKhv@%hoI-UWcb$xUqktNA6rs0?@cdPp@voGw54vXxG?(vo%G2$x=tZBCc$9ViTGbHN zTPuS$ul&ptj{++_>Vlbf8DwLmM-`qrkLiHL;WA1)SCTv){o&#bht{4;ba{!1kmq%y6~pHBD~)$n1L2h-GsN`G4X zQv0c>@vDUs9XV@-O`DUA6&IWv(lJ##m6FC^zS^33+eXJ@n{@S6JY)N9HOU@O>*vPq zS~=w!S-arkt4ik7YP=V=o_HiJ(dOrPbV-J2#w~Kcj5}6#eb4iAov7FJ>;Cf|PAm3U zI=1r7ZOpyiDt&?E9F61i`8^lnD1xtJ^8%8~Y zMBL_}fLNT}g)dqPZxeZwImzcS-n!elz<|}c=Ik{b%sY9aqZ5tD(ad;T=B=uWPEqHj zRJ6lyxOf+@-qGFjq={K6E1KkQwqX-$HUgcq8ifyEmqzW+cN+%l?cbz#xg1*apTD6a zv{Te+wgjem?NnKEuo(#8+ZQ8lBctUQ*2XPi%xZ{PUe8&4yM0V>Y?XE!i1Kw)QLnCe`spvwnM*g0;!NDHZ_fE)Q zH&fnGQ`1aKG=;v+O(O=?ZF`eCuOpBInh);*w`QRS0+p-rao&EQeCUbOn)t`1ho~BV zM!qPFVv95Vl45-oA==Q2*8cd~>p-g|50?2)Yt!u|co9qTtR<34C%kh-dwxKZMT+!E zsp;ph@}|ERQRa<&K;6VwY8UI*{puL#hptec`ZG%2xV=yw*5{todyYFYEB0{1p;nD$ zUih*z)#J4VIVfC8wVpP5 zlXYEcH$d+kVA6>bIj~# z8Nk--lvrQSZC+g(sH)d^TpsvzK+{3hJg&9!=k&-B*@NBkfXei2Z- z{St=fZW*?8k0Oh93m42Pt|oP+n`n9z@~ zw5pDY>(%q`EaRoT*oMkFkNFf)Z3?yYe45YiT=Jl;2r>HBq`qlZ=hbMm>-`wU(ACuX zKumjA(%Tr;GG+M_ClT|9Ux8itsDZq$W?HA2g5jfhTx~FJIw(2IJO!$k;lWymn_}QGAbf*G)cF@WrrH2g8kOyGdpFS{$DmVQe7JxQqD1E);j2`#@D51dv={F z=vk=!wt$R5pJe*#)5Xxd0~b#s*t5l_ZGJ0f-+c$QdMXElGR{9LCnt#Y@0;(wiIxXW zo4S3Y!!Q;^@#|RIe|uK#E!`4kE$;li^I+)_(!`VCbBt$|1PMzIT-t}}Z=L@ty8df@ z{qvhZ&tV4T_Y-OG;VJRXHgqJiP}LTHME8Gcwfs=44d(x6t@b(>qCNg4pS`O9_wd5JxJ>hpd-wq~7CS;L@10kmb~^#rd5mQk;Cfo&+DuuO3%;2-=;vhLMgJlP4Uv$L8vRqDvDeDYbi zLPofjyOG4_U_Vf1{$~~Jw@dy@{BWZ3diW!n=$cC|OOHiIWc^v8d@8$`-Ql5or{$^&F_2jPKS zV)=piAAcVd2)FRduIsD->A|l=AKG-l`u|dTw4H?9!bm)k3mQZQFG2MB^of5x?*I7d z7zp)ipD4AF!}BJ>FqPhsfB#<}p9mvzv+(jRjPgp*Is~8T{>uDkz45OH`un{zzl6`c ze_+B6-o;t?k;;g%?0*?iOEqC)u!GR)w~6uJ9**Auu(-yLWxON4rw(Eq!Snl9|J&s- zUx2NO{j2s=BD|%W@L&$U5dF75`9Ht@AOce*;{O3mEjk4c=g{YmL z3BfKGeoMfliA%e{BwE~MsUh=Z{nXI5!)DS%7A8|&h9i=f^U|G=5ERm$0bY*@agI== z|5u0)Y_YD)q`}mHmFSi$UJz4QiBq&0DZRk16d%eW7a{L_@_@&-Yjb7<`{dVO6ZYHo zzOrMLm6fS-9|BZLG@L+!UI4nB#%eN3lcD;PQ(=KfO3GU8RvZP5E3$muweC_c+F*l! z+qzXJWc@afnvc1`1vThC`Cm@)uS_KMG+$QXQ(aRE;aCT)L=oc6X;sf_AexZ|w%AJ}i>YTq4H##oq4IaAZVeg4Oyg=rIustZ6M&LqGnB{=ZCO{xeYP(5q1L^3a3^lf@ppi4so~+Tx|G_j9;2sqPdQH*)m1u69&c z&rWrv9S#?Mle>k_eO-Jf`skvfYuqh1g&lz&sV^WTklc+t1$t$uXtJ9cJ#xb>R_E`v zzsa}zr7VnC!j}23FX4+bIiy%dOc$7f&+$Z5$D8_(HPJT7MSNF2poxg)(7tJ4v6j8= zWtK-B3hrFqXhmj*vuDpPdQ99paO@yBz~8ZhoQpBC7J>_|jrO;0_o8WV<=%3d@?h4G zlqv@B)_rJkHv7wa7VPCQEHgfYqGpY_5)WpbT27vg>+2rBxnFB2}Bx z#h8p{>3TuL(k~0yGPkRD*}VB#)zaXC`>$ri+3hIUJD zxxO{#?cqFc744B&^SIHAB#$vtTvN%dT>GS3shZsOc5jq`<)*~b2j1?-gFwDDrgc3H z3F}j&FSW07ba2n7CtjgRIB>?5-kEX#j~(P66*Vnp1M*rtZaIn!j~kX5dw~G{ipA=W zwRZXF#Hy*Cjq|wUfoBjtQV-*goEK~3`9+^!mNc`I=z-8{5z+2{jDeAwM(9Q|`|A_8Rvwu4Ls0*vb#yXp z%;(Px$GP<6U9kVQP$KR&&}+ZdaKB)v@GkV!o$5%gD-JJtnmNhvki)IOwDqDT~FldM^zINzQeEi|7BpfUz~B|9k0X+#j4 zo0|eiVUeh7La4zN98}1K^YlM8Ndvl%GTVa`T6C)rH8cg&gv+@xtSLoFgC)xiOOhTY zUs8D$)mJyp4xeYgq#Gx~iO{b~;c1eyXo8hJpl#uVjvV z;NK2_XFOx2h*IOsK*cEQ;(M~^?Wum!i#f>c5y+>Yi>j&Z7()vYEqcrxM%P`{C(NYD zhhU{dWev&KfgKG095f4;uS}4-E!+`XX=|D`=-aNWPgazgfI6T%p^RBwkM-&eQwE zL+Xh$l=TjNfM$?`k+W3xYIUl)`5D#86fZLx4#Y^cS4B2-F<9SO;`Z4is6ek}@E^a} zl>CNy;X*d{D6Cp9tZ+o2+{!&OhP#XfCx3B2o+k2!E>B~Mk&EWRb2{HOM|h?qUfcG%F&w@HBwQ|K>z8ra&S~QOlDRFXj+; zXp}U(MGf~@+-oj%`+-P|_C>Mh3xhS9O7zmEn{z^O8^rD>ewX2ZBzydI-9(WuCoJLU zxX8;-TxK5{78rT~3gUy8VGhC-fD~QtZ}{VPUf7VYK(~rXFnAw@&V^w5j1scM_U&&* zbL(oXPPcM`@iYRM_Dx`cvSzh=Z1iGN@p~6oW2Ej2s#IkN`Yi{uiLoGGrV6<0`o>p1 zL6*}ZImx?Sn?Jl#YIu8CL=h3MG{UoJiJ!0f_4KunUoN$8QmZxT*kJ*8k5i;$lk<}= z*b{?#`cmvWpi5*XBKXkR`oDb+$H+)zDQn#g<*LGCX7S9r!eMYz;e~KdLF)o0zHGT_J=)3vGjj0YZZL8pf z9+&`>F)Q48RlJ0oiv4uU6=>)ip!V_JXN|GB6CK1>r(kAn2D4lmAT7E;h%zV(K{_6r zn;<+W{IjbK9G%g?KklP*6OP9Q}pRM&NC6Y&92TnXT zhKIUYlXnRO7CydvbI@cI=xYw^63`RQHSF%2PhOvUR!yQ5x{T`FwzozrZrJb}+V*z} zW+;rh+#bjg++qqQUt^3KuV4NR9!{7_d8TFhbnh0p2t(B=)mJekQ zXi6U26OgZ(p%fL&AU@EcS=R@fcIVP)oPde^`9t$aP4dI1BWa`w9adF&z22j=KvW5< z3#guHPl^VIzFdYEfMNUa>ODL>l$G9tV^?zH+nNw>pRyRL=eGx8ygMz z=TepB46jtIlv)WOr7H&d%~tzrOMd*AU_&VJO1|8dAD&{L3}Lr#_?kenI&C;Jwd&xe zi13tpNwTtY59b(sJ|kkZ!wefJPKV)e%{|k3~O@cHeUcZ~JN&l6M%oaFz<5 zb+)B51(IQx2K)Rroth=918leqk`6E>HTYDF=(Tx1Q2BPtl|7f8%5~UhLL~WYUg&q4 zO;^+LYyrYgYKa)zlnCYIyoL5;8NodDN6Lr%PoMh9Tqnc|9M`|y)ko5-BDdM${)$BZ z{yg?uuJCI@pC2Y)^4&=JbtW2L}f=765U59S*!!?F@i=pf(2zq?O~_DQ4=$9xGRH zf>FdU#bnP13$;hxMji!0<#VAxk;UoRvb}82_2psal3Ul9`mur`3WMjF7qRieBzAY) zKB!%j<2S0ARGMNHPio@a%g8}i(O*ehHZ=Qi7Ge83$}18!VtMks4@vSAf7ib^G?bXn z>aw!u{ns}6fb>|qT!Bwg_#1`Z*iO}^1fPnxKgz-}|SIzz&9tREFZf9e@ z4iIS?P%(|7I>|PqNs>+SKA?Pw8?@KOq*`Qp=wsMo51dWKGsgzR7UTy7;2*}iRnz2I zLCO{ea^)uI?)0=KC;3w_rm>ZxR(4I+BIrtGctcI=s{GqY0|93|xtPyHu_O0eB}VxP z?vNH~E?wROO&zjAFj=ltkC@@=`YVMxu)=Ac4f^~Jd3hj!Y@{<#Vrz?Az;HYo&c*rcUMooOEFKe}d&$Tq?Q9Ay$ZX*v^hsz7Fc68_K!TEJ~=T3%5}4b4dM z9@v|mG$k9mw0%+XYf810K>1D6%oxcu7G&(L;&)ZPls4Bmk--Klu+V)?D)p?^Ywj_1m#okl4*Bu5L|)$hupFN!~-X0Lb86f1TK0g{OauVjcl2i=IqE z38IHBz+2G!%mjzY>N6Kg`Zh=N`WU9=Y{Kd|(-egy5?A^G+dtV5CgZ;T^8#!{-z7;_ z;BIb5zmjsAi|m0_PlJRV`F5fDfws&r0Ek{{&}PU*+(~qwyE5C88BrAj5R9Otq@-ao zt-3Mj_czc1HI&DaOK_ZYF0-HxajJQw|NR;>bFBu$ybxI7d3Dqm)}2KV-mqk^BX>ne z@-CSL-1s9m`)}uwV{U#d-xzs(>aF6<+U~B7YP&2@<`GdI%om86v&sIckrVl~i-RTB zcdJ~M4bJabti*}C*i@8!qGyEw=+iU$XNIxd#o+_pia0kmNMmt88tZSixnp+_dU~9`=CJ==zjzYT zp2jEJ7B7x5lblB|H7&kBzPo>q(6uLnSZIiOc<(Oz#C0a5;M**@8WM3NYEAY<#zHFWvvIz$;J;bvb3b6c;%xz)%B86%Je z*b8|$ZqIy@n*B>8^KUcf3#2KhXW>+Li`bm#az;Yj|H$8dCXT|bE7KG}vlArdZ~s?@ z67si@#(bht2Az?D?m=qm=EbAG4Z#1%Ho0CtmP}oW%cU5KOWMAh8`-y4l#stm~ z7UbI@!fp_~nETzQHWc3iLKX|a39ORrseO*$-7{O?KN#-K28PoFHV*Z-gZ__+aDC_? zh>dtuQcH~+-rWZR@TCmpr1Fd(B3|X|;knl@{F0BoC2TWv>=Hk9bey2YWhYB@I8q28 zLFT(4Zg=mym&AQWw^BztL_EgRcEz_O6&WJ($WgMl5rdLqt3hj@2L_9j)T;d;c2z6w zW%$M0_F_KcDO@_`3a7br=E5uizWoAhb)x+3Qn>%_9l!pC79Iv+r7lE1y(I=RtOfS# zD-R)TEKHy#rXV?$4H{#G;)V{Dv+nJ28VMqoD>1rV+dEA&hTg1r8pcA*ch;9@A{2dW z%RpaIW6OSYqFk6nT0iAh5D;XQ zl5%$FLo@`s8`Uw+hNRd2OR5pnuqKN;)B+Q_(q&HbZ>fWk(Hs}# zS_QDs7bU`R*3+a}deFFO-IhgpNv~^WV~MS&^`dMDL-a15rl~&3>q&c&M-5hSb5Wex zVTSj}#w<#;pj^;rPHqqf$Ews(-wWyTTTf4MXw|A6EeYd_x!U-q_PTbSYbKZe=Bvdz zAvW8BkbH^}5U#$BLhVk~KSw^zy}nOAYK=g8 zdWJVb3}iCaaoirZ(`Vb=pY>TE6W1fjsv-Kn2{Qg%kzZ&qkC4}1Q=Mr`3ar2D~ zg&37T^L5C;O}0wKM^D>$WxS64dph#^>e8T>$!^kllkp!(R(%6#2g=!DoVBXD=iN$0 zEy6!t4moWf!B$_meDWb)Z#h1}>-#!-;9%eUtwn7}cxg|VTY z#pwGHwj(r?j&#e;UP4HdfpQJ-##E%NE(cUoM}U-$fwB*_Z|JApa> z3j$LRJ5Hq6r2XaxDXzh`7lu~4t@YtOaxmTD1N72v14qPz>H7;Bf1PAMxky7J!dOF` zt~dg}``<5~|5$Vm>eqe(na8{7mMGoT$yQFO5h>j3y{|8f@{RDqa?w)$lZ|Vk#!ZoO z5P)5jZ8_1YbAcX%&2690ciAWTb@hVzW9~|XYuB@10?q{Xe4=30VuK?pZG2+G`Xg!v zgVIIr#5L(@62;~HWtJgad**J?mLu`Qxcy<(H@eI|Qrdg-@-dPw)o9w2YQP1i*GKm3tyQn+HLGnLrN2g4dNbACc}M`Y1aevpcG#CIQxL_Kd0C~xA3tG z#Nq_>8oasijVb9zN6FoJ>a9l62;xV$TF7m`#m3{Dq+c)kgdu3D#y{wm(HVRJC+9mFw9j zd6QZ7WhKw9bWZr=7yHo#`b%zmDXd|KeD&E)TS5)4RTIKpJv(aCq``-MQ5vW#H%gvPCbjxBLz-f#tQ|5%$nlHc7VKMPL+HbYD_rKXnm;R| z0+7;rce>IyqDFhOi=*M9ZQI;m+bbAd;uFL#I$`JZ6v9;4f4q9HSPxOlHSRr=6?cOnH+Z5Ev zM4>4y`#sj^nB7Z=B2-PEKKZ>Q_s?PR$JXY{PPBFP=BSR27~bnp6njD_%|u9DywW07 z<7Pi|d~|(wY5-%&HddueMSsjrE|^}7>r7ix^n#du5XDzNKarN|;!Kme9j7pZqdK^0 z1|h2;SgbJ%w)i4oZmWf}9Pc(%5_9~ShmJ)*;Bb?DCqI&{UC_`sE|aE+S8_gr))9fP$JG^zMeFaBB+zKv$Plk*2)tM+pHa*5iS$-jm;-+6VQ^NtkT488s zbMktUln2&8w*Km0^2S`~-usJKjH_-vI{_$_|F$goH5?QbMmRH7F3o%N9H^=fVT!Qo zx4#>f@@=WCL_Gb-R!dB`{T8I&T*oWbP#Q>K8$G63gqAn}U84%Ui=(s|%i@2A*NZ4H zSD3X|k7f1ekv`B5-<~y9yd8ZX`;P?oYQm_M_8%$kT-3i(+?T$4y0&{cxtF2U_%W4@ z5u=MP%~)#zryUXdCBpC&dcGIM-I{1+T<^WTN_+0Xmj}BBVK>RsWbkJe1zPFe@$%!T z0guv>_yLwwM#|W^pI^vVUqw^ZoO!q0YFH||HK5LfVkwU-T-rw!?^*UXPG%YodU$?U zJ8W5DomHs0w^`vSZK-6Ocs2M&?GrdA2FIPC_`}fsZ)5S#Um1@DogrD-wBB!Mj9_0_ zoNkJmsYYy>jw$}a8J=r7o`C2hd;2Tpux_i~QYZe)v@5?W#S33b*cK#%!Y^zb!OpqF(f4MJNSHe;-nHB&umXd=~@w0+k=&ZnZhoJ~GRN5A8|@ z{vS-*FXihcii3F%{Q*zPyGNaP{LhTs^{2%2{=$h~jxnfNm)uxstvzqQ(%~)Uy#8@G zLL+W3hSgHI(e}$D>cIARDQ-N?LR2#e36*zO=?ysImqDhg_M*@><7(g z-5YEx)L!<22RG%WFWnH)UK0BSAS!;+PNK}9n!yYpXDtT=5jpCpw?CIf{}1yrD2de1 z&o3W38*GU*i3j+1>IZz&Dv(!Jpy#(WO|^k;RlkyzRf2TQ1E zX}%fHahQynhF+}jgu9YuDBF(=priX11}FUgPub`ZY_XzmAIw58pMN|AiHo7BeRWgI z4GH4u1uXkcuCn1QPj(Arb8dj7!q;~K?v6o(_2JQRUlg zW)+$-%7IGUq}G?@bD-B|zghm1i96drUh$>~DJjPnf~mX{Uj^+^C5NZllMJ&ob9o^8 zN-R~#-W=~iXpv1w?-Nv^26tj*QwN-yvHF*jdQKUim!Q zZRZ4Tmtq0Y+b>T!XG3{3ABP9%b~9u=t+cZJrgd$rSc&~O7i)|g!}B3w)?e;~f)uG* z;WE?@hDC~r-Y;+H#(4!I;z(d{$PgDyC`Wxto~2~+d{CcnJt%ArHcf?hd^Z4C%OoL1 zY%X^Vj5yd^oRLC~)aMX^P7KleP?X>yvOIvdJUEX6XPFN@0S7tafvlemT62RNMC9<# zq-(sy{_~I#)RophF#w)W9=B}XLzB(X?VU8Pph0g#$Iez}xPXM zxj8@DdGg!XZ|C5HvXFeL|BDzD4#j1bk5Y!Ej4j44SB}AKR?ZJX{diM^PvS)cNPrt zp-E}DN}7DXgAmL7U`g)qjLT5z6XBrq(%uBG3n&VM3YX>L3kx~=6;>a2faZMn^HcKW zbKnxoZ86weLcst{Vk~#O04=HMGIk%DMD|$HmH9u^a-BDzE|>J z2pwO4Nz2)?bkt=*Yu&qN4qjJe%vG}{$Xs)PCn`c;NmoV&eFt010}IaQ3GDm(GKSQp zhMvD}S4iM>XYi_X0-P9 z_Kw80xjAa_OB<;OsNGTVN6F)$j9lCvOE-TthnJk#mB9I&Z32zgm7DF| zZdkKhN&K46y4t2HQEo^#JWVvk~&lR*^426l#IbG=SHF$@V-Xe zlJpRGdd}M{_7~J|CO^!iu%c_RxHpuoebpm7poz}3JZEN35Sj~--KXAi<<$uhYL6J*0IX6noz|$R_ z6M`C!Tf3M=*2Bb+5{jR&{1%UwYD5KqUjXmqDAhCS6=PYY1n8oL-onhdwqCe9`YTB4 zIuWZryLM5hCBcT?dk!Fu5MBp8Oo5>FKo3mXTX6hsvUraU9$0^lfS##lx8pQsr8at0 zS7AjM$a+j|v2hlc4v@**Ylb?ly3l6>Z!KxSIW1(2O&{F{%DV|<5U%KN^Bw&&1?ndX7TMrG4pmB^p>l3g zcqVxiz2&)7He=P0`H%=BNawK@hgEINcJ_(RENVQ90`hW4j~Kkwj&8wN$fNH{Lfr%X z8iQC&4rI*7gMTxVBCy;iA|P|XQOtn)vXDY|M=Nc<#YP&1oz9y~FN^Lx#c0u^=e|Ba zji}&oE=tjeE`>y#j=vH^oI!;JCQJA=0NabG(eN**Cq?E$*%}xNps4jHZkG9wnoU@UhPBu z`oTfGln?ckQ<-^Hx-fN%P>16NLs**SzG1JZ&=KggQARK2**Ag;s2Olh=nC44Mg^Z zN5))2#_^IK&MWtv_H~DQx8V%nysx9#Eor?`{g0=J|&kg|K6zf6WC-s9_M8H_8AWD8UDbQ_4F`X@@O? z(mb%j7Cj9bB=h_JUXu;S&j-zgx6lP!gl3YY1Vrj2Tyt5{tEfSHsG|*f;Cmk+>tv`u zI-2XCgD@7d)MuTN)Ts@et~{nj7rNiC%MQg8i|MsXPFcGa%m7HeaRvGv51+(TnIkIU z966Y=bRRXI#50)vxj^Gc#bz@mR0#*^8sp(JTFdHqtc9t*fo2_)ZHoKX9Ifc)PIGFs zXb4t>DEgZWhiKL081){SV-R-+B80o7ewGfWbdfE(iMk)KH}1hYmRl(Gk8}u2Av3Ms z7xIYg8^*@mLZQ9BKKh{i*v!aMY zi}Sscp{4!_NtiFfN3bTf`W}diO-gKsroWH=UKuD*ifQy8=@}MwESpcQLqtGqpR#Fk z&^l~ZH^hO)*Xi72uYe6RS(!&@t=>;_qyInO$$Wk_v}gE;`W&UfCk5&u0T3b#hVWcP zk$hSr(fxSu!ufY|-k+C}vn<6P;1F844gC_E1Zr)eLQGmD2tjS5k7FeRg$`m@)LfJ-Tzv({hEWmmSMYh9fZQt+P^C*!c%MI?~Yk zQJ=4ANGkhFP)lw=QN{7a30AB384r{2eLZ*Uv!iP1PR$#9DewJ+BGszrCw3C1%8H7C z{j1zoiTEmfmuOKQ9P{BA;Bkn4DOXa16SaizeqN#RXnrk)SK{#s zsy$}O3;nQMYHf#LzJlVV_!<85IP4TI@EUm5XXH@%S52CX4F$By9nI~S>GORu+G&4p z;>65FEYbaiHM0XHNnV6m;3v=zWE106I&Oyga9@hr|3}zY2Sm9xZ!d@fib|tMNk}Mg zR8W+ZkOt`v5u{5}mb578l9ul7h9d}wbV>`-EwMD;>`~A0jo&>r*-Soz*v`K?&_ zbI%l*6kMzA6~DK5{f9U4moEjuNQmNOi0gp4X$cy1x?S;R*9jI6G$#IDh3>*rY|{T} z^@=1C?ZIFC7@PIm=qANs0c1^j$bK3#j_MF`vFgv{WKjFAFIU@Hf5ijb;7q3YId38- z`=uLTBA_{;&l9=#AtB0zW^J6(XM;kK382+Cb^p;jH25dzpw9`#GwdJQh+ZEZM=Dto zwz&5RKyRN)b2OGm9gznuPn0rSeRLv3O>=!n+34xUsp%uwbqeNms_dl?2am4auH6oS zI%Oh500(*{O!2o9jECbPOb7W`qtvJ?{{#@l>d}D%$7dCsKcf-w_n4F9C5t{PJ;?+; zXHJg=*VkX!DW?~2R6L&+*ni#4d%e2rLKmW3r8BoDV`OP(1RA{rLYE4LtRuGwYBDzO zeA%D;j%E-EZVQP^_?X@gVN<%LIPd<`MJv!9rc1Q`?V0J%=_1h61=pOIU7`C*zy0<3 z)SOX28KB{tQg@a(soygE%tYR%xo!t}^%S~x zyF}Yl=|)DiTlcKi1Et<^sG$kM#l}5iWo(r6mT{U>(;-qFalF@0dHM%}9Vcoygf>KP zqBjPEDh;APZ_yEv#^%=F?gTDDFjEP7>uG)kUhvRkHFj}nVkE~{mnK4BYgQ8Hcwe{- z7`iy={phokkwvpDkdYEwkY|vx8j%~nyc-9V(4ns84VtwbHwC9{myzQ!UId$5w%68T zx6-(?VIbrrwbl811p&WF_u-*mQNpuDMP`HV#h2}4oMNXLWgeP-jsb7^CY%KSNNPg} z5pA6Ym&mX*ww&VV0vFNmAz_NEfgg#Hin*F!2%bE~|1}f$D)in)J;j|*z8dacYrm2? zTK)X?=SWGt0e7&j{IY!-2)m%NQm7;bM(V9WQJ}NvLv5vRP#kWBlXH^Sc{ly@p7n$E zOO??AySv^*-#?7*0s3iw`^oH5QTt#?ifn{Ts?@%)da0{8OE~-WoOn~HcN9-MQo1%N z4m2TbFf$Jk8x{YfaUM=%6nc!qc8e1ZMM(R#(A$PGp5Bw@EN|LW(&?|eYEiK=p`cTo zrDf#qSEER-=b48k#ziZZ=qz9aNtQvsrRj@^P0kHO^`@+D7DW>+APb{jvLAF6hk% z(pv#??qSIo9$qN_v6uEcazI^^_h+`;2Jm|4-SOb}?=`+Vq1c|yw{%ZtYExv=EOhnS&|U2$`<#C(X;x2JYGDM?lt2d^P6Yx2-Ql=;Vf$iBsRf zOM~w!xAm7wSGq6XnFBNm9{QBHH)s5kt^X2ZpHxLJ6sO5R0doCKzr|QBtHbJO?b?Vq zy?3_uwrTNusL%q1G1x3oD-5w!s9?zm%w(DN#h&jAsn^P-uR&Fh(7f76W{u-7PxBz7 zwKS2%r!1}x)R(anR7WiLRGodSRNQ{a%Jk(T+v3uZG6*zT*0rIEW`+O#r2Ln`{Y*;% zq?QcfS&E&{J+_e~YS3A$g)%Z>0;jUyXbofebH-yuxFe#`A&CshiT^QI!0p3>RG|La zGR88IK|r*}-KGgQgV4@tEC|lVzyrK(#w$$!Z?Ph*M9clF3mt%pa=#6W8+-HO+yB+3 zkq0no%WAStz;kg#M+RlD`8@vZp8DIK{dvO(gu*kE8k-sb6!i!`5`B_-M*Tk~GzrZ& zKX1ZQyyZa}CK%*%|j6SABHvSPDOLSyb{2CgFFkNrRCbC zoyLXV#)l@SV4gZ<08C&oi%c-f*?!tUuRs((pL+4Tthl3#UsK*ZkN4ZsdtAbW`$hrE zjEf*B=tN|Gg2Ft8&`A$gwS6#3L}7oqot!RFVB!KSi|c_b``v%wx`AS7xos7;iT*LD zMg>?2$NScJS1_>juE9><=I?+C>+|po(FyzA`d{HZTs6xGw>ZMCYs;PHAB0ma(8!ExT3S1 z?`k5#5waY&jC<&D;f{lrdacoxwanVu+Sa^Mx&Q164D2hKC+7H5SlvTlNn?4q-=2qE zK_{#Sj25`4aE!pQ%sk@@81W!z4I%>N4nbh=y1F{E z(Q-L-^Gu;PctIGa&J4X*5^7;l%4~rcOENGfo-On+fGPa;ymm=qnOxFCbfs;Ll>IM; z@vV?SN`d~MJMrKRFhi%+Kxn(w20U4_mgTuQGAVSK)XTh1#aoO0ZEUx5qyxGiIRV`w zm_>=@fYYT)yE(&6Ia^f@s%EePEHi?bS$@bwZ$;ty)1{v@6qXuF6$ti^SFxENq2tz1 z9M;Co=cb`>`m-(Gn_R!G1R*2>t@NU;SAX=J^wP^(o8>$qwZk>X=fpoP7~`0*oa`u9M-OW=n1>^B_l~gTlwGZlA zixrS7l83fi8Xk*O2o!W5wbju%Z&~u)9=dS$o%gQ;Kb0r`g3THRHbpvTES zcZ)zade6^ZkNtUXx_e?x*}*sju1PN%>|o&e5QqW|G5M#Xldv=0ir^C3-l@Cq& zbAb>A1$Fy!wMH%|phoOY*Q-CyN`R0g`MSv!PLoixq5KBruOma*rcjr| znN@Gd3M(V;dD<4ZZl zn;OeD>w55B0f8t!D3d2vnyvQj&lH4O#o}G#zIC?-Nr8j? z)G#(mo$$kJSp64s4BPj**&MQS49hy6^(d~9eva>4mVBq_4CA`T&lajwmlKp3pcL%C zGBSKIqcfJDz0FOF`O01M@PZGX645)c-4)XTJ6@q-zrLqiPJ??envICJkBv+3**7>~@}TYyTTHJ!Yv zn2y~&20I7s@lNto(BNscz1+N6rZt9nnpaX_Fk4$m{f;Ru-_yJnK>W785)YAfr_g0- zC=`nytc>hJ78D~3O4MpM-)d0-I*RkNZvO@|lzAVK@#O#bjge+p*kuxw~`dcDw~O&@eq!`PeU zP3U}FtW*nE6As_g!`%&gHIt(?FJ*dDk$hy8)z=rF<6KZP%;x*I!rpWP&=H+}KJ8Wm zsZx7f1I98yEn3zc4X_#HAQ??ZfRbv&!~+;s#tFhFzg_k;-4G;UtYn^h^TS^8QbFSb zO$RfmS?eXq*jqmOq1et}{^Ab-!f8=g8mPk8G762NytpTG_=2wr?N1`m2w3yixf9FJ zgS(ww-r`z$;#~opHAQiQQ337iWZ628XI~Uo^lwXQxpF*xSG=;GsafYLq-~6P0VCxs z2XfLQD3do$$-j6ilE?fZEP-ii9k_t47j5Q7Mly8D!g-VnUesu}07}mEJ(LO}o7svi zy7hd~_6nj5`SoOoo5(Rbb0>iR@*>r*L&US*Mf zGaJmL?{!8f3C|$>Y~+>QwTZ>LP9()#47bi)+=15G+))cJGOuR9A*ZIZr1s$Xz}U_} z)f%#Jo0na0Ux$o==jk`yD;#FURpa90d~aQxcr5ayqmAR)5B|L7cjbu|lFt|<+&;o7 zTT6p|Ws^M5|Dr!(JsGla7ekiTVPSm4-s#*HnU-p+lkJk(OV0p%BqHLv#>Ww%iW0lU z@wVgLCuy9ONeN};I-?@jvQCoT;H?YqK4W~1TI1K9i(ogDUajc8kK7i&@3o;F@I@!? zeX0OW2^gXZzVzGr!kEki^*#@`QHWKb^Net-E+np1%u$gwWZ_J{!m9vpqWz%b1UvLyeve#*r04zYMl{1Lq><(IS(0aj&jBCAW}=41#tgiFa0+F3Bf>d>lfya47RWPEH8Byu}E2vNR`ZB;0(&Z2v^k%zM^*I<9ntoU=foTI}ma z=UZEXE*~7_plYhVyfU(c!%Ws)vx1||lj*?hh8cPsVX$1En&>~pWG1OcqE_wXAUJcw zDySUt>DISYt0vnM&Wwp_g^4%WEYdJubU6FWWC9eOPfowv5cVLPmp4Q28=# z@kHL@5HyaR*}5gAY17Ur!_@5Lea*e!9@kcU{U!Fr%9;q^$j$DHdi>nA9~-C#_B5CM z#~0xOKR49shQ~XidRL;bOs>%>ODnRo-`I7@oc`Q}PUZzVWtOr#yJybnie&K}K00T* zXXPp2vak840;Z~9DRe<(YcJ9%?p#f5iXVB^%3d)&kgGK(K71limnCMkbA{ux2o57B zrs`Y()t#=7((j<4i+S1Cm-}Ci+BH1G5)+u;zw{8>QLaiaJb&pH?fUe{l~KLd=VDD{ z11PC?BNHt(?o>?-7cUtN&y^t~nwJ}tFLHa_AQ-oteB@x3k5KMgZ?>8Ha(H7G<@XB< z&`ElFabZRT(Ya?^OH^MI?>H(!%>ChlS;X27WI!~gFk|$Cy$UkjMGU7 zKB{(&9htZ*f3Lw;A`EN1V0w^P?gDB3@QK4vy{o-L$Wx0tO_CLtEaGOudLNl>x>p~x zIBYF7DQ!X#JBcQ!=W>-kq{_I`7`&std~x`pnCi`=k5KB@mA>B?sW!;+edppj^)3HM zS`4AGjkR7+gOP396QNu;9{8G8iU*+O_bj!9rWOa?O{vveN_#^@QTW5UdeZKwt z9Mx@UnipImFWX!3cik{?bW<_0^gQbU4E7}6bw8tKe=+M0Wbg3^nxYAva3({$rZsVX zi5}>Z#xt-^W^YgB{HN zm#eDbeJCXAZ;e@g_YxxnaNGM6`6wL_#VCtSF= zo$|c!Ok@ZRz90hQbEvz(GJg4#&-EL15tkEP4tDHk!c;!WeCSDMz?tO0lq~sM5reFW ziDKeR+P-Q}`AajSzqo~7K%YZa#dND+?nENKNN#Smxz}eW6Gddp2HK;Y2*lF9#6vN0 zRJCnK*|_r)0R$OS;6&7&+GfeSY8{1!ofcr27Y04~Siv;(3M*gVx~yX**389i0E&aO z&fPH+&?&>t^$AJI(HJI>HR9*Bc;T|S;;=e5ELx_RQMBCcuSK_WGXqnVB#hamloR>B zAa+XR4)r>12>(0IgY>O_^+30;6=^Fk1NcfeLbUNTxr^foN9;^=W>Sogo0U(B2PjcZ zDs8a{<%+FfUuovFeoGN$w^B#%EcO_La&gfPZ%5b?mZjSq-A->*$(@f+5^C_7@x@qS z8Tx3&dGXx^SHh^p2X+@UST#*9R((apD< zys!V!u3kDYhFwYxr!zm^>xGSZlhOTje?+|w+rg-By0F$=Bxs=jwt zykAJ;{o(H+KnvG95r(%A4Z?#1+#VUCY{j$x3?AWO%K%@hjB4Wj8a+LsmpDc?Uno~m z@=@uNse<-|<@- zVH%N+>kIe)(O3ebp!J&DAN3IYSTX@kLN7H8iiqL`Vk{bb==)oL^ENRD7G|V>J@_kT zK;@6jov^+1k4*pwtA{i?wa&^lPTP)4q({GQ9hwU;wP{`a1xzfOKwDU#~Jjx*a{Y}5i|6h8;V_F7*;XLZ;eg^it8Io+fj*sS| z{^QSnTTu+0WKPgL>O`roV9#6wiFxu&)d=SL&oKV;BQrhh!c_qoPXyMjYw+uj6nE9n z|I0&viQBKI2*8YWpW?vOnc)?;XYzZE|JU3FMu}SFOQ%Fcms<$(lGKjtibQWnlAhE} z*PhtoW8$|Yqc_fWa^cSZ8YfK$u0FH9_!yn1{mElbdyBgVC`;~Q%YXy^)3AsFJVdGm zdasoLI~i9cHu0a6{C(A0USSn#e*aGX`h-d&&NLF&y2}7os|f!Rw+Ry_R}tQwqgVfk z|1S;x^Tfdt=tE|FQi@~9mIM=jml^}>k0Ol!y^W5r!2Zp0DY7um7cf^`+9|~US})iK z8rX+36dX}_u%j^`H#2w{|DTr(fq(2@b2C8%UUDC#J`J+u82@`0E8*;*H4#moxe_Hgoly z!vTX|GW18vQL{8&Wh+Pg^yF$P`Ml zR}2A+*s!UC$%K)VNf)Av`9f|-1^|F9gtF1YpDb%jL?pw9q2&Mc^SAR?Ko?qM&y3RC-rxF@1fEtYl^1&vVrB4hv$e)CbE&o%r>a zZFY?~e~3EGDsE#kq87Evodb31!@-Ne!q$aI-5sMLrf6P6ixo!U=~Z+6b}A<> z>9o%E2eN7@K64|0xxZ50QH~p8w*R>-*mkU#w7w*-@=w0F1LOD7`^aq*rJObQ25}Qc zY#|y_(2-9b6C0hvy2S`1o~3Yb`c1Fk>tG!pA6Q1;wWPx2s(lkS{!`GV-k`^ZnuvPJ z80)B)c-3ul3^l-WI`|GL*1BgAKj6dX=qo$FUXYT+$le;Ultwn?)>S;7>^XfdPXD#c zy(Z;6wdh29n_F1^*ce#r8}B2d?)JsswL;(qT5}y=>N)j8_CSHa)uXw;EI*L> zTp`O|j)a|%OGEgWt^bpuui59JIrD?Ox@*W7LOORe)=7)Iq%7Dlb|xjY!x*v^`J&u> z77#M9Dy)2RAi5mWlLFNdPfEzNW5@$5#;R;>4~TU%4p_3DJ!V+yPOD=*c3zM(SHiNQ z$IzUO=^6MMbjSW#!;8{`PW~F(K|1qoS<}%yLq{Z?!}Ddi>rPGtBNa9gF6-UR1Nq18 z9!B%YrKSsHI_iTRl*@X3t8peJ zk5v~WBAyr$vznad!N}tExcI!sVK8^B)VlZSf%Ue@^>3{oqjU32pTx(%TfQmt7BLXl zxc7awB^HF88Dhpu1u3|Eo@=7#$Q z$MYe8^Cd8Cf0#Kfh62C7)mX(qRvw^0pDs}InYkeC%77uw-4MsFqkS+xb~ooE$spD~ zO~%qWUEJ>~X;Q_jH3yZY65svF@3&l&e%?CkdVd0UvYETI>>!$7-*h0?=76e}%R1)# zWxnCsL#OA3&Hk{Qny_5of~R@l#z{dhCqv8L@5{+`3|}_8;Tgf<{&C#~p;biO-2)f0 zy{g5LwdJZcw?9@kfLut$Z9C7-cwhE@fjgNuGF6GIu9{iU{;0D~HR;+#5LF^Nchmmr z&+0smb-wjltF78~#5-79*W-4pcRohf%ujq+ZKkAl89H94ea2Y*M-%SkDechpt{_=a zGo5l?=(V||YZ$>|3?{63fV~rs)#`PHYmVlZ4Cb2Q^~GF0M(8i_}i#E|Z&7THGUL z+WV^u(3)wvU|JZ5J_2r&if>%(MfiYR^*qSE%x!PxQ+Ws#lt$1fU|?~`%JDWZLKl%1 zR|qtssv2+^V9XmiE!^Od>+G=LZF*`<=*1G4^9;RII1Qf^g; z_VXDM?uF!#9{pEDm99~mp%Nbm#e5ADwR={8N|U2m(Zg0bPrEd*;&!rg)?8SyGqtfJ ztau(<;SAa^sYc&caGI#_QGugxFT|um~=YgsBSiv znvIci-BrmYS`K-B&aCfUfeZrrmNG`)nQr$Q3?RnHHoaT9WPdOn5bI$>;>~T_XvFxg z%FgxkYI&OYWYPDT4x7Ocgp5p;3}40j=C3l0g8>wLdTDV^KITK^ttpN&Oqk`ZHGbJG z#;?um1HXFgXwIiNhvU^9)TYWHd~Kl9(y92Y9G_rU^WnGp0hEk_G!-Dotqf#3b8z>z zojFgg4?GIH$cr8K<|vlB5?+QeD}1!u@}zdnhx%3(uLtNv{f8jcXAU{lGNPKklkKI! zG9a%EF+^zUt<@gn@m4oIWlIvA7R8;sROzqdxPiRvS%Z zBVZ9&00WKd%+?jl>cKSKE?LkCJ-O>YH`qJ872Xkg*T@!oWEO(+aD4YpH?huBB^)Us{B}Vu?+%NmQXLL3`jP^ zDG}O>AII1s8)x&R24D_I;LNQ6tHHkWO3nKFxq{=dLg*f8+V$Q;I-YOr*<6Rq+e0O- zuUP9qF|%+Of1YNyJk*_AvooAN_=s}9quU|m7N)U3WO7<-c%r+u;cmAxO8T^eNx>TdJ^yj>Qk+KGz0F2sMbE@ z;;%gR z+~_|Z|0$Q2K{aOjy2cHufL61OEMT8x`{S*=_-XhH{1w-t$ki(AFws{3(1F4-wPr7z z6J}pjIZ5y1A9rx_y?BN9=3YrerlWURd~Tw9M4o2YGC_ab2O;dOqG-#irS$t2mX9Vr z#i|SdSaC5uK7!?W`1vb+6GTa}!;sflrL|rfsN!7SX1G^}+G&sF+syG=@uhdqtJ)#Y zQreVX`YxRJq(kZKqWE;2H9@`ld&La7BR$iDN9P#b88SkK*;33%MN|8AZ}jBoCiXe z#_|Ad4yjz-;de>oqzCrs4knsw#R)_j?~)PAx-r&Oc8$`{QFkp@YkdVz=aULB7*0H& zi>=q2i65t0R2LYW%1wTnj&Oz3WDYq`i*6O)_mJJU_B{zkG>8x?t@QkvNx!WC!SD`a z*y|`iK`>Lzp~B$;xp(F8$nb+(g1!@9eT4lMiV1@4cRX4&BO0xwKd-jID~sBc>xnrZ%0-mam)-c4smx098*5e>#Zd9-TO2c zH@dIl28Je*VF8YJh4zmS^%p7Lt9#%jRI<<>m1*;+=IcI?XAeoACi=je;6q;m(dX&p z2pz%Zy9xK@H#hs{Lxz*~Qs+$4z4{Cr*e|?Va!WqmwUcBFXLNIT$3yA zics>gBv0m%9uo$+^z5Rlb$=TZ=%PKjLCMcChfwZq`OOszEB2=(?AoKXyHn=|)2yah z3NquDnsU2nyRScAZhPj!c9j*k4*a2XOs*b^u*N|LRcJ^;xdxRkhtlPeMu&v2F!H{< zYdLPVGE8|i^^A+^$BCNUPaHGd=Y0yxw2BnBQAL4zaajhkqVIiSr!NBRw?+0>C02|0 zZ*IPLf<8{k*DzcL=Tkla;Wh1PN#E(ey)yaNXxm;SzQZ1Q=(;hZQhFS5zO;P=TzZeA z9h8n+)oI7pmR{eOr*olvBeUKLUN?P&Ft1X30#`w{=USXo~0c!QJ zEFWjf(cx#pCi9UUoiV-FJG8DApP3-`TVPSA;oTcHj+wGkr9^JD;+BOotTq0pgalST zDYxMn))^#n0w|{Zn9P<$DTy>>XVTNU%3a04X$XE9UH2+ty9$#Zb`fz+iX29UG~kx zL?UFLt8KeH>nk|sy6PUn{p21yCpbh3FZDj=2|bhSd30_vLB#h9VIBYuaD+AXn~fUo zDkAEDY9>QFn5EJw@+_lzdq5+#NshI6;#nJOGmb80l9)0-W-8=Z*#4C8ooWzyA=UF} zJ7a#c!e*|r^fjpn9kcl+@Om}%qiDaJCA~49_!J1bxMeNbTS@AwD)ecz&5C`D)`7dV z>wlgbLw$mJre7C(Du6m$0BDAL38}9yr0mx$<(_EA1kdl#H4}$rpLSry%+k596ZF6& zYhOL-;dwINYa2PH%9)C-tfXtB6}|m={7S`tP*J$BT+XmxlJzDNc_O~dnL5KKsT4*dG);{F}8;M(V5MX07(Uc1#ce@!% z-*ainxs|-#rj^pKps`lf_`*^OW-ANspOXgyw~Qz2J!GQNjcSdpC7&=5uzIBLF=4Pj zP7!kue*v7_s(X16SBY=tO&@vLw-ewxw^QRXy4(TC)RX=91t?#34Ejp?CV+Q63E%H^8+AXKmB>fPe0I~Z z>sRtmnk|3zK5@X-?G@?G-nD$Eh6n7?Ou-wUZ+8}xX*)Kwa>Qnlaj%w7``2eQjBbCh z!Xrt^JZ7fMrZ4%ZHJyr7Mv0a)ElzIuHHyq-m`CYMWjUd%DC*|zB|XoWOP-Bc<>hzM z34TN?JvZ+l)_!s9Ai+jlind+FFJyKA7-E<_9JPQhd^RW_zfI9~l)RLF63gwtm)eICTLXg%64_gR$r00+ybP`?AfX56}Mu7rFw@J;^g$xR&`nhSDZ<$yWUuS5e8{H`g_#BXrbzlu1TGBnF6%|7-yKn*yu68fTVt2mOg;EzDhD|k``JaBSXi{`ZqHY|=kFJFsoQt) z+A`~LK7Ut*me)6NTWC{g^w6-7zH-*DL(W?4_N2Ut zL}Bgvl%huddR?FHV1LC&s`9luVqJaJIrTYhdAF7W z7M7L#ND9uBy}3rk@ya@F^u)YZNI_?3uSwvzkls9}`cEJ1mtk@y`Be4)!uq3A#NXkf}MIT98yR7^5w})HU?zJ(rt)ml5r?llT8zDb6|3nYx5!*T#I+FR1UFzy`%BL<%?;r!uOMl2MEs)UR8gqqS4PTPM6}@ zKb21#;*0@3ytJ|^p=|ORX-t!h9AEI~U@CRIh~52gcZ^e-D5dxzv1&1k zOkVYvK|zQ0+FJGe@KME}N1>Xb_=<|}NWB@<=+Dw?7td3+F}NT-!?Tbfqo2IuVl zk+6+CVQ++JgP@d4xN2~H&rL#y%J6TmX!|+b=y^!G*@DrY*wZgf)oCnC2?! zu;*k!NOkWXp$H*f)HI zI#@zP=khY3n|F1>fpB6mI@#-$zE$^7I`3j0yAcPU1X*@LH)i5~CfS2SqcPpp?n^#3 z@0PR&eVHn;Ij?;p#Agu@YQ9TI7Mm8K-)AA>x48Fy$Gb*b0RL3MppE1-lySO^ggOLT zbqrs&oV#5z$%N@u?ffOI;uG5k8*y}R_B!|4Jh~;dd9S#$;YfwLPsXn&082 zIDRK_L6@FQsm?|);W*c)d}g(bwyJh$2OamOb8eh5@!U^I?6iwpWhjxJI8AOkCPYpx z;wquJcrSyDe@jp*y%DeG&0+*cFUrq{OMG&%g2L%2>6&hr&ej^E3*-E~q&2n@5gXN> zb~k20cIRr;ICAyfWFqpBaVV#y5@9d)iF1nPfJ^3F-Ktxw6z-(hbmAJZdF%U92`5vF zg#)=x!FD?rL*JTO{%xt=+gFRd%4&PJc$2KVZ*x#Q;XTJu_A)6-DMEYs(GIRl=bp%> z=yZk?M|>hY$cCdl)})HQD6SWZRNy)jTf}g9a>%+gWUlen;H5YUkMMJ3YKRlbSQYc!CiXj}{L)+*&xNlB&EDdNBnl0L zU1{6{P&igDKYV@!Rw4m9uf=P$9kABMUe(tDxEYZWfL(UB_SYRBw`mw1_TDic*PC!f z)wtYp^O)I5yh8tw8LgiPB@q}x1}(wAumCZt@*6HOq$(wUu&Z7B%4AK4o9fmgX)BI+ z<@*ghgA&KQCNox(15AZ;?LNW596|H|o?t)}cV)j;&{^HvVo)m1&*|%A@$m`~j^st| z$Q_){x}c@uyTjsB9rh~Z(xcGO#y6w~E)~gqCL4#T(H76-?9|Or4DU$|!#ZxNB78&i zvwUbz4#h;hC1SI@1W0gCY00|af=eQNDE6R4oKVh1K{$hS`$LL*IFDj%gNWcGLAmaS zi$$~w3}$+~dQ4yjRPaOQTtJ0^t@xcb)Q1^r)eK*{3%a?XGD-E5k+J`SrmN&S6ovqmYA7Y{qVx*|mBZ z`#A?P)9JJe$$iC(WrgSG=LlOGI*Ypayv39ESSlhOVOgy^hjjI5o?Ef8I-&FP8_tV( zL~OjkKTT|qF_+tz^r}+0fIpS6e3)Omd7c5QrX_JL@KHu8lghSirviyLU%OmQ*HyMm zSyXsqL|k+O80(z2b?N?-iXrd|=C_=y4Ve4yT33z)1~=-GI8L^1s01AY%-|Rl9V zi9bZY4;7>RHc+#_jkR_Q)fp71T#BfnAky4AROD__G;UDhh~KJ7b)BDy0N_FLJ#uHt z{x48Ktv1wvh=%wy8HmC6ec1aQBo!1@xMxBXWhRF4rR5AjN&Lgr?;v@E;oDyaff}>4kYqBBZVHYveW(t7c;6X*32EPLAG}~DWvmd=RR*;q96HuXNv9>*615=P z;6GW^@m0R5X$hmQK#DJay2gdT+ z$r4KG+-`8c&z9mz@uNIi=J|7essJ6`P?KVEnJN_d?n57PT~Rw!4kw`YBtAMiP_0{HIdG5TP8dzPNsJux zl?ceCU0@PVoqLdvfKXOY`8TxwQ5m_dsQ(`_g>XLuW8@O84X{$S>mB9`W{2Y>(tb*lcNjr~4ulFPeZp zKP7nu+E3+16#Fjln^&&Zh;^oRRgkZ!5$90vyLPWhA3Jz|6n5#c;Z~1LIO>z6i=zI- zkvKuH6)a@3W=`!SeS862Y?>eLC9U1BjBTJYI*N7i%FslnjwWZfi|dn*(RZ8`|3um{ zR0>{zwCz+J7{9BYTxyh|Eqznfw2CE@86s}1eFu8Yc^pxQ@EaR+N+by>UWovp3{q9! z?Z-4pu30bMy-`6cmz0RZC$7{QI!UW`@YR#LQZcbX#P?X8J{&Qy?fiK5oNkJ`qHw%^ z6gvrhEJN>$)A`BC!%PL)O%5Wt3nz8;ub=(!Qv5SPC2nAWlZ?_ZiV|MY&px!1YeH36& zMM$oT1slhSUD`*wNgzZumLsZr=Ng{mc?nqu(uCp!Z{e~g2+9Vv5ZXC)3lhu)TQ0hp z;Oh3zOsEic`3PJfgDl7O$KS|KYBx~L=T}9UP)2x7+Nk6c2t)9bMt2zvb#3^46j^34 zAoiXkGob5oSrtW?YSK?BaYPW`BOkTgR?$=Fj=q1lShjmMOFu^6G5_0U@Ad%_v2>62 zaWXAI##`)eTKDbmFMHg6Z~$j9 z)1tDVOy|WKuJ@+9NGt44&&HhxM;FEm1=x+#`L>mgaPP8JqUr4S`p&+u z?L6$8`ub6NBHHJzGOw%L!wdW+#jdGyo*$V;?Jq`IezN^)$PL#B&xPaI`Zdpw466BA z7JhQc_cA*ZbuD&FRNX27Q5aWeN}Xvs-OA$s*r%I!m89vEhoH8 z%&MP+Gbg63O!uo`TBQ*|ozaGVR#`;vC#8wu<%;;BImtTRcjzC|d&3bBFQ3}cZN(HB zHR)E)xXs`h>Z988fZ3D5g#L0#c}A03ie=ahCHgqmR2;IEnG7Z-eoR$lzl4v(H6v?v zxzHdG;9PoltoTIZqr}seB6Q;Pl8-XXeP}{r9y!UE$OY4I+*b=0lyjGe&X~U}^2YeF zmT%@URXun%!&&LqSYJpUE>dU-$vthrRMnG}DlX3!F=E|fp-ij8&hJnnj%*BRj2jLj z#<+2r;bzi)v4hz>RVJa2kA$+^D|LV+NhM0UD9G*0vr;T|soyY#Aczz&|X zGZ(+_eWkS%4*e~0W@XL*rL%fi!U~;Y@iUznNM-gjJ=eQ%ObmND=Wd3wMO_N zSkdXS0Ijf|@KMnGfVD|cVvrrHxcHcZL~~Z%((4Q23@5GQ zvzAsm9Cs2hRc#)iDz&2m->YyW(z2GkyYz{dT&N&IH$ruAmt{(xARyu3>l=Sd4mLhy ziFBckn+kFE?2F`RFv6qGJNAyPH^$Lqhpt=&Cz9}e6Vd2 zvz6Ufy?r`Au4!L0QLH=LiJ4{?LUkEYNT-ZcVQ)f@iY(%zV5{Tfvfx%9;_y*Ym44PZ z31imBnL9?>#upY|U-7o2Bx32`a-rdK$`I_l(XP=d>2`5)a!%0-%c|Ja>*fA?q`I96 zTbB7$d>~@r9KtC`XhJs1G>S1etHF4a^JI%QD2|exx>bf>$%#vxJmVrR@5`5T?sR^> zwWsXNYgAL_Du=H>NKaQ7yC`ux_do_!ySg{&GHvH#K!Hzf?(+UXa`VwL@j?#S1Kaq8 zd&z4+gPKe%*qp1Po}n5}o5Y#4I_e)1jY`_*>$Pci^FI?$Z5{&(clL!VdmxSS>_r#G ztURB6CGQ=xvt`&;<%U74FO816G&gkgL)-^8W!WX?xmkKPrR^3OD2|T5rMVBdjW=}*oJ zE|9zw;M??GS<87~Be%b=p6gLPEoTQT^ zky#>-Tjw{c$?}2TXB~fYL((|XYb-OI_#%v7Zn$6J-;o>ZeTRpZy`=Yf=f9T zLO2{1;nTO7#7H8}dM|;DrH?N~Ka6HT@^YoJnyvq3S8v$SxQ#oMVVP z;>~p_U6h2p1E1Ku7~Y~LE~_{GiudIor>Hz)IpS@98~I{ha%4QF8joPJ3Q7j;≥@ z6uEYS^CdPiBQW~xTXpLurZR3I2#cbSQ7@+`hZ%2rbLcdEbc^%$jmwCtawUJft!5*8 zNV~1p8Q;Yl$S}TT!^>^!Hh&zXk0%3CE5_d|$)T6dq?*Dzux-aXMi*SSUo~5pC}=19 zhA7`>z3<%Rs4Bu|kg!N$j^eh>LxDH4LOzuk;Ypm5w?^A;>FvOKiiog2N+USa#P{Pv zb|)h|&b21-7fPtIZ?UVFai5Oec51k~YoJ;3pnEhSqnIwG-~x&Q5a#QpOwC9R9N%V|MqghniL@mRUBwvOGY*ifOof^bzWdEzuA zLrkjTSe-7Uv+q-~yi-fQZ#S_}k%$ySCFCYVbZj{fZhnznp@Pg-F)kVc@CRT7H-EwR zJXEg*UInb(sbq8bua5u??07f*Fe~7qht>Dp-QvcWm`92cSP5AE$(}pOp@+eJpK%zX z5HiLG41M&e#2I3(JXrLy^g57pjWLPcIh}t(D{0ge9O<+`$MAYDiqI+M+|O*wfAggP zhJ6S@g|95R3cgv62h z*-1rXny_ES?GJDaE{uZF%j?vyadG*h;WGq$5pNJ!{(#L~-+0m)b~W$|8tip|_x|L^ zTmD+14FNzDUQ80B{`uPtNgBz>k6Hl~L$%$<$1c7{K&38qXmjE&exh)=^DYie6N}6(G6=+{(0)}zi)9z58SHD=oSPCo}mG5x0BEOy(m9K>-WEhr|3b6aIT$?uSnq~ z3m`f%7XmA={`a(h6Ayf7%2QwP$JydvYT)M~MW2D+K6>=QagZ@j?863_F%FCi@1_*T z{|6*aPuPNw$pw{Rl(QXi+d{YlDDOMA+%f-RghVZi>#6jj)V41aa8{%}K+XbZpaT8{>U(+ZuO9PGoYWskW0ht7!P%p>Ejr6g46n*BH0iTCG3W>W%NE1k+Weygd9%q5Rv@{2ZKs1Wsc$BG=yfd>gQy za(4HxgS-a-|9XT)OZ5x72Cpw^N7;uHDjD&Ufcs_ReuEle=dOQ&^293{4IhZKEHmT{ zzq~|^nF8POG8ZE&plp@^ITzz{aul%BS$A@+a;yRSDGKW#*;;m_Mq1;HtMfo4!!mSn zF6Osil~9h2!2%5q2W6%^0TlRuALNg3=_3TB&P}2rh76o0!=3zjMcYdQXZBUUZ6(@| zKc7JZck;Ek9j=wFHOHW~yc!1Fy8c+|t?~WArkt*;TpFxu@F8I}NowY!=1Mb7+W|(P zyhfEpjh%CGPONfyAel=Qv_m;*qGZH49PyvX9(`_*BNo)V3|N`c2ptzXd}F z7+Ib&Ayb3&z4->3+-hOcVI zcMTt^$72YYeR$({EplYu|Bbzx-(onUtOZ%qNY;eF+T53H^R`)gi^!hvF`{`i4K5++ z<4_zwN)<|l@$9*HQIa-V~xqJ#7_Ny|)b2!VCKOk97e?j3>ao_e~+Y z=X98P|EHOq5le3Q(@&eu&g48kip~*@KeiuSIlt_svXqr4>tH8JR3G1Q@;Or_N6&}q zf*&IHQEiOfX5x#olgbYr_o_m-SH{Lxk@;a{kDXgAEUkPQ^*Z<1dxj*9$C5M*HTIh# zc2*CHVx-J_3nT63N81NZ3L6TMg^u64Fz#6Vc}@n)vkH7#i*MdDx8_1i8$LYzGVXG) z{Otwf=|o3{a6tcHb35 zN=kP#VIm45r64&;DQS?BK4ZFcE!SS|@B6Ov^IYf8-s~;B@AEz}o-yt*#(kgP*m^XW z!2Iwg!zoE|I4>%Xq*VXsP59=7Z_F9g+rVV=^iC#2XwNSht~rSI(>@I`VM3cVN z@F2ImNk*xpADc$$n=|f;NXBHH4|pO2-CCvA%X9Mih>=yUGBgx!?*Rb|# z)we=lRMQn@dG?U0Z9fPY8EHr*IgJW$IL~WSe$Ez2`YJz<@lk~h+N9g0F3})fSwJL# z=vRCA&-?5jUoCd!6twl|(0F6zC~bVkIp?{x$jtaeS?Rfn>+62S%|3l^{A~X5N2FGu z;n20b{1%Gl|h3oM%c!Oz@=INPKyL)Lk=!Edqah;=SxNwJS z-AHA1_X}i;PhvVAla$}hX&EkS%o7%QTLsgCglhOKw7u43eWQ4LbyWO_V8}Sa` z?vqY(0xCC7ww3L%1LPu-$W#!^qbM4CXZjDO7k&5Lm+Q91C43zp;I23t`k|E{`qiB7 zd?}7ynGJa#lYNpaK8?EUe7Nh6ic%A6_4d0DH|5lX!z)Mk3kUU*dAgs@%+Gx)4HDbHP#_E7A*x9 z^2+EUhZ&KJA7|`oaIO=4!F>R<-p1#7tV)4`2l2B3?msO>)GV$YbcV<>MYy|W#qwGY zP{fcxi^m`7R!bk!YZC2mDOx&o>e|$!fxVzlyHL2*Ph=x4X#G(kPT}U)#i8*#mILJv z$;J1gbLj3L>eP1`V0pMV?l|XHC@ilic2;`2IZg>Q$X~gQS|m>hMVxd&!Y?fAR%o|` zn^t}502-Xq>zPTPH)lQF`o-Kmo3wcjOT?pqDQJGA3b|ioSwV{Wys{$`k^7L`Hy8KK z2N4y4`r5LMN49++lhk)O+FhAhVO#W#KBIle^3)S<%oTZ_c<_jC+YN(}T85D=J1iEp zT*>KKE@3%GY?zBO&ynTlWfrP3YnJx^ITe2%8Q=a(p|iLv;;L+1E&cK#TYM&IS|a^# zHkU=mHdn1jN}onTa?;t|JkDjI|GOw%K~%v#Yz=YZVlw9ETT1)~lj5VnvbPb?$=xo~ z>`|htnv)Sx?|+iASY0pKCb@0*$~CMDOKyN=ebpkVytQEwMQ3^vQz1p(;-NToe4%0Y zJQ}01;Jq+p?kK8j&{O-xShVY#Uv-~W5@S=?T7KK62S== z=~r?Nq(e_d{vmJTsgDd+5;}Lq|n2X|EW%DJbyEF*Z!Qu9TVy%Wz zs83n8S~bo@Z>)CB&i?jd65GKTAdl<$I%;JWPV9qR5;sW#jC6}Wv!Icd>N_7kGlS3d zcQ2w5AI7!gH9D8k0RPRY!gcybIP{MtOJni{Z8!5lOJ+b+t_ov9wq$Gs0H1WBBiP@1 zwdV((owKE`IB*zWhu$xF(Ow-)wD}(&3AL?P;V-X*oV0$3!07_LM!xnBN*v(!B77$p zd_pep-))Z*Hs}=D)%kRSB`od`jO73*J}jgJW>??n&E0OvkoRl>Rn|miLlHPPHDeEs zFFxZ8NVh;{byc2FBEaT*c;a;XZ+pPZF(^=zPZkvj0n5P`_e0~QMGq8RiKMlX@35#n*TWLQtf)Z=Sz3}? z+bThsD0e|*e-nlitNh-q(k@gdhX|wttC$*WRwME9OPhmPPC8szdqa+$gTSLwd$2$8 z#=`$(B8COGx5U&XN8??hYtqkJfTD0lyJ$2trXg2_8>!r01%Vsf>b19u@4mm$cLm6# zH(|_Z-y^$=N$wQDQFMr0IF*1c@U5QLdhiQDA4fTqkSnCkl!|!nRJk>lU28sh_QUAr z(y$@$Xq3m>=G%h!LN#*b^-cG=tZPdGByS=Z6&(85l_u|5c2D0~jyaqjKSIRiJmayy zp%+}DA}3ZhMoFc zuP~pbexr62xC3g7AgxEBl?7M;2*)fnF>=COdeZ z<0H-rto2NLtfTHOm3M7Kgio}tS~r0~%ScCJjTZRj$f~_kvdgzoPxS~ogDKJODfgZy zdODkfPaVHoqa-Xt<4203s4utH9ssj6KX(WeZNo*`f=cI2+IK$m7U-XUf;f4mta^GP z#HI9LA75aW<>l^s7IyS2MMIrLiyNu8w%oSHt>s5t7QVe=d>rq}&Z1%m7feBjuDi8v zj#k;ABKG~gZ!-&}-2we_ygh3;E2B0E3c?#IyX)Om8Jm~)ODlyA_cRJd8hf)*f5JS$_+G77Y1XiyCjY7iE- zQaw8MhGd%WcmB(%QLw-uCAEGM%mpUE=E(5FSz)i=yGzO_FO=OtBFSy>X(feDu)i&q z6M4CwRZj~ot%qYsjp@x9DpJmnUB&st!X~}~EgjQOUnyc?UAd@T=iv$p4|z=QRh2aR z1fUu%At8>-Je=Hk3%H#sI-w$b8mDw_qW}$}TZ~$&3Q!I$JieZqBS9RdAhh}jRDwHz zv2IS>xQ@zfD?&kJmLT6UWUd(;DWqBJj1@?)FAdV&dR7Qp+^!Du%I5{0b4gu6QO3D1 zS~9rTwgm;&4h^N;rz`zZG8t;24Q7(w{9tceHy*T<++1WG(B9ux#trk+y>deC>H%w% zx;HeQCcJ!`lxMvrr^rs>;t90jXmUzn8j?84PQ=8&LB(xUZt^ygNgr)Hq=&JC;eFQy4A z7q~4R1)byM`Cv=Xlarrq9j&88my6J=aSq}g^{ZM-jU3~-Ns6+q<8SnW^UWUQ*ed1( z!z$ifq+XkD9LOhxWg!?!Ch1R?NuJoq3a;q{PMultzIp?wU>B4|z}eX4@jeh@bOz!Y z;#V~a$$Cp(*?P!Z1}BBT8IUsGI-KAbn&4ow^T<(aPq?y47jEeG;C>KP?B}^-iG{Tf zS8v$0!y<)%e9dcdoj~)-2Z-Q}uyoIV*=)=q_XI$r+n-Y%u)@2TORfoz{<@1Pm}uPm*GlpIMiXplIEmxC^h zy!;s^r8lG&jaKkkJPwnR>|MnryH>DTSp4VZ5iHj=twUcef^Zk9MtF6AKrK*u=vJHU zyMBJkC~$6({OO?kvg%EvBQ+ogBmA;rB*Lho8fgu5bCcrEBNLnFqWUqdZMrq|f<4KP zc7~x=l2MY3X&_aCi1G1TCC^?{*!acdOm5|*s21z`&i?u+oNYPY;mMuuT31JQ)fK%y z7b!QXMUX70{-P7qy|=waxr?bkPRBk|A!)tt3WbavBHNRsTk2AZW%@d7^?MG2GYO6h zixs2gpkxrEbPZF@%pu%B2jy3baOuA-y8S6v&#H2%dLHTe@dLrw&c^xsvz>?N`YkYB zYdE`&o1X)f6IT-c7(j~0iXK*gLz@bgu256cgwD{7Da50 zVrj)pW~kvWym-T}A2=x!_|)hNQZ!YeA7&z5cX4hm;!Teer2Ez;TP7YA17qoFDmZ|{h*j`FQ@J95$}FM#$HLtsy^ zne}>AF7zo%?TslR-JHl2p?+fP?P4jv*G&A8A3x;S^53Owrg+d7>yvQUNiG@4Y6NRhKTSm9Yl4K~>_i zfIn2jVw46aAdL!69R8fDQC5l%@C!~oGJ?j%#6lHC3)A#JfD5)bA{uhlyC0bMivn;m zyHr`qN~F1fg{UXMVgvnUi@kJlrwS*F)^xdY;-t&IeXK&wo zzy6T%mMwRg;pW1C{6RLzrG09d(qIe2Sy>ssQM=05E&h@D_10>$pu1vsA1-`zA+2*{ zuRF@ei|s-0=n)O~5$E~MN2DotdomS>*hxI^eu4(ya;xd~Mf_oY;2+xZI#f1V;v_~x z0)ckZ7F;@#9FzHKrsZFP=htJ3cI(yur64$i5bI z1bGqQPd5kiQw6wfZpNtqEDh?1zQ;v6?v++tyR z<>{g7!Ep|@+w-Pp+w41vOko?N$)20B5h*0{m|n?b255iXH^K0R1rw5?q@U^(=phS< zQE)Gp25J!oA=Cu-l3B1dveF_2`1!E;lrRqRWQxVUmxY2(x@50j?M^p)z;iVU;|=br zMIvOBenpp7U$$!N;sy(cS37qf4rIAkaLIMXaOzUxK)fnAr1w_6p7?czWD(&H_~LAq zqo#|=SERl~1KVw-bAV%$0xWG$1UQx(woV!gG_gYW>ZZqsB+LpMdS*%tRLudcpfmIcGTd1!%i7Y6b6heHripL(f2!1g0J z0fmcdaTem_a6lSCK4E?%=W#`|v#ZMjVAONsarHBLHhhPB$JloG6q0J~8GRzIMiZl? zUcN2*?Op5z*|rH6tJ!nFvGW-2VnbOX0)s`x95dDW=ipF*m+z@UpD%L!3<~ty9y2Cu z)w~-9;%EMIRdDa7()xhZqV{}$QEH%Mq`z)f4#S<1cwq>>9b_Ryl9 zN}!US*bzqOA2<@JqING^|K2`6)qNDHd4=GeY%_yJ+*&{l7{wVR7;eF$C{cq(G-rd? zGWdOH_H~QsB~8V)xrj~1CRfAUjL0@zz$&>JyL2Wf9P4md( zA9g5 z5^{S-$}#@y*G-JqIlYt3qTw;WY*9lSR$|(@^u`5z*I}x+0XAyhGLg(vN=C`9)3F4{ z#vTn)yRi~D5?G}hs68s!$^CrXAn0M!&1aDo+F!Memq(Dq=(BAyVd>J{NW>}Z&HBW;t(b3HS;V?#;$Fj^}y{5~B0Hzl7X ze>+c|n7BcxdYusB21$q;su(5ACM~Bk!m5#@#m0f?co!f~9!W~?(VeP8M6E;_DM)>+ zdoAr)3%$gQ-V9z0of}F%O4_E6z`qodrUd>5sHYv4(H^;IRqA-_?C8Y6EyyFMb(Z}u zX$hQW(BI#o@qq-KP)gYN-Neo(^p0UA>^!7Bm$Tjmyag^{to7Dv(Jr5F&9~wXVrEpN8HBy!EP{`;^S|DMk&4 z$EyEx*Gqjb*Rs-ToQ>aMAdq`Jg#oCp-sU2Oy{8L5f0~lQlq+-{ZA;*94IpPXfyjfN zgwOm7pTHv@T5kv|hk*~zqGHH_)#G>fHK0-kM-6~o>Q~CBH>Z7F*P;ePMDsUNc!=Jp z)R@UO(S|~PiA8(Uiq@O6xA3FZ?^gW(Pg?(fAZZPC8^mY*fsknR8hGE-6O!!zW#9bB zkMqSLuw%{v$&8US>_=Jtg?kjgg?oNrdZ)H9)>HHihG6U{a7VS({u>$nHKRmBg}Jos zRc1+Hi(=Tf9z5au4LtY@KZFX`VLp6YINTPc(qXHX% zo+Wu|d4b*A?{xr1`R!M? z{bioM|G26^(~h$;-YBs!p)if^U988uoTQ(htD1eg!+CKq2oZc(B~EnlR5yGqhp-UW zNo;#LOo9V4CEpVqI$NB*f^d{a7Z2XC{%HLaFO{Wkm|*JTh*N>nV3F{6FDnJm4R z1`JC~ye-@}m*hzPI0*$vpC5v;7HJC86QiLcIm>I4-IM3e5LVypx^kL8$g)?+a%3-m zVu*x|=s#!c&xP~-#1LwN{cUke^eVyVDtoQfA(e;~uFyG;MyI}ha{au*tESN>j*daQ z{F%^dfrHip^?n7*KFHi%d;4|s^CxrXl@AwzK2Ha=8@YK<#Mq*eu6V~w<^$kol$%Qd1jN4u`Y&$Y;yI@EVxqNoq{zvcQ?k29_Mp7 z>IpIFB5;>JZLKpdm6uE0$tbhj61!!|v-k^PtrLZrpr8*!}6SEP+c5rxRHdTHw(UWQHq!9*U1j+przWLq~!Il9F)gI z3dUJ^2VcLHIg*u$F1?RRqT>B{%vG@&=!x#IBL#*N3*L?SYjc+=b_Tl;H=l;f*b&3# zoWGh;aq0;JSd9L2ggAB!&v01*A^OeEkrc)@XZ8t9Py;eJ!M+hM+o)leE$hv}XD+$l z&Sm1`*g@o#>(;~UtTle!8RROXmt3cKN2@O1A;6H%qf_FET%M1)LnI@b;b~PQUaIocf8( z_W`T@?*k^fAY{-UH)KfNo@h$;K6&Qm3cuctsVMfhudUhc;k^FPP56@hv3oyTeQEWg zs2vTBPl-GBefzUednqI}>@GY|>o~(EIQDd`^9*a*u=m02BECmG6^ zf+-EDy@k-ajqK2G`c9sIN~JGJa>n?Z?}`AF-1sY|5&hn#y~OEKL9i5WGP4t|S3$wW zKoX7`^A`gwovk>Q#jR17!Qz4_?U7p7g)xeGb5GnK1ft&NSf;W3q=s_&mQJBY>&E610{!IiGJ17;h;= zG1kyZp9e+(Ns2mtrmduzdYP(x_AYH~PV^ig#T0zEpxJsP*;~(cxJJ~*bN8g3)t-ag43mgNY+a$9M)QPJ1^)Jus_c#iFbTV zS%H7R=9M;Dyy~*FOQT=AiRKl^M4HVcDwaMOR;y3i2jAZYYkf=9d9XRT&BZ%OgQi~B zQ(Ufsx-g&aqVP-vZqd~iPV&@*D4rF?>zCBeGVpfSN$Q!qc~r zvl?*#1WQ(cacn|$64m1ue`dYpTEplh?tZ*bF+sxO(*u^-b%5(YE>0-)1%gWSfS(0a zF5BZaUY6MmV?;uicFr<8Y?LGk*9c~Lv@md!u=?ES6tqD!>fE3E6rHPw)P{C14++m| zkd=3zNvv4Nuc3Hs%?1trx5CPQf6u0!@#TW@)Sk+;-iSUK>;AE*^H$U7Wi*v-!Dy~# ziAMUdsbQ){>W%~YtR8zI2xyzZ9NG(2+V_`%K0SstrPgy{YY_UQje&PwMui=er>voq zxxf>Mlzb-)uOVy)OIs+o%t9xRj!}DkYdFKE^QG%7kkb!HKfinjzc!B!5@icO&G9{3 zbeX4l?Zum8W0(kJXT!aV^xKy9+knWFV+I7PYI$+)Yevu7lSMnrZH7O0C=;APVfZb3 za=hvGqb-+9*v-%qrs)B)+*({qg6+)gytdZm)3IdNCeW3rG{r}j!&j3N=t!;k1R_SJEK zlS^sE1NgA6Y-8~2zFplB9z2Yede{ERVvs$mN-hKH{bLZ2E}nO_CxguB z{RzTo>}nA9SsDV3r%d_8+IwcOz2OmkX*EUV9_ zBj78`!bS3o-zK)G$JFs{2_7q8_hATxf|iBNp~bHBKyqW(II#0?S}=-OHONP{ zfM>_^SjPnc=@)1>FW{_$yEj~fNsd9nRvVOYa)Ibgo>?L0kA@ageMC+khH7eKX&4!gy%BcH4bp09MQz{;6ud85pUofo$`S^apiw**SOJ zvgvuC5n^w9rg`8gW0IL%JWn76)=+^y_3r0DWBJoLW-QP2kV)&b={Xn3uyXB{(^fvb zq#NSPqQ8XAH{@D`=L(85BOVK%kvO-}$fHm~dx5>n)hFV0*fDIr-dkat|HWe9`Bsvm zvalWt0MPsm{QlrWj^Jo2kUZ(WB%febP`8uqUX|swzoVBnF~Qcw9fw%z&I~~Um5+Re zUxrVwNL6?Nv)3?G2Ygg~2ar&P-1+ERTH?hUP_f9nFDq`Xh*O*rOzp99O>lp`dlAsC zd&x}JPJQ{hiMdg2J;}?+tt8Utquz(eSfxYlpS1uLnbxFzeqbZ3k?st^s>uPgj~F5! zZn5cAIiyE4HKYTZt{Nmm+21ZZt5>9rW@RQ>0GYj`ERpwJ+(LniSy@QW$U#k@2|v^3 z__3xzoVX4Hgusg7$Mt>_Ewuq zkmSUERT;uxCEas$cjI2Q-$XQjzT5?#&A+1>gzs}q`@nOW8Hka;v@2sM?0Lg@@m%Q_Ta4ma^_Oq4V0mPAHD%7UE0tDc-v z@I!j5d@kaoFuU40cFp2<+<9&r6a+lPSP3+=Cx3OB|8`G*SEcS(%J||PFv`tUakUQS zsEgm-0E6QMX^f1>d#ay6#7=Ksq;D0&RY;1G4ur!{ZwMVt`zo2z*Rd<%Zo+y=A%~FJ zv`D_ItH8jAtPQA5>*46VBfk5Ixu-;*z6ONBsJ^*F{v#Jk z@XA4=9t|1fu^AAwvfmGx3Xc)?+20IQXpPFF3_LVk7D44Om-7S{a9t;mi&L3JUgoBi zLp^}ofeTj!hb%Oh5Q|JP&gX#&E?Yp#ldfy0ubqONy{empo6qg=Q{gpzb_%lBY5XVY zO_L)Ig+@JTT1HAty-Kj^Yc)A35Fr<~>u_+=0%J!wJMV=6AmR3JM$nmU4m5dE4=`M>B0 z{iTPwLXQ!s)?#e}sbw}&P|ZHaK$-pm1)*R~jr z>9%pmJZqSC+wjcbT1x6bYVM=!Z2tvQ1Q7+-yGO4DdSP$2KqXX~Z0uE~7h=AA)fCM0 znr{X{ebD&z5~O%B3|8$kr2&=VfoG=G1uL|1QO;B)6_~HnR*!PM=<~PK*pHvgwLwwP zxV2oPg zxO4c|RF4=8@2Ge7_Hg=uMYkw7l+QwFbdDy9cvfe1^(^|Ak*GHs2uG;L<8_1NHn(ak z1)il?E?Q&WDVj~UxWk&#RJAgnd#$VJB&C1~hQ5<;t(P?n+_R;&HAV-MLdgg{W zO_Z35%JYSxDrImEOke90dX*_@R7m$)rwYF$Mxae2F%LcS4CmkK#9}BXP>;+nNn^x0 zwL$}rCepg%7vK|3ARGJjahaTpa7$>zh_LhMzcuJmRzp`d~Y;W4#t@ze38-mEZ&fN zVvz_rpo8kZg!c+F_WznU%rvIe!r_~ z#n!k~V|v0p9MbjS<qWM}`U$5(2><9kxp zLHUE~BB)dnx4xnI?-L_7b`sZ$5`mv~JoKGq7zrh%r+@cn2bjdMO*l2R7k|6U{v7RC z5Oz7CPirJA3*-)-=iE^@p0JIdeBYBZ@})BY&V(F1z~;eB(f{y>&=lb-Nq0J%>(Dfpmcj(JNB1*w17rnq;)&o_5F6{KJc+6ZaQcnE=kdH}~Nd zULC(mzJ5KMi*e_rXf}P0P8g@pJe*ryQz;Pawll?sVx{Mh?tU7Qc0J~cuX{gF*;obQ3)@&E9WX@)yTujcL#mo|sXnkI*XRb+JHjh-v zF4Qo)d~K$FAG1no+_xkwkBgef6?2_0r_4~;$6JXm$&lI_Invfl=SLW+sqev5pR3Cr zrgl^o2HppTre@?h6|oqKABzyU=Ch5xUbQQqA1}+yzc3-6jac?jnyMuF(-1C`eOQ(Q z4(ljHzk75{?4N5l_7U@8Wjnl}1;X(PU9lY6C=o3A@&PUb)H_edr}w4iw~96&a$K`h z(hjlFoz$A-jy19#zEETQ{<-3GTf!w!z8q8;v*_RL*iD`MFiyu2-F`SH`gJH!cxtFB zG^P{DdoT2g=E1|jwY!?DE1eA{mJ3hk!edfQBROn~O$!eY$HZh*a zXjePxK9MjiXkBKBCo>~YIcYqfJCbwf4o3y!X+*s>F3R5!7nLl3ASrT|Ti$tb;Jo9g z5etfv)8;`I;^6MI%52XvH^0-)EiungBw0FoEpY+|6_Me2DgN+V!L}RdWs?agMA``N zXrbjs`6M*7r`xV|)w?KcTh8;uZXHTb2_`c3(fNqI#vHwKn4<@GpG(V{&M#l5!)UiS z$gPQVY@?x3$@S{+MH#DOb$o{B+`74=h@DcL z8cDt2H$q~4>R667#{uuL7pj^U7`E3&SV@~hf|ao@h$3tbRb-gda$l7ZuJdt<2p{>j zDnT8kp8G*P$4}yfw>BVVwDlT2sGWoZr27*!ESJR*^a)iKpAgL%0wQYurlHZ7x|UfQ zFKhXaXmsxNqz2vSNapQxp6}9R)2Y1dINc%ho~sJ9Ky2W8Ys>LPU@wt?S063Z33};2 zzwsBR@4NZ7L;5UYk9|{avr637&M|4nG0iu%&i!^Lo&N?eP=igWvBo`BJ2KN|AAM6p zw8~|>%h>Y#H1g{E?CBn6Lqf|5eAK~#SJz89%Pi%ZjGMY4;Ws3L-#?$_oSe++HaW{6 zb2=iw(<$7}k|pNJ%286Bit;2LWBffs}~Lp+4cvlEJ!`d(D3Z`p*4 zc)oDLvoGb(y#IP~R_*qlZKQ+J^n2l>G^f;k5HY+!(lNvU_cxpCujcX>%k$l#7<0oy zRBNZ&ytOt;vZ^=IOs-Fj)8&-}g}8MUo@2q*!#qi~wZ-Vv%4OIMAzEyB zzt~{02bisL_J?&cFvUF5xk+ZOpCp@E-a>x}TnQ;s@Uh~~yIizm!P>u*8`m353C#h) zLVGKXOvlKmsMs(9;U7NS#weph?P{y8%Wz~$=RM;YaJ@gJvHx5aSfC*~7z&k35=3L) zU)TZenBdH0R}NUz_fn=u>7~HqlCm0_r1gbJ={=M_$lAtUH4cYL%=aaoA}mhxy-puV zb-LRD6u8;gi-G9}?^xiXFdFlK-;b2;N`%phr$KsxOM2jQ}ff2 z_)j+tB$>c%9_ZqPD_H}sZ@SDofmvNVcP7Dns!N4yU`2axU+7$tDLlNT<7ZRO~LlJCB+pqPMB zUS|MIy{kZ9Gz$RetS3FJF`07lmr^A{q5v3)6T`@@Vl?jgJ$Fj8COzTrFvdG00HdxD z2^DDC80iOWRm$j8b7~4%!8k)p(|tVuO_$Ga2$=M0D+!9Y>4T{e-iP~JS1mqW-u{vr zGKtY0SmayZhICOlfb8!8Kb#7apyJqXvf1h0*m2q~1N;M{droltc*Oc_cRaH(rRB|= zkGRY}pkfB97|tN(g;G*e+c$fIGtjy~|I9yaXj^rwPY|f-KhMuwPM;woGrsPb_spfV z)FG&&PrHwwX-&Fy`TBLottinoDOYlmICrgW_l8(e{Jmk~tx*ZnwyrMjq;D?%iHGNd zi!PqhhZE-lC+@rbU$`QMu$t+)V+H>FT{sy`w`7{-kMdy~5PERGNr1@M1Nwc64H~dX zcK`@ig61kyW?Sxl9q3DFi|18Oib3g0R zhn6B{jHogMz%r1QN&|*(6Dw#(%JcxL=1Wg{5L!2;Uc8{0g&Jdm=O6}pk>74iEM?-u z!Fm!D-Ms@*hA9xz;AtpNHNTwi*oR5hae8|?!=j`CYf_W^4V&R()c|wK=RVkFtI|by zEDiCy&Shn`QCNORrjs~HTImgS&0WyetdbPxHvSebj1m$L~Axj`+_Ay z0bVslsQIh&HM9J$2cr$OjR@PcDr~GDs95i`>*QhU)!r}Kz7O3XDUr1x?G*;tax6lF zB?w^I1|0PlfQxpOns>*rz7T~Pdrc@A-%hq^^f=gq=-%>6&dVH;L(mD~xnm5)=u?m# zw0FBuG?RJ93F=PMfMlFwqRT*|`9cjYK7Ra|R?bVOV2U^>7w>*H(6Q)@^H~9rq0gJw!ZV2ZQM0i zTe`zWzdnP~K4@c{(v>t@GM`$;H9e4@Tn?`Ew*C6oBnlpGoj1Hea}j201lTFQUwefm z5J)Gg`xjmG5eooAn|YR%Qt;x7mw8F1IWG%r+P9!c@*PNS+`&eJUQ3L1?1Zgv&NXYm z>*WkK8;L~Uy$vL9cYaq*n1NS)aBu*+y&}LdZ_jyb(G+Z-NuaK^7DFysvs=B0{t<&b zA&{51eUJ*rf4;IT&ZX(uIgUPnxzQ?T!Iu=#XNhr=nHHqX;#WU-PkZ535CwnHv89?xm_R6CY;*1(OPA((4|M|_+ zrYcxLm_iWiZO7Tynicl=X2)qdoTB4>^e!NACW7mw_kt?&9hIgddq*FC^FCQS)#;%$ z^7zqoQq+N--SqNJ^(wa;qTj8-ZH2v>M$bd1rU+umIJgM9hANN$Fa=5ya2YuzxE_Ox zK^86sljGq{ex!#d3mt_BlEZ-5$ zlkKuj!Q>P8$WYa~rGBVt;dSVpw0S)xNGWk0t5EIyJWqdc6o=t0Wn%Y*ehz;4__mpi z1jDi-N125a27)1pu;72)c0MRV%$@*D=zrQ1SSuhjV1&Uk16(%ddcHzqNgSsq+F* z%p_DE`>}vSbYsfZcYDk%M{my0iX2j3E8RBX#5npZCh&{4db+!vtefXhZeGc1Ef!zk zvn()-D9@;b2d%tw@~%HV_7%7=0n<>IGuwm2Rjj+)<^mJcpw}29a<}HTQ53H&k}2Nt zSoX?RJ1@?u!2fA6c7cL|8G7lW;G%p76~waQMM*WpMBnFw zCZ)hCWXvTo1W4)UBmar_fDc&i^W&AEPKZ5rIyK!8(6F0uPpbb4vUZXK_O-`tg!iZO zcpqbLcnjDZZvZ|ADB79>z-cuQEDtkuV(NE(zRoTYesBKD-+>1^IE8M?ZZIGsh|D&9 z#P2>idOr((DVps{H>eH&7^b8$e5ScYRqT~NT{$X7_&a-5*zZ>JH#FazKe zD}Abj7~#kCAh6uQ`k^g_&w{$$clYWIV4BlG03&iS0nT|TI#G(w;5ba2_LU- zyvUUm{!k8{k=bHw+V$t-|MD1r`JeZZJ`y-6FRufKeYdnec0AIXPzeCLMqb!52`Ha_ z`OSa+brz$+Jc7?Lstw>I@fIuVOot7N7^~rG)s7^=Xg2{N2o`JAz!q9YM?L-3%KY<@ zp&7VV=MlT#zDXtulrb3ko!~fr?|aBOP5x2e99}Nw0N)ak+Kl|K1A%K_9SeqV4(2*KzB>5EB;n_4yypO9RrMw zNPhXt|9s>r1yng!VQ`7$IIR*?RDN81QAR0rU()$t-=&!R;{c-4aF>TfCpG)q_O{f` zH{9n$?-VbL)O30by_93gQbcBd)56-=35qw`BzK$w?hB=QJxZof_JW%d60NC3yrEN~ zd-NFaZHe7E>^kn63}Ar#?wh$Myta;j!z=^JZLmASFTpnE;$8atto&m;eczbw$NL2k zYSqwgD^s4U$*|tV!0&xfE5nsY;L{q-!jdRZ8s=+zlDlC^cc7#ty;VJzhVQyoh1sQa z>8Q|gI?>ZCvyl}eXD^@6%gZ8X)wIDL>rkkOSSDu;TRy`U_3qt#SGm*imggJ~W#c)} z&qM+1oJ=NV)V)eBa1G&`2Ir5lJ*eid1Nrw+`S}xS1Oq4^IdUW&eCzO$9o0A4i1LHw zII=n_em9+b9jR^B7mibH7ykpgS}==Xd=|-JC>o{fz8R;`lYj0?5S6Q>9`#-@flsp4 z*UEd#oPSL3>{_ZsY+7r#GAfHiE($si?gkm$JDNre$C<16gDmMUrsC&#ew;b3O9rh7 zz8gF14u|^=?dD*ssv5Z-t!YQ~FGzc+WEyJceL7Ff7Cuy9`qXR-e4u(5C!7-8Js zKv^xaW^J-6UCqqwm1<-S+qBh~pfRh1!o_pzO$gV`$4mA$o}X7G(v4Q{UUeIK#Fq?R zHIh49OHAI@!laj(BV(lie!E&y*P-}27;B)FHRNJVn;^g;TRm7dM{ml59@2^(}=Gd z6{D@T@m{OG?y<+K6v=Bn+?JF6aGNMHHLOJKYJEZmuax2rRTaJ zEixo;0L&LkP+hsYx{CGFmHTcvgj8q*1hk+vg&xug>mSbjVfABNKmECBKLmCKB;=;2 zU<-Z+cXQNOA;s_1cPe~e=k>f1X_v1*!AggF{{xO6h^W#vnyXipdh>N<5DCY6g?_>S zOFBM?ZyoKGN8r8_gYP?jCX9(@>?Jk@0@S<|_V)LM@8O*||LXyyrKL3jkx9VPj{UeT z3}i4a1>5n5*P%iNz#Mt=J3HV3XoE3av-#y38QaE&Kq&(JOj3mVfzs6@e|}Z161@wC`oAoR5+oJ*Hga7--1qxqhpG@*D~a*;_>fHX1=oZ8 z3azpj>IUvnAK`oBXL){Emm-WyLHWZhqdMR%&U%zhFpEr=;3(fMy$9X*Pcf<1fPan` z2bEOs*Y?9RvpuuIq+4WBdQ(E%aeezxDD`b~^QyaTk^sgLECoCzAxeFeb6Uh@NkSbOk1_II4JG)pp0oV$9Hg4^P`MJKWw|FOS+E0Fs!aB-<%A34I{eX!@y2VkN( zuT4!7qThO5=9le>eDGujIT~Kr@O9Ao4oV# z@(LGrcP2P_G71<{Br{}V!?_}%dAJn(AF$eTA7>W2TxNDQi&?(f;W4{+OPvCXhRiLw+No%Gu;uRCnasgm9WWz_~1Y_$E*Dx);Q zqgket@E1R(UmmmZG3vYK{e>?Vo0@!nOe6w!?Fh`XWCYBjxx;|6RaY?L{z88dT!CBh zGiGOVuchNPK!IjBkifJ85KGU@EIs!riw=qg{l4w)wobRl8$%d=skZ0+ILxjzF#5<{ z&e0ab$h2nECwuKk!R*sE2UA7}xqf{jl7k6MN_!ULfbl~0oTyiA77ozfG`9<5g1_2> zo%s}BL-70BXDmLwx+4-T;^_vWl`c2EEp+4kdO&8BR~wQwy>d}db|O#!{q_nYUc1(S zx?5hTM{f*6)z~%&FB(A&w*v;zX`B1Dgxu0Au`a>ur5K!xxmOz^ys07kEmvDFgi2Ve zRKRuo^<$3CPxclDdG#>ky38uCXztv(V?tHUP-+uPNlbhv`vsu733n|Q1Hi}7eJM%( z%dA!ceBX~e5Tg9CQElK#Y&#E?F6MBW`Q+H#1LOvY5tgAjSn~jIPXewKb%s?d#z*-^ zE2RHo#w=0{SAg}}dt0mDIs&|(2GuvyI_L}h4wBnnrywB&nC$mBdVOo`AyM41 z2`EeNvf|AnD;n$r4i_U>jACH$Ar-K!(Ur^*P0x?4qbRlDOT2gkuO)^fmB7d8#1Z_H zT%IQ=xFkW<(e(Ow|Dv-erU&{_wLm9iGh^qBhLbcqXM2j~Jk|%GMSw?J4Zy}3SSHhX zZnpx8FFKUghX?z`7Cp>scYu*CO{3`US3oH!x=9$M-vL?=j!t)q_O(7hn&0*gl2MW( zYOaTg{I$byPT3WJVRGMh75Hhh^e}l3h%UP@S3#W4=IQu*fc8-mWr5i9Gw7}!>H z4HRsG9kRU$orfz>;1q+XgzUp5zTi==nylDK&0LOD(4KOQp@{J%gF ze7z!7Z1B`4EZ$@aq zeYux4X}M~K>Ku<%OqNX$C1UYE}ol+d-sR#f9UEc62ZjXAn}Q4Xtc;+Z~S zAU8cy6_K0lVnvr{de$<1^<5a zMK`C1Q>!Iq2_C8_nR3@Rc+HzKxqGp=?AQ+c`B#7kV^-9Gk!QOts^8h>uY2R?QSfmD zO);)C#CG-W5p?)-uj>?AjIp$4|9Qv+@7!hYCXa;2xMk zOJ*M@w8?Pr{hjq$lfVfWYuw^)y9jd+agH z9R;Z`%LkLyPV;dV2Ex>kfEbNLp~TK{+`aki)#fN<4Ppgd@^6ZZOF_-HEVmtW4Yz65 z0Mh-Gz^60djo3V(=LH{xDitNBc z?bK>-=z@btx?)j}g@u)&keHd|?=joOVE8ifBUCZ+%p9>aF723gCIeRb;W?> zrzuchzBt4F?z1tA(fDWMmL)+i{Nd_*QNr$9tsyyTHMO<^fCA2dr?wuUux3ejYX7tLfbwLKUXWs37^R>?q5Nb^}$M znTVR@4$J9E<5usfp#jd04&H`&fl^D>w}Gd_K2BVc{E{vc&gf#(5_%DNLFgWz=KiI7 zj^pmfS|I;1rSr};dP~k9kX5LzEL>C50g0J>fDfJX7?HmKU}Gm?;$2>?$^MvnJ2g}k zmR-}c*ffg`gRn1V+sn!I{|{$x9T(O1y^jwFDj+tX2#A89ARyggf&m6FbV!KOA>E>2 zARz+MDoV`^-8mNB$dCgl-AE11ZyoRT>b=+ZeSN;a_aE=qaAgkX?7h$4Yd!0Eo~7kI zN+}2=x2b21H4MjQHQx->$H-TQVOZOard3&$rG`|F2iMmxCycKwj`6ON4nJR}p+D9x zPwd>KfEiL`$9LY-Jo|+zBvHeVI<)rm>E`uMyf>2cJ*c)Ta01gU^xd=v5~wt1xF4cQ zz22(ACx1>mWtTp)u!gj`7qmDNXHfcC|M%Kdh%0L7F6Geyvcw=+Jk8qWKw| z)S8+WLyx^mUs8?k3$aQpUMtmrb&W)9M zH;t{u;vZcsA7*=|yoGZ6HQ&IsYn{24MF(66;(6W6C=MNZgZphC$tp`_4t~f z;MP$kl=dq(4Q@G)(>+XU0nsvc=u7*#xvWZLoedH^XX>G*|-!s$HI%V=)kFaJ=nY4*sEZ^njb5G#1Fy_XRe-7n5p=_hL zrJ$mqRHbTfb92fpkLH{7wV9R%0jmx20*A@S`E>HmeomzqfjP!Ck*YE=YZ=+5y%)g- z`{ZO-ZrIfXH?A(n3I9<(wR$E2gZ66?3!lPgYdROu_OlBw(mti42)&2JZEI7;bPmkf zyZ6Wv=1MbS6ZK^6R{Y&IT1ENwHt){L3w%rwkZ~JG@;@Z3EwZtZcv3z4Gqv~G5MjVt zS#gkqCFanXlx}aA*N2jqZIy)WJs6CiPaey&+h}s<0VeZF1BJi%apnlBmajxyc`bXM zG+rp|%YEup$(r|=rkouPp+~>e z&TtdgxI#WZOUdX=iG;(N72P&XzZJk3)7b+uU+_x5#-QuC55;v)*c2KPe=S%a?OEJ^R z9cP!;b2+_JeRzXcyLcj(IG)>hw&wurVSh%>qQ!})1tNAstfRd}iEBCbQOQRGm{Fvu zZ?BnTTuvqws#`S=Gj9&}QunnFQ;xS8Zo>!oEoq&0+J@O%FeClIs4{(*_9r>n?M^wc zx2f^*h5R)`zeMVPpm`uLXcquu^^9kr5!T9GThId-dY*?NQUBeU8^z~Nh}$j&P2_O- zgmli=_MAmm9x_$kyN!aPQW+O{xuf~*z(QV6u&wc>iw#qJ)mc~Vb6hzY$T)(r1Ul)F3|K!%tTJLS{4xJ(opDU{qvNy^sEu5=6Gc~lH z3zx@vE~}QVtZ3N{FK?D(WO_Zi%lu#mAU1VOdfn> zFd7=xs|Ki4F)okBE0B3p-gk$Ks;w3p&JqpPMh2ti?1tJ7ybv7ZfMki;u%-SNDBG|P zsMJ`0;~LcC#TtWlZg54RZqr`N_BEt3q6X zTayB^_P^H3pSTdk4F#k$k`|ZtoJU#LcCg+sfd$g%jYJ?HQ9~|gc+pmi%z9&ulKr?H`WuN9V%C@o!^7{BhSJ2#W^Hepz z9XvL|Apc&(`QP_v&ZWPK63dN>ON-MEEVNX<_fmeo7MwBGGj+XJ017${?F|K{y_vl%-k=pPqD*;I3s|c z=n&Pz+fNw%ur2LrhkK8mPQ`2N@Bd7(b(Abx@wuqaLhi+az+4|RoqKIM;T z+zp2nd7mU*zYA2(sUgluQ@_9g%*%h>#*aVwjl%7c8kUi#sfm#2zOniC_P1{~Yb*#4 z5HE>Lvz<=FkG${~Sn+2i1`|huLqTJ){>6CXLr#GJf@&;V6eTw_z((V~uZ^)7_<(Rg%+kE=TTt@!# z8#3?PX7ZnaSaK20u@<~}#L+(=&4~@l{gAtaBeCcGU?m8;sSqp}MB;Al?jhEB z(w1NHce`S@#4AXX^(=qR8oKz$=r}!=GctOE{Z5ruruVn`BWMLt4%Cd>?+NNabd3;M zqu>^%_BNvOp`{O;anq?sW`1St?p8>U14N5Oh7U2mrru2FOccepc zQ4~n+6|jOFuay5CEQH^5xcvSHM#u_p*Qqp6qP)2XOGBt&;tC@fBFX78ICy~KmxWQa z7hrGt!=iuEFXgBqNJ>0)ipb{WB@HWKvVhm0+B=&So`N17A^vAX4-e;51Y@3;c53dA z0|}pnygsh$2c+Tkfiz0&k@cOK+%{cTMe6;>S;1V@Hl@x3=yuJJ3TuFaGX&O>t+8f) z+nB{|&uQkGp8$8;=LlI;?9I%KwYbyF%lp2*mq5!f0D?a7NadB>(VNTE-U`_OYT3$&`rM*E~u+_R_iQypC^KV5xqnrRJEguG?%f{`={u4(*5gX>^g-Rq6?T-Mpb--=Zdnq)yveGQ`PK(uE7WyHVg9rm7 z4N$4Y`aydNJdCH{KJ7fqnA#XgwF~|SjsX0n`0Zleot6tw|2PKpON4lu7$<5{&je)h z9IC*aw}Ysqk*0VKsjErkOCYruF-wtUMT3s*gi2BnC?Z?$4>Ry;p9;kX%B&={OidLA zq1tt3r5JuWL)xg$UARyU9ePf}`+ip^asDs1LOybuSZ}ydvPb2_^goyzK6aUKGShH1 z_r(ddB=w6*aT3QI2P;pQD8+>-CvMRC=?t8K)!a7-tGTBD4-hs~XKtDG-8PGQ2tEc? zK)JO_@w%$9>9FIlj5fg796*=3k*LJ%-2YVc%IzW$#{wej&m`m4uCnGZ(cWZ!9!zQ( zPH5++7bqbv(ClDR9c)+;a1rFRMgWiBdNM*Ra;y<-kYWSjaL911s}eMY=L6J0kmS!K zeglwfKIqb(ySxN!zwmHfmy4y#Lx-9k1C^5y$I?asE8t|kveFK6mp;5VhXNt(PI?C@ zexvA>&h`Tc6vYqF=dN25E_%Sxt;+%4V2;~Ds~Ta~jToW+CK=!w-CFsq?*{)!*rjs* zQq{9#bz)#->(nyrM|+6ls?746`N=LdfW>bO&AzAMQvgNaK&YB-@EoD6HBf5y;kTAc zcX0O!lNdg|(vwh&5~XWGAftDSMbo9+R$vSTXfeR3NZ|o|Qy%&D72X0b=Vwr0>D`YS zcUoXvS6#&dcyV7t76m01mnNQ9!XtqX9|i#v>cv8)9ozqb#RU41ETcM(!5TH8UVlhK zo+po-K)-$iQi3uq>6PW>nqJ8~4KnMURS|-L+F4xQc^eCU%NOi!XeRIdH0X2lESBrF zC-K4@m@sMM>x>b*J`Za*GV8cPF*;~#3!Y~`x~D0NTjuC_Kc#17jT#?kow zeJYOKtuv6bALLk)wj1)N66$rEQ_Nb(+=_Ih5Zoo+5>D#&pbKNaqI^V7D$-oxiDi z1mNH*SVVl#E+hzyiYo`ILh5p>7>|w@TP`?DZxIKc-4F|HL94{^M=df$p%L^P2Iz8{n$&4aNeXsSU2X9NN6#LBb#tau&n7Wa(wSuW|lJvkn~Bu z(xul_E@#i2x$?k#V$Q=*4P};}a=IV9fEDo*w>1?J69Hx6wRXe62-DBtQc?w86n7B5 zGxp{PI0P7{g!EgH;STqL%?$^;d!P5VS}CEV0OC9&j-7DrdX#S)xadD0y*&MtC=}hA zu90_r16b8pD!WVFT}K;V`P|-(tJ~9DzFF*BE8&PUCyeRQMhVY@YQ2E!zP;-$9GYM{ z4#0AG^rtoWUN!i!^16i0LO+T+LC_Gm?|W&s3MIhWw$Cbg4uqK^$ZkX_@nG%C|IT_M zVf@XJOtk3u`q*=*He}KNpRi~=0SduE6S?GlhP*qyd@!oPiJHkFDh%bR+BDO z?6i0*ALpI$6>~NUeIwpQbsq(`H~-=-Pkyyj_=Sb>)?n(vn#XAibra?qS(ngehsJrd z@@f#-**(hH%bPvY>{q9>Z6}sw(UcuWmTxz!jJB6PrD#hsU3vO2pQE4iN*K|VM5Nl% z1~CRwP*ghO+-?kSpO3=2s|H2~IPDhib#UFSK{vSXEX=Jq=1d8^I&A_)QAHFc6jS(V z4os)m3MVtr5#>9*?8gmo=UTbGwkzfZ5~w?tCS^C5uhtT(GxEF~?qeCddw9Pa6J2?c zqS$bXVA=<}dbzXz))GdM@tjUx-QwfBr00W*!O(}psMQ*s=SyfXqs*QP`_g_?EAI{= z&1@lKas0*EGjVNPM#N6dyyt{#?%*@koMm!VkYgbU%Dq&+!fvgC=|Ur|L-|%vl)SZL zWx0K>Sx%EXnK?m%p0=qUR;Acx8^1GgOcV1EX~;5^_$5t*I8=B&{z9&Y9hH|wiPVmr zzZ`gp5O~EHeEmVTunzI!+0qA9z?zJB`MR*@5dxGNCN#be11XO{vt^C0(-)LD2J^PyzdzL4NB!oP74FTp|7RnI#^Fl_|~ znJ8n2k)_F7%KYuS=9}5ERtsI z%L8kT=rDK~Mf1Zx2Y7@z@&U$Y=okwas=g$G*a%yL## z^K>1AXzlE!0xUx0A!uZrA4Au4midOkz^toJ^ zhu6jk)tXdTt!v|U@=qC!$qi_|7SwPqggLt$auPV92$D=^q8f*}Rg(1U5B6jj`b8A! zrkze$1mB;~7a)Jpqz!?V5c(i zEYM~^^23g$2);IvHJ!_P@B1gmuQsO@gAOCfKYgmvp;qW9*d@)UUOb;UMza_Zmk?S*RTxN!03N&l0@i3GR8!Bt#u=A5i7R;8t zf!$DTaE4(8T;{_N&=?{n!_0uj@!r;=kZuk*0*X1QQwFc-3M95lM~S{dsZTG@w_L3& zs9O1;%UExA8~ZA!=TH&Qf}+Gtjk8$7U%LDH`bI8X3FnPTzD5``EP{%AQQGeQUvNuS zD7f`~e477*$<>W8A;nDUwf7phD|a+B^tyf6fX+$=?rQCBEo=93DBp8>p%V6sXNsg<_uHRyLp%#vXPJTINa0rQze${i)bCgt1P&%?&a%l%I57t~Cl5MtBj$^0L5;v2e14baqz zEM~S=3dp(>Fp}nZbT_58zd^)p*;$0z{eJ}lcvqO?5=V=OgMSQZ5AyQUVk()yjUxd5 zY5Il#A(;E~5JK_ZdvoX)w5}VxS|%>Iv4B_0_sMuf4M8_yGOi{x?fCVk>qnbpzK*l) zJ=S#VsEkbMUR+#WWZIH?+X!cT-Sp;aTb%6|H-*>xhRETHa>z2<1%E{c{>s#|;ewx& zyFDKX?d`+;D^4u{~emPe!QD=R?H5)=(+}L{AisHi7>SYfw5;@cxxX`pf-%|B6#Qz(k^{Tz0^) zJO?+w@7P!5IsVE={_$x>4#3M@+Csj-YODghOsVw9Pr2O>dh{42Qi&u_dz=07E5P7dTDK5%2qXm9?%p6s8$4421H{(wo*Wip%7lA3OAOl6J> z{UeCWLb@MZL7L!w{lBhY2G6WBe$fhGjw3K-EScAK{8)Uy8-*zaFP@jv51%}NOjrw^ zt;G0P1aL(E=U?~;SkRFB-()gBK-zzOsc!*pK)3AUmEB|%dja(uD*4CY|LsRBz_%C6 zRgINkmXl!AUY~<1#s9t&7G#NgKPe9ahQl)aohs2c;8^*$EB^XpdK`@1I^q5vV9s*E zQcNTl2>hSF43DRRtOa{p7Ff6r0N~|*;5xPS*X#Y`yVf~g6^OQ`l&W4@LKE1J)vdgU zXDDeDfZS)f2PI#VImq2SGRtoPaDNI6#~)4` z&$xH*-Fp;FvcuEU(+3;|XIDW-H}L)e&0ZFksmxN)O4NB)&~c+{joq8~{C@DdHIXeS z`2B;y*!K^N{nzUViaDXQCjYeQ*0vx3q6^K97>{p)%VBfAjbZr0MfrcNi0>0>ZAR8b ziD#T3q^c?l7*jt3ry#>KefJZt6*=%wRX=|OoIj`_O#x)R6i}59o1`{-y6xa4=MnQ` z3=2b@mP0EWCjpbUw@5@=L5f@gEhno7*PH)`w5R(dSXik09XRkp$y6mR?DqEPZdAU? zqp!`wq2X`MNIkjEqM0T&9LsaVAr@VZ?YFM%pdkCX6rFs%sJt3fvIq5tS8!n*ijnzl z=75j@q*}lQrIO#Q&!BWj*7TW83-6-0YsVKR&pg$k!VXuSmzTc`aJ`cusyo|;4WV4t zMo0!a(!(|aTzN4{n^E8ZS#s->_qEWOCkwTCg3kz#n(yhKfc-D8WM462Uh)3HbTD`{ z+0M6TM4jowY2{u7T+wJXfEgrF8sXdU%AkVbD~sUUMWGfBHcC|hp!{#HHYaq2oMBrR^Ju3AIn}YM9<0eF5fwEy*LRB zyv#S&uSSTw22iCjlRQt|DlYDxbgqix%)1gPqb%Wg+l+k5?#PkZ58$i<31N($Vk&^g ziLf8VBz!#s#GMTMQ&fKO6IXjhN4s^;V9r>l_rfus#*BfCrh+Qc4&C|NYMFvMBDIK@ zKOB5HoDby5SYfsR9jb{J?-S~xl-W_SA~w-TWwrcjv;m87x*lJ$dMS2(MDSXD3=L9U zuHq&ZRnhSEwhL&c_2wET`mZ}ZTV4wGU3h!i$3>toC$F|*nwFW}b0g<`>Bge#H_}`P z3ttH1x zbKS5mYkhs)xF~^co`A{I0Cr&IYzehsyHUdR+aAwg)kt{}vE2>&uF@AN``Q3u5vT4)!s?65Fz+<)Zad#vyHO`L2AMtZ3=s6`q&E zLGGYcvK^V(GnX`^$P!MzJX<9nk`NiVj^*lE)?1g?%$__Bat1-Nsq{UD1Ne7m`3+kv zUdacnSE{Tnx)ns-9dF_cSaY;9LD)1^V5w2Jo)MSGuopEaPv`v^Xo5=2!*68HdsZgg zs*4mbs@#3&b5fHc@k*r7MKo6ZNUz5#t|hP?D04`{a;qVh$B}3s-a_j5@*WBfx{5SV(HcK= zHRSlu_k41TzNe>bRkZkJY?FKq>>xO5O`GL|c*e?7#MEb+6%{KqDcG@A;aRstPEKxA zUz>~`>1s=%UktH9Q(CXq^C=h&*v~B>xj1KkKN?lx5vx1rmblY)xmwk3@f0xJZhQ~1 zauKKmDD)Mh>A=JmxcUA*yDa-L@;*qO5JKaz25C~XxGtV(0q_&z$B^h7+>L6tv~x6V z&da|I#iC4`{tH?A;3-qdcEX_Q1k;8^dy|s&+#l#a1Lait`%zx|@-i!u=f` zw%6$o?-Fz`);jT|_`1xK396^hZE3Dn3Xhis-&=EMpHE(E-VCG>Ff~bPX^4!nlqO4W z(fl6c{jy2@u{lCb4_19|04`wRX%En8tDB8$Lf&YloU{E)0Qp&`e-=N#l6j9HU+ zpK+8UqOCtd1|_||?Os4m=QXbBX(xyPTyf*|IjY3fhn}y1`YUlaNor*zUUVVkIdvcV zE)Gh|Ztz8sTAy!GQkw^KUCx*G(9rzCiBm>ecb5T7uK{{(Z7>bghr>YmT?L?lF-W}$ z^^6Qxj#;0Xe!{c$u-R@7l+5+&EB7-ho_wd5L#R~SF+$XlxkE^ zy;KJPBK`Ra{`Dzn2LdL!$XKc`JxvMg%9-~btrPVo9z^k}XKJ%x=e$65bUuJ(RbD1H z_lXSb;|FtNb-HnvO-|@!-oLMcMV!Fg9G{{(mIukOoelL5+f6?k8cm5Io z3?eGJ2SLZ)MoXI~FmTZI)LwUW-6nMm#4svY_3Ho}9D~kMA%I+0zdY?@aPKR$JMB~% zgu2&_Sq-fz(sGz?I* zN(A}t$TA5Oq{lH%HpTm$)Xu*Koy2$~a-kX4L-g~pO`$8F(;6a$FXvkITFM|jmg~|w z^E^^PEApLtUTS|*a};X<1bM`0rpZeHN*=S&x*5Y z($i2O6NY>qPQr;EkUz8LE%r-b2`>Aq+w##E89+ zL5k8vfNVzgSj^aRhe3pqOC3|Qo$^DsdD2tXyu0+C9UVe(wVj?C5~-5h(>+nDvD_@| ztJ&mYT3*kWrtMv~aI-Ptm5Per8T<`jJfEN!Woqj8_{)LGaB@Ed6 z0OGe5!yVj@a@)gl`T!+HWztK z%mL*FI;njrrGL8TyvVYJPMn4wPl$+G)Ty+Doz$#oZ?%dzw2aq;a&v+%X2^36`K{`(tn5&+j2%(z$F1{IZZ%UvW8p9F5 zou9vTsE~eT3#yRrE^Jylqt?28yhe%vH$QGD+}1IO#xwG z3UVYK&1=^3@&?At-oG<@MU7ii80&C9pRxN+3tkzcxiAOcxtW3fW-6SP3tG_^kJV_? z78r4{I^Yt1r7@~KqEz!3h74!9inRb9#%z=PVR2?ug{SUBfh3ogVVCKtL;Lp|opGRz zLaI0SG%Pf$uwR@YUiylSNX2cP)6ALNzOg_~>y;y+Hk{huHkrCmY*=xC>0Q1q=9Mkt z2?Wvuj>^_j;GH?^ge-hWj`E<&q5h ztuxcZSX}%jZa`_`m7SnD2TEzjqqJX~&VEjkz8wHgMkjZZSqseM)BPdAuq0ywHyL}= znMB32Vnk|kB@MBE6&|sOP|&LyZ$YY3jTHeVJVhde=As-~C)#g9xVfEt;yPqE}5HroJjpjq&Y3yoV0@)UQJy6A|JFJz>R%TvL^Z)etm1 zT-MzFO{!wKEFjRn>kg4a^Mk358Lel|owxS!7oHjrhyi6#0%EW?y&dz|IIMxC@>rU= z6nFn->7IeGI!4y6DEj9ISI@kiD{Mid+`{Kq54$>&zqMN?*{qrMUOc)QDSPHp@TL9K zp*ct2K7ak7St(4RQt8h5CvVPQzklZOHHA#GEF(Unh>5Su?z|1sb^cwo(nGN}eADC6 zN}j2?RXIPn0D@(`ImM}Z!{WosOS)=rrNS0U7SKYjTUX!Sv56wJ9l@v_kFwXza?rc&e}{wTNMz1PERi2Hb?hi`dS=G=I^QXDf5AtS$d*G3+RS?|W?4>mk|=bPGNShVYK-p;`}^+(YMC%;xj znGeRY5r+3t;)m4<8ROb<4bI*(S*l7s-s_(YDDkf3jF)#kZ798u64U2lfu#6vL;d|n zRv{SVvvztffCc#`6GE>UFL$sI*^g*{#??s#AmG-kqT)c1ulg=!OyzX^)K}GXMo|=j zSS+!k@#^(^L{?j9+I-&;)oWA1Ovts+VmO-eSl#j6%KPiRmy{V%n3No_y+!9)>Zlb= z6s4)JkGC5Tr7?XDAX>8YDAkvo(ss9N;%}n{snq0HfD6IBz$Pjn2CbgjryT~`(7UG- z`A%6jzVycALX1_y17sA{6Ns4eI9d9l+`FQ3mGfCCNjbUq73Y*unD}_rQEd7;($yhw zbjd_~q{oDE(ex1|zEhd`9gni=GfWzy9({gEmvTnJLralvh`JO`fG;cP7B@Q9Rjv#l zQs}lkP6m_}??LOtL0gl|cdhkVos?M`K!i3gaAdGG@E;dZpUo(81sv%qVcj@8z_CNp9fw`U`1-Kvhqt==;u822|+sV#t2rMq442d zmxHGxin~!|CR`nbeTk-vgYC(#f?=FRhe&n^#+?5XaJ+<>MYVw%rgkS1EDW7s+k7pz zfSmpyQT{;rOhO4Jt=!@StydW^x)?rlRW?f(QYY9qq;*14(H-0*k)Zx6=bnCVQ|kt~ zKC>_`NX6R{W`Y=8V-v*{V}E!WLThbLYJ~-8rE-!@m14TPpe40rQIlaWH~X#&l<5Rp zFik69CAOvJe|_9u^vqpW*$FqZ+@4{`yA2qrMIM|}qG_a3vJC7DMW^|YKzx5$K*jAPpLT3G_&yndqOU1DI zB@`2deBnPZ&p&?bGY89tI7RpJ3n=d_4mp$?VdR6Y{)2ppOo^#IA8!@}v2)-~slChL z!nu>SaNkGIq`HQwCNnEUL1%25j|&0h%laZjW*nEXDh>CV>I`&;zwRh+lU_kRRNTKf zNZzOTNg1=ce?d}aI8!-gSo!mp%`&WERlydq1JCAvOE{KmhAF&w=o`TD=U&2#L}6*l z#apFEXT`PCkx)o*FHgxv3#SD%qVW=z-D1l6%ge)YT7`E<_r`RAlcEKj%TxK5sNHK+Wd6Yj12t%pM z!4>Tssja}64r%KEs!;*rG&VjZ#8e{H^>fm7bUav*w_?JL8)BuC)n3n%cu_s-LhNla zGHul4eL>v^IerKx{|2YZ^}%C$Ykgyf$Aqp3))W>Tmrt|SQYdZ3pFd+u=(odVgNY`k zyQQWe9sab*+q3`Pb71|iQNQ8#Y%=DY_((M!UTVmEmEHLxTNwNyxqJxI{Z@Ahcv8Gq z!&Blt_wTHt^lvD6j1psa^acNk--La;^Wi@F&{80W+rth+a5}2$#_sKcWbhIn-~TL& zCC((L6{jUSVizTBMh`co_P=h5#+pJ%4~jhJ-ogb{EG3^@U}dKOT*qS9^l>9@5)194 z<&QBR@4JeC;9+{*PgkqTx!c*R236fD!}lNf@tA z-%e1OG0S>L%`iCag%fQCsz=Lzsy}~w4c5JsXLwPT*EU`FP&=i_WWF53>nX+UdF1*b z5`qjmB;ME)p88u1_>b%LVX5wGBa{~R)i%rsakD`-!J2ee@rX@8v^bBC8voj-@RkNf z)-B+5?F7T-H(bgOQq|wy3o={^+N|tb`aBn>^9t;Sj}FKoC4pp8;MZej<3*LY; zP#G+x`i~1dJm;OX6B-_r;J40C8iV2SV`7(&-!6@=MdpTTT|M_!IuXYWl8LeT*7K~L zFgo!T6&ifMFUlZGpy|kNdNOM;;+nkg$TRaw>@+Ty^DP~o+UxN2=XX=nOYIuH(3k6S zr@O^ecB*uo?6}=cSjtdUv=*8WF#By(@qJaWu27tjLDfed!|3S77doqLbiRHd+wSeN z&Cu~g4!J&&uRG6iSCr_qYm|t4!lCIe+d3mseB{fd31iyIHC$?ijRLo7Z#&x#=K@aI zK4CT7@#ciy$NI@gP2tI*gr`@&EX0<$PEI=Z=Bb)_IFwC){zkTHQ*{HQI(Mlk>Bf0i zlL@h+SML@aJiOO(jmJf_$~JZ#`nHlZvdXBzPkslgc+tM?JAS*x{rMW);n+P(W)0Bh z;>$fO5>=)E%E65X^P1LD$4eN70V8b@ea`)_bhoNv83WlxgoNbe*1RFQ};wJ7Lk_ z;NIz47PXp1u1{IU=XzxN>gDjFg0>`wWZ}}$tz2FWw>h1uya!-^QsWXpIxD4x^-wn_BI;) z=anqRS0+3wghj2ID<9LJFoF)K&6LF;<yc%io6^iHSpGEUl^@3OEN z;L|h8oT!bxeSP(7&8W$_Xcd&(<*f3>vP=f`FZYJKk{ruBp+-s+qxP~gw~mo+dMiJX zPwmuD6W(y4SfrW7b?J0hzVj2snG|>T$zZ?EE`r6TDr$3MRU&F-(35OtuxC%Bq8Ry# z|6(xQ-9F^*uJ=TVQra;o$oky6wkEYnwwMc3?NqX_FTFo?ZTlYe8Mad?y4o)oC z%aT8=6EM>#N5AQ1%2}<=Vb{*>(@MR^WO=fAFv(l{w@~dbv7`Pf(T< zC$CO7q@a`zpu$)6G{)CHvMu62Tx}0#lYhP7YDC=rCIU8u$m@d8;C*S&i&I}NLyOJ) z7*|VOh+%0i>|uW?=Q&(2oomz20cH>$6gdC4zdL^JKao?|isw{{PCx6{ul50}mDjT_ z+x(eJSN$C{hi5U1eui?=FB>k-*jce?4_(!Y%K8mO?#k)8)?ik2CMXL{c`PkWjW31U z%Ae*&p+8Lp$IIieq%3fDY9H>gsSioK@?$3(Cn}l&nP3+Xo-JzgcUdeyR-Gb2 zM7*vY`T=nF`4n&H4t7>|N4V8P=cBvB;OM+rU~z@f`{C{vf+pU;^Xmdg@4%k%n|n4W zp_kjORnjzx%Nuj{FNw&nPbU{i4F?G5SC}tP6HB7f*Ed!*waRY#K*9VosKOam$K&M# zIw!2k6qSW2@vXci_k%B|*pb{LSGIW{2bx7dY#j&u zU&*bv{|hw^-pLFpHR^<0#0i=O=`@LDcro<$dNN#2(Kb zdpv0U{}A0Y@e2wiI;eD(+6p5ET7v4oNwA!K-#`aTzK^>VU$mPmO|H#sW;r z7i$HHSC`kpOlhKo)RW^pSDN$U#etgeX{l+J0bc^PqYlEC!uDarWyP?f)DWV-Z1AUV zgF1sbvxJnnam_}9be2`Gj9JUj&O`iPRk<~@)hAptPuzIZerMPrSLO_PU)HgP{ND`k zT{sowNgG)%6tB@RVy1bq4-&oc(A0D?Ow01yz4AmXR4`8;{syfxb8mjw%D~PQee%Ye zhcVD@`SfAV9EmGf@}*F<3*ehx=K5-w4V`Ipg24&$WC!qFE~fEvKa#kk+~2v2vJlW> zd$JjyUAb9MOP+S@q*j)+9~+^ph?rO&Uey3c{C;U@VXS#b#x1F2zL`5i_Ug&T1yKcq z2B6Wm(73c{L6Xnoxj~)_(k;G^%$BMt+6GWH%^SBIC-5u|gam4U=^|Ds#)Y?bW1&qC z>-24J74W-U1EZ*=%O&d# zkfu4knrAm_9ot!Emq0G|dDG13PoLkvZ9hgxFh5k9$ps^F1~P`L>^zibPN0who`klz z^Y8HwPO~jRiOd`(PvTxC+OpqXmgPX00Q6(#% zIWwb`|K}ZEoQf--F$3$ZnC;VU)rBn1Z+wV$4J8g$$5e@X2q{XuswvsM3s&vvJr|z3 ze4z1M?E*>5WTaW@LaEPXVpcLUD$m?GE5_5~^mv<a_ zYU9Z(aA5vd5biwyW5KEBKMeG(qYxXj865g>Ymd#AJ}9DeLWS)P;`pN&E!G5jjayJB zw3FuuJivc+u>q0~o7QL4s%kjRL87DPXJC$wrig-w$~;`hAwp{t%*#q#HMd2*Z$e7@ zH~ZWjpkqZ*FT^ADs)HNABnEHpljFT*Wtekt=H#uGkbj0sl~^ny2r5ZwRx37q2#v}e z>FD!-RwNxCWM3I{Bp3W=p`FMJVXL0*qNp6~xN?OI2HH{9GOuHW5e7C!$YLabpWsSu zDriz~4$Cm!G10=+F&vlT>^eX4Mb*8>g8CV0x_6q(54`S1o#t+Au9R%(KtD(fGzS^g z?Eve$(pE-@Wo6L0I6>X%-`Z(&}m*E=%Yrq?iA`UD$0 zsykiU#lb5T8eb;qWmQ&^BYda1c6|!k5oD_AF^{fHHV>W{Z~qqTSn6NWAeOU5e_3j-w#A|8ysE*L6;SncJ*KOfc)+mLWsa-gu^vvM^|+(IbB#EP z?1%<=VGsT^uXH*j}PZ(aQUdnGr@ z$+%>b_VpX%bbD8uc94YgtWn;55NEYQMYo?A?-EYqg7li6P9loppo=Ll3cU$p%48a& zg$=OMp8!j}#Q@xT_x=l<#gb~?v(JuQ*2ZHSodA7VghNLY2uMUJx(jB!)Xa3d!R4t( zvWFzz!rzFr`~*c<9gzq~>r`hB2B(o#PT5Ay2C9NS%5YM?wFVgoFl8sEs6D{7-yHZ? z1P}&^%2W22CdjQIeU?2zDVwL2MbH9MDTXsOfEzOFF5EuB$BGRJ>x!DlP+~w~Za9E7 zqi$n6(JBt!TR^rYYW!HnMq(8wwvSv4k(n!f)s#_j?69myH_ez;fTN$~7#ig{G&QT3 zLawfw3ms{`9qJpeoEEXrjN*!OSFDgNo2z$CJt_)Q^%Sbf0@GzRyt?{24NB2sU*0>Z zvT^U1(P2X=EqbyP{}#rf@V-09S2%H>wE1ba@Dgd07-KEZ-3;%rw*s&~0|tn+B{|D9 zUhqbYkY5vi@PbD(dnQHTs;_g(YgM`lq+>66AEH<+T^ZvhNm3R}V_L7dUOs=mwFeY6mKYl}bW*w#>KSf6mH9>=0SjD6kRu>H~ zTxYOcjl6fWWM5t@G#lqaV@H_ZttKkQNVGn%E$q()*RofnJs%^QMj;Ch?TVLIo4!5l zs8nEvPOG35SH12E=9Zm!@3Y0$r3$aftO4vTxmFNGcT&Bw&H%tV-S(a;8d7fn?E_^& z6rs?CG&P7DpL7D`IDNYP8`(79Nq?L1Nn%kVhIi31Zt5Ooue0a3FOq4t8&&F6!EAbS zQ!jX@rUaFqpwzc`F0@{8{q`xO!o+cGKED;?sPnp)k<40QGqG~uH=E}}x|ig?d6m$i z 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