This is an automated email from the ASF dual-hosted git repository.
ilgrosso pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/syncope.git
The following commit(s) were added to refs/heads/master by this push:
new ca24d5b347 [SYNCOPE-1968] Initial support for CAS tenants (#1394)
ca24d5b347 is described below
commit ca24d5b347b77b6e14c5fab51dce51d6a45d1157
Author: Francesco Chicchiriccò <[email protected]>
AuthorDate: Fri May 22 08:48:26 2026 +0200
[SYNCOPE-1968] Initial support for CAS tenants (#1394)
---
.github/workflows/fit_WA_Multitenancy.yml | 45 +++++++
.../src/test/resources/domains/TwoContent.xml | 2 -
.../src/test/resources/domains/TwoContent.xml | 2 -
fit/wa-reference/pom.xml | 72 +---------
.../src/main/resources/wa-embedded.properties | 2 +-
.../src/main/resources/wa-multitenancy.properties | 17 +++
.../org/apache/syncope/fit/AbstractITCase.java | 5 +-
.../org/apache/syncope/fit/MultitenancyITCase.java | 148 +++++++++++++++++++++
.../asciidoc/reference-guide/concepts/domains.adoc | 4 +
.../wa/bootstrap/WAPropertySourceLocator.java | 40 +++---
.../apache/syncope/wa/bootstrap/WARestClient.java | 61 +++++----
wa/starter/pom.xml | 14 ++
.../syncope/wa/starter/config/WAContext.java | 22 +++
.../wa/starter/multitenancy/WATenantsManager.java | 126 ++++++++++++++++++
wa/starter/src/main/resources/wa.properties | 2 +-
.../src/test/resources/debug/wa-debug.properties | 6 +-
16 files changed, 438 insertions(+), 130 deletions(-)
diff --git a/.github/workflows/fit_WA_Multitenancy.yml
b/.github/workflows/fit_WA_Multitenancy.yml
new file mode 100644
index 0000000000..b9ca7c4ab6
--- /dev/null
+++ b/.github/workflows/fit_WA_Multitenancy.yml
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License
+name: "FIT WA Multitenancy"
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [master]
+
+jobs:
+ fit_WA_Multitenancy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ - name: Setup Java JDK
+ uses: actions/setup-java@v5
+ with:
+ distribution: 'temurin'
+ java-version: 25
+ - name: Setup Maven
+ uses: stCarolas/[email protected]
+ with:
+ maven-version: 3.9.6
+ - name: Build
+ run: mvn -U -T 1C -P 'skipTests,all'
+ - name: 'WA / Multitenancy'
+ run: mvn -f fit/wa-reference/pom.xml verify -Dinvoker.streamLogs=true
-Dmodernizer.skip=true -Drat.skip=true -Dcheckstyle.skip=true
-Djacoco.skip=true -Dspring.profiles.active=embedded,https,all,multitenancy
-Dit.test=MultitenancyITCase
diff --git a/core/persistence-jpa/src/test/resources/domains/TwoContent.xml
b/core/persistence-jpa/src/test/resources/domains/TwoContent.xml
index 1a9a25dc7d..518e8c882b 100644
--- a/core/persistence-jpa/src/test/resources/domains/TwoContent.xml
+++ b/core/persistence-jpa/src/test/resources/domains/TwoContent.xml
@@ -110,6 +110,4 @@ we are happy to inform you that the password request was
successfully executed f
version="${connid.ldap.version}"
jsonConf='[{"schema":{"name":"synchronizePasswords","displayName":"Enable
Password Synchronization","helpMessage":"If true, the connector will
synchronize passwords. The Password Capture Plugin needs to be installed for
password synchronization to
work.","type":"boolean","required":false,"order":0,"confidential":false,"defaultValues":null},"overridable":false,"values":["false"]},{"schema":{"name":"maintainLdapGroupMembership","displayName":"Maintain
LDAP Group Membership" [...]
capabilities='["CREATE","UPDATE","DELETE","SEARCH"]'/>
-
- <SyncopeRole id="GROUP_OWNER"
entitlements='["USER_SEARCH","USER_READ","USER_CREATE","USER_UPDATE","USER_DELETE","ANYTYPECLASS_READ","ANYTYPE_LIST","ANYTYPECLASS_LIST","RELATIONSHIPTYPE_LIST","ANYTYPE_READ","REALM_SEARCH","GROUP_SEARCH","GROUP_READ","GROUP_UPDATE","GROUP_DELETE"]'/>
</dataset>
diff --git a/core/persistence-neo4j/src/test/resources/domains/TwoContent.xml
b/core/persistence-neo4j/src/test/resources/domains/TwoContent.xml
index 043883e887..b8ba35dc18 100644
--- a/core/persistence-neo4j/src/test/resources/domains/TwoContent.xml
+++ b/core/persistence-neo4j/src/test/resources/domains/TwoContent.xml
@@ -118,6 +118,4 @@ we are happy to inform you that the password request was
successfully executed f
jsonConf='[{"schema":{"name":"synchronizePasswords","displayName":"Enable
Password Synchronization","helpMessage":"If true, the connector will
synchronize passwords. The Password Capture Plugin needs to be installed for
password synchronization to
work.","type":"boolean","required":false,"order":0,"confidential":false,"defaultValues":null},"overridable":false,"values":["false"]},{"schema":{"name":"maintainLdapGroupMembership","displayName":"Maintain
LDAP Group Membership" [...]
capabilities='["CREATE","UPDATE","DELETE","SEARCH"]'/>
<ConnInstance_Realm left="b7ea96c3-c633-488b-98a0-b52ac35850f7"
right="ea696a4f-e77a-4ef1-be67-8f8093bc8686"/>
-
- <SyncopeRole id="GROUP_OWNER"
entitlements='["USER_SEARCH","USER_READ","USER_CREATE","USER_UPDATE","USER_DELETE","ANYTYPECLASS_READ","ANYTYPE_LIST","ANYTYPECLASS_LIST","RELATIONSHIPTYPE_LIST","ANYTYPE_READ","REALM_SEARCH","GROUP_SEARCH","GROUP_READ","GROUP_UPDATE","GROUP_DELETE"]'/>
</dataset>
diff --git a/fit/wa-reference/pom.xml b/fit/wa-reference/pom.xml
index e279666471..f5b556207b 100644
--- a/fit/wa-reference/pom.xml
+++ b/fit/wa-reference/pom.xml
@@ -34,6 +34,8 @@ under the License.
<packaging>war</packaging>
<properties>
+ <spring.profiles.active>embedded,https,all</spring.profiles.active>
+
<ianal.phase>none</ianal.phase>
<rootpom.basedir>${basedir}/../..</rootpom.basedir>
@@ -241,7 +243,7 @@ under the License.
<configuration>
<properties>
<cargo.jvmargs>
- -Dspring.profiles.active=embedded,https,all
+ -Dspring.profiles.active=${spring.profiles.active}
-Dopenfga.api-url=http://${docker.container.openfga.ip}:8080
-Xmx1024m -Xms512m</cargo.jvmargs>
@@ -414,7 +416,7 @@ under the License.
<configuration>
<properties>
<cargo.jvmargs>
- -Dspring.profiles.active=embedded,https,all
+ -Dspring.profiles.active=${spring.profiles.active}
-Dopenfga.api-url=http://${docker.container.openfga.ip}:8080
-Dwicket.core.settings.general.configuration-type=development
-Xdebug
-Xrunjdwp:transport=dt_socket,address=8000,server=y,suspend=n
@@ -427,72 +429,6 @@ under the License.
</build>
</profile>
- <profile>
- <id>hotswap</id>
-
- <build>
- <defaultGoal>clean verify cargo:run</defaultGoal>
-
- <plugins>
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-antrun-plugin</artifactId>
- <inherited>true</inherited>
- <executions>
- <execution>
- <id>enableHotSwapForCoreAndConsoleAndEnduser</id>
- <phase>package</phase>
- <configuration>
- <target>
- <copy
file="${basedir}/../core-reference/target/test-classes/hotswap-agent.properties"
-
tofile="${basedir}/../core-reference/target/syncope-fit-core-reference-${project.version}/WEB-INF/classes/hotswap-agent.properties"
- overwrite="true"/>
- <copy
file="${basedir}/../console-reference/target/test-classes/hotswap-agent.properties"
-
tofile="${basedir}/../console-reference/target/syncope-fit-console-reference-${project.version}/WEB-INF/classes/hotswap-agent.properties"
- overwrite="true"/>
- <copy
file="${basedir}/../enduser-reference/target/test-classes/hotswap-agent.properties"
-
tofile="${basedir}/../enduser-reference/target/syncope-fit-enduser-reference-${project.version}/WEB-INF/classes/hotswap-agent.properties"
- overwrite="true"/>
- </target>
- </configuration>
- <goals>
- <goal>run</goal>
- </goals>
- </execution>
- </executions>
- </plugin>
-
- <plugin>
- <groupId>org.codehaus.cargo</groupId>
- <artifactId>cargo-maven3-plugin</artifactId>
- <inherited>true</inherited>
- <configuration>
- <configuration>
- <properties>
- <cargo.jvmargs>
- -Dspring.profiles.active=embedded,all
-
-Dwicket.core.settings.general.configuration-type=development
-
-javaagent:${java.home}/lib/hotswap/hotswap-agent.jar=autoHotswap=true,disablePlugin=Spring,disablePlugin=Hibernate,disablePlugin=CxfJAXRS
-
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000
- -XX:+UseConcMarkSweepGC -Xmx1024m -Xms512m</cargo.jvmargs>
- </properties>
- </configuration>
- </configuration>
- </plugin>
- </plugins>
-
- <resources>
- <resource>
- <directory>src/test/resources</directory>
- <filtering>true</filtering>
- <includes>
- <include>hotswap-agent.properties</include>
- </includes>
- </resource>
- </resources>
- </build>
- </profile>
-
<profile>
<id>apache-release</id>
diff --git a/fit/wa-reference/src/main/resources/wa-embedded.properties
b/fit/wa-reference/src/main/resources/wa-embedded.properties
index 40d6658489..c243c0ca03 100644
--- a/fit/wa-reference/src/main/resources/wa-embedded.properties
+++ b/fit/wa-reference/src/main/resources/wa-embedded.properties
@@ -44,4 +44,4 @@
cas.tgc.crypto.encryption.key=mW6lMvsSo48eZ1Ntt74a-O9jjQQQ_OLUE24RVN2_A_sPX43mpB
cas.webflow.crypto.signing.key=Md6kkPlXx5L18TD0mFELpQXWnDbMffj-uPutPckMnAPPuJQEbfcLLYBnOynYIEDgnEpd7sxUwGYd8_sVYFMcjw
cas.webflow.crypto.encryption.key=FhLgLpaPL8GVNuqqo7gtiw
-management.endpoints.web.exposure.include=info,health,env,beans,loggers,ssoSessions,registeredServices,refresh,authenticationHandlers,authenticationPolicies,resolveAttributes,attributeConsent
+management.endpoints.web.exposure.include=info,health,env,beans,loggers,ssoSessions,registeredServices,refresh,authenticationHandlers,authenticationPolicies,resolveAttributes,attributeConsent,multitenancy
diff --git a/fit/wa-reference/src/main/resources/wa-multitenancy.properties
b/fit/wa-reference/src/main/resources/wa-multitenancy.properties
new file mode 100644
index 0000000000..a95a51ea7a
--- /dev/null
+++ b/fit/wa-reference/src/main/resources/wa-multitenancy.properties
@@ -0,0 +1,17 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+cas.multitenancy.core.enabled=true
diff --git
a/fit/wa-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
b/fit/wa-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
index 994a5e2d3c..d49586bf83 100644
--- a/fit/wa-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
+++ b/fit/wa-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
@@ -248,7 +248,8 @@ public abstract class AbstractITCase {
final String password,
final String body,
final CloseableHttpClient httpclient,
- final HttpClientContext context)
+ final HttpClientContext context,
+ final String... tenant)
throws IOException {
List<NameValuePair> form = new ArrayList<>();
@@ -258,7 +259,7 @@ public abstract class AbstractITCase {
form.add(new BasicNameValuePair("password", password));
form.add(new BasicNameValuePair("geolocation", ""));
- HttpPost post = new HttpPost(WA_ADDRESS + "/login");
+ HttpPost post = new HttpPost(WA_ADDRESS + (tenant.length == 0 ? "" :
"/tenants/" + tenant[0]) + "/login");
post.addHeader(HttpHeaders.ACCEPT, MediaType.TEXT_HTML);
post.addHeader(HttpHeaders.ACCEPT_LANGUAGE, EN_LANGUAGE);
post.setEntity(new UrlEncodedFormEntity(form, Consts.UTF_8));
diff --git
a/fit/wa-reference/src/test/java/org/apache/syncope/fit/MultitenancyITCase.java
b/fit/wa-reference/src/test/java/org/apache/syncope/fit/MultitenancyITCase.java
new file mode 100644
index 0000000000..28bb560b18
--- /dev/null
+++
b/fit/wa-reference/src/test/java/org/apache/syncope/fit/MultitenancyITCase.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.syncope.fit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import jakarta.ws.rs.core.GenericType;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import java.io.IOException;
+import java.util.Optional;
+import org.apache.http.HttpStatus;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.apache.syncope.client.lib.SyncopeClientFactoryBean;
+import org.apache.syncope.common.lib.SyncopeClientException;
+import org.apache.syncope.common.lib.SyncopeConstants;
+import org.apache.syncope.common.lib.auth.SyncopeAuthModuleConf;
+import org.apache.syncope.common.lib.request.UserCR;
+import org.apache.syncope.common.lib.to.AuthModuleTO;
+import org.apache.syncope.common.lib.to.ProvisioningResult;
+import org.apache.syncope.common.lib.to.UserTO;
+import org.apache.syncope.common.rest.api.beans.RealmQuery;
+import org.apache.syncope.common.rest.api.service.AuthModuleService;
+import org.apache.syncope.common.rest.api.service.RealmService;
+import org.apache.syncope.common.rest.api.service.UserService;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+public class MultitenancyITCase extends AbstractITCase {
+
+ protected static final String MT_USERNAME = "multitenancy";
+
+ protected static final String MT_PASSWORD = "password";
+
+ @BeforeAll
+ public static void multitenancySetup() {
+
assumeTrue(Optional.ofNullable(System.getProperties().getProperty("spring.profiles.active")).
+ map(profiles ->
profiles.contains("multitenancy")).orElse(false));
+
+ CLIENT_FACTORY = new
SyncopeClientFactoryBean().setAddress(CORE_ADDRESS).setDomain("Two");
+
+ ADMIN_CLIENT = CLIENT_FACTORY.create(ADMIN_UNAME, "password2");
+
+ // 1. create Syncope auth module
+ AuthModuleService authModuleService =
ADMIN_CLIENT.getService(AuthModuleService.class);
+ AuthModuleTO syncopeAuthModule = null;
+ try {
+ syncopeAuthModule = authModuleService.read("syncopeTwo");
+ } catch (SyncopeClientException e) {
+ if (e.getType().getResponseStatus() == Response.Status.NOT_FOUND) {
+ SyncopeAuthModuleConf conf = new SyncopeAuthModuleConf();
+ conf.setDomain("Two");
+
+ syncopeAuthModule = new AuthModuleTO();
+ syncopeAuthModule.setKey("syncopeTwo");
+ syncopeAuthModule.setConf(conf);
+
+ Response response =
authModuleService.create(syncopeAuthModule);
+ assertEquals(Response.Status.CREATED.getStatusCode(),
response.getStatusInfo().getStatusCode());
+ }
+ }
+ assertNotNull(syncopeAuthModule);
+
+ // 2. create user
+ assertNull(ADMIN_CLIENT.getService(RealmService.class).
+ search(new
RealmQuery.Builder().build()).getResult().getFirst().getPasswordPolicy());
+
+ UserService userService = ADMIN_CLIENT.getService(UserService.class);
+ UserTO user = null;
+ try {
+ user = userService.read(MT_USERNAME);
+ } catch (SyncopeClientException e) {
+ if (e.getType().getResponseStatus() == Response.Status.NOT_FOUND) {
+ UserCR userCR = new UserCR();
+ userCR.setRealm(SyncopeConstants.ROOT_REALM);
+ userCR.setUsername(MT_USERNAME);
+ userCR.setPassword(MT_PASSWORD);
+
+ Response response =
ADMIN_CLIENT.getService(UserService.class).create(userCR);
+ assertEquals(Response.Status.CREATED.getStatusCode(),
response.getStatus());
+
+ ProvisioningResult<UserTO> result = response.readEntity(new
GenericType<>() {
+ });
+ user = result.getEntity();
+ }
+ }
+ assertNotNull(user);
+ }
+
+ @Test
+ public void login() throws IOException {
+ try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
+ HttpClientContext context = HttpClientContext.create();
+ context.setCookieStore(new BasicCookieStore());
+
+ String loginPageBody;
+ try (CloseableHttpResponse response =
+ httpclient.execute(new HttpGet(WA_ADDRESS +
"/tenants/Two/login"), context)) {
+
+ assertEquals(HttpStatus.SC_OK,
response.getStatusLine().getStatusCode());
+ loginPageBody = EntityUtils.toString(response.getEntity());
+ }
+ assertNotNull(loginPageBody);
+
+ String location;
+ try (CloseableHttpResponse response =
+ authenticateToWA(MT_USERNAME, MT_PASSWORD, loginPageBody,
httpclient, context, "Two")) {
+
+ assertEquals(HttpStatus.SC_MOVED_TEMPORARILY,
response.getStatusLine().getStatusCode());
+ location =
response.getFirstHeader(HttpHeaders.LOCATION).getValue();
+ }
+ assertTrue(location.endsWith("/account"));
+
+ try (CloseableHttpResponse response =
+ httpclient.execute(new HttpGet(WA_ADDRESS +
"/tenants/Two/account"), context)) {
+
+ assertEquals(HttpStatus.SC_OK,
response.getStatusLine().getStatusCode());
+
assertTrue(EntityUtils.toString(response.getEntity()).contains(MT_USERNAME));
+ }
+ }
+ }
+}
diff --git a/src/main/asciidoc/reference-guide/concepts/domains.adoc
b/src/main/asciidoc/reference-guide/concepts/domains.adoc
index 065b2b7387..3eea561038 100644
--- a/src/main/asciidoc/reference-guide/concepts/domains.adoc
+++ b/src/main/asciidoc/reference-guide/concepts/domains.adoc
@@ -26,6 +26,10 @@ External Resources, Policies, Tasks, etc. from different
domains (e.g. tenants)
By default, a single `Master` domain is defined, which also bears the
configuration for additional domains.
+Every domain besides `Master` is mapped one-to-one with a
+https://apereo.github.io/cas/7.3.x/multitenancy/Multitenancy-Overview.html[CAS
tenant^] having the same identifier; this
+allows for <<web-access>> configuration to relate only to the given domain's
database instance.
+
[.text-center]
image::domains.png[title="Domains",alt="Domains"]
diff --git
a/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/WAPropertySourceLocator.java
b/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/WAPropertySourceLocator.java
index 40957c160f..ed59649778 100644
---
a/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/WAPropertySourceLocator.java
+++
b/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/WAPropertySourceLocator.java
@@ -53,6 +53,26 @@ public class WAPropertySourceLocator implements
PropertySourceLocator {
protected static final Logger LOG =
LoggerFactory.getLogger(WAPropertySourceLocator.class);
+ public static Map<String, Object> index(final Map<String, Object> map,
final Map<String, Integer> prefixes) {
+ Map<String, Object> indexed = map;
+
+ if (!map.isEmpty()) {
+ String prefix = map.keySet().iterator().next();
+ if (prefix.contains("[]")) {
+ prefix = StringUtils.substringBefore(prefix, "[]");
+ Integer index = prefixes.getOrDefault(prefix, 0);
+
+ indexed = map.entrySet().stream().
+ map(e -> Pair.of(e.getKey().replace("[]", "[" + index
+ "]"), e.getValue())).
+ collect(Collectors.toMap(Pair::getKey,
Pair::getValue));
+
+ prefixes.put(prefix, index + 1);
+ }
+ }
+
+ return indexed;
+ }
+
protected final WARestClient waRestClient;
protected final AuthModulePropertySourceMapper
authModulePropertySourceMapper;
@@ -77,26 +97,6 @@ public class WAPropertySourceLocator implements
PropertySourceLocator {
this.configurationCipher = configurationCipher;
}
- protected Map<String, Object> index(final Map<String, Object> map, final
Map<String, Integer> prefixes) {
- Map<String, Object> indexed = map;
-
- if (!map.isEmpty()) {
- String prefix = map.keySet().iterator().next();
- if (prefix.contains("[]")) {
- prefix = StringUtils.substringBefore(prefix, "[]");
- Integer index = prefixes.getOrDefault(prefix, 0);
-
- indexed = map.entrySet().stream().
- map(e -> Pair.of(e.getKey().replace("[]", "[" + index
+ "]"), e.getValue())).
- collect(Collectors.toMap(Pair::getKey,
Pair::getValue));
-
- prefixes.put(prefix, index + 1);
- }
- }
-
- return indexed;
- }
-
@Override
public PropertySource<?> locate(final Environment environment) {
SyncopeClient syncopeClient = waRestClient.getSyncopeClient();
diff --git
a/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/WARestClient.java
b/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/WARestClient.java
index c4b1438bdb..12920a4aef 100644
---
a/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/WARestClient.java
+++
b/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/WARestClient.java
@@ -29,6 +29,7 @@ import org.apache.syncope.client.lib.SyncopeClientFactoryBean;
import org.apache.syncope.common.keymaster.client.api.KeymasterException;
import org.apache.syncope.common.keymaster.client.api.ServiceOps;
import org.apache.syncope.common.keymaster.client.api.model.NetworkService;
+import org.apache.syncope.common.lib.SyncopeConstants;
import org.apereo.cas.util.spring.ApplicationContextProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -89,48 +90,46 @@ public class WARestClient {
return Optional.empty();
}
+ public boolean isReady() {
+ try {
+ return getCore().isPresent();
+ } catch (Exception e) {
+ LOG.trace("While checking Core's availability: {}",
e.getMessage());
+ }
+ return false;
+ }
+
+ public Optional<SyncopeClient> getSyncopeClient(final String domain) {
+ return getCore().flatMap(core -> {
+ try {
+ return Optional.of(new SyncopeClientFactoryBean().
+ setAddress(core.getAddress()).
+ setUseCompression(useGZIPCompression).
+ setDomain(domain).
+ create(new BasicAuthenticationHandler(anonymousUser,
anonymousKey)));
+ } catch (Exception e) {
+ LOG.error("Could not init SyncopeClient", e);
+ return Optional.empty();
+ }
+ });
+ }
+
public SyncopeClient getSyncopeClient() {
synchronized (this) {
if (client == null) {
- getCore().ifPresent(core -> {
- try {
- client = new SyncopeClientFactoryBean().
- setAddress(core.getAddress()).
- setUseCompression(useGZIPCompression).
- create(new
BasicAuthenticationHandler(anonymousUser, anonymousKey));
- } catch (Exception e) {
- LOG.error("Could not init SyncopeClient", e);
- }
- });
+ client =
getSyncopeClient(SyncopeConstants.MASTER_DOMAIN).orElse(null);
}
-
- return client;
}
+
+ return client;
}
@SuppressWarnings("unchecked")
public <T> T getService(final Class<T> serviceClass) {
if (!isReady()) {
- throw new IllegalStateException("Syncope core is not yet ready");
- }
-
- T service;
- if (services.containsKey(serviceClass)) {
- service = (T) services.get(serviceClass);
- } else {
- service = getSyncopeClient().getService(serviceClass);
- services.put(serviceClass, service);
+ throw new IllegalStateException("Syncope Core is not yet ready");
}
- return service;
- }
-
- public boolean isReady() {
- try {
- return getCore().isPresent();
- } catch (Exception e) {
- LOG.trace("While checking Core's availability: {}",
e.getMessage());
- }
- return false;
+ return (T) services.computeIfAbsent(serviceClass, k ->
getSyncopeClient().getService(k));
}
}
diff --git a/wa/starter/pom.xml b/wa/starter/pom.xml
index 79c47989b4..fcd5a9f25d 100644
--- a/wa/starter/pom.xml
+++ b/wa/starter/pom.xml
@@ -186,6 +186,20 @@ under the License.
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-support-reports</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apereo.cas</groupId>
+ <artifactId>cas-server-support-jdbc-authentication</artifactId>
+ <exclusions>
+ <exclusion>
+ <groupId>org.apereo.cas</groupId>
+ <artifactId>cas-server-support-jdbc-drivers</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
+ <groupId>org.apereo.cas</groupId>
+ <artifactId>cas-server-support-rest-authentication</artifactId>
+ </dependency>
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-support-syncope-authentication</artifactId>
diff --git
a/wa/starter/src/main/java/org/apache/syncope/wa/starter/config/WAContext.java
b/wa/starter/src/main/java/org/apache/syncope/wa/starter/config/WAContext.java
index 4452c96ea8..8516f74915 100644
---
a/wa/starter/src/main/java/org/apache/syncope/wa/starter/config/WAContext.java
+++
b/wa/starter/src/main/java/org/apache/syncope/wa/starter/config/WAContext.java
@@ -32,6 +32,7 @@ import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import javax.sql.DataSource;
import org.apache.commons.lang3.StringUtils;
+import org.apache.syncope.common.keymaster.client.api.DomainOps;
import org.apache.syncope.common.keymaster.client.api.model.NetworkService;
import org.apache.syncope.common.keymaster.client.api.startstop.KeymasterStart;
import org.apache.syncope.common.keymaster.client.api.startstop.KeymasterStop;
@@ -39,6 +40,8 @@ import org.apache.syncope.common.lib.types.IdRepoEntitlement;
import org.apache.syncope.wa.bootstrap.WAProperties;
import org.apache.syncope.wa.bootstrap.WARestClient;
import org.apache.syncope.wa.bootstrap.mapping.AttrReleaseMapper;
+import org.apache.syncope.wa.bootstrap.mapping.AttrRepoPropertySourceMapper;
+import org.apache.syncope.wa.bootstrap.mapping.AuthModulePropertySourceMapper;
import org.apache.syncope.wa.starter.actuate.SyncopeCoreHealthIndicator;
import org.apache.syncope.wa.starter.actuate.SyncopeWAInfoContributor;
import org.apache.syncope.wa.starter.audit.WAAuditTrailManager;
@@ -62,6 +65,7 @@ import
org.apache.syncope.wa.starter.mapping.SAML2SPClientAppTOMapper;
import org.apache.syncope.wa.starter.mapping.TicketExpirationMapper;
import org.apache.syncope.wa.starter.mapping.TimeBasedAccessMapper;
import
org.apache.syncope.wa.starter.mfa.WAMultifactorAuthenticationTrustStorage;
+import org.apache.syncope.wa.starter.multitenancy.WATenantsManager;
import
org.apache.syncope.wa.starter.oidc.WAOidcJsonWebKeystoreGeneratorService;
import org.apache.syncope.wa.starter.pac4j.saml.WASAML2ClientCustomizer;
import
org.apache.syncope.wa.starter.saml.idp.metadata.WASamlIdPMetadataCacheRefresher;
@@ -82,6 +86,7 @@ import org.apereo.cas.configuration.support.JpaBeans;
import org.apereo.cas.consent.ConsentRepository;
import org.apereo.cas.gauth.CasGoogleAuthenticator;
import
org.apereo.cas.gauth.credential.LdapGoogleAuthenticatorTokenCredentialRepository;
+import org.apereo.cas.multitenancy.TenantsManager;
import org.apereo.cas.oidc.jwks.generator.OidcJsonWebKeystoreGeneratorService;
import
org.apereo.cas.otp.repository.credentials.OneTimeTokenCredentialRepository;
import org.apereo.cas.pm.LdapPasswordManagementService;
@@ -598,6 +603,23 @@ public class WAContext {
return new InMemoryUserDetailsManager(user);
}
+ @RefreshScope(proxyMode = ScopedProxyMode.DEFAULT)
+ @ConditionalOnProperty(
+ prefix = "cas.multitenancy.core", name = "enabled", havingValue =
"true", matchIfMissing = false)
+ @Bean(name = TenantsManager.BEAN_NAME)
+ public TenantsManager tenantsManager(
+ final DomainOps domainOps,
+ final WARestClient waRestClient,
+ final AuthModulePropertySourceMapper
authModulePropertySourceMapper,
+ final AttrRepoPropertySourceMapper attrRepoPropertySourceMapper) {
+
+ return new WATenantsManager(
+ domainOps,
+ waRestClient,
+ authModulePropertySourceMapper,
+ attrRepoPropertySourceMapper);
+ }
+
@ConditionalOnProperty(
prefix = "keymaster", name = "enableAutoRegistration", havingValue
= "true", matchIfMissing = true)
@Bean
diff --git
a/wa/starter/src/main/java/org/apache/syncope/wa/starter/multitenancy/WATenantsManager.java
b/wa/starter/src/main/java/org/apache/syncope/wa/starter/multitenancy/WATenantsManager.java
new file mode 100644
index 0000000000..ac68ea3b5c
--- /dev/null
+++
b/wa/starter/src/main/java/org/apache/syncope/wa/starter/multitenancy/WATenantsManager.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.syncope.wa.starter.multitenancy;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+import org.apache.syncope.client.lib.SyncopeClient;
+import org.apache.syncope.common.keymaster.client.api.DomainOps;
+import org.apache.syncope.common.rest.api.service.AttrRepoService;
+import org.apache.syncope.common.rest.api.service.AuthModuleService;
+import org.apache.syncope.common.rest.api.service.wa.WAConfigService;
+import org.apache.syncope.wa.bootstrap.WAPropertySourceLocator;
+import org.apache.syncope.wa.bootstrap.WARestClient;
+import org.apache.syncope.wa.bootstrap.mapping.AttrRepoPropertySourceMapper;
+import org.apache.syncope.wa.bootstrap.mapping.AuthModulePropertySourceMapper;
+import org.apereo.cas.multitenancy.DefaultTenantAuthenticationPolicy;
+import org.apereo.cas.multitenancy.DefaultTenantDelegatedAuthenticationPolicy;
+import org.apereo.cas.multitenancy.TenantDefinition;
+import org.apereo.cas.multitenancy.TenantsManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class WATenantsManager implements TenantsManager {
+
+ protected static final Logger LOG =
LoggerFactory.getLogger(WATenantsManager.class);
+
+ protected final DomainOps domainOps;
+
+ protected final WARestClient waRestClient;
+
+ protected final AuthModulePropertySourceMapper
authModulePropertySourceMapper;
+
+ protected final AttrRepoPropertySourceMapper attrRepoPropertySourceMapper;
+
+ public WATenantsManager(
+ final DomainOps domainOps,
+ final WARestClient waRestClient,
+ final AuthModulePropertySourceMapper
authModulePropertySourceMapper,
+ final AttrRepoPropertySourceMapper attrRepoPropertySourceMapper) {
+
+ this.domainOps = domainOps;
+ this.waRestClient = waRestClient;
+ this.authModulePropertySourceMapper = authModulePropertySourceMapper;
+ this.attrRepoPropertySourceMapper = attrRepoPropertySourceMapper;
+ }
+
+ protected TenantDefinition buildTenantDefinition(final SyncopeClient
syncopeClient) {
+ TenantDefinition tenantDefinition = new TenantDefinition();
+ tenantDefinition.setId(syncopeClient.getDomain());
+
+ Map<String, Object> properties = new TreeMap<>();
+ Map<String, Integer> prefixes = new HashMap<>();
+
+ DefaultTenantAuthenticationPolicy authPolicy = new
DefaultTenantAuthenticationPolicy();
+ tenantDefinition.setAuthenticationPolicy(authPolicy);
+
+ DefaultTenantDelegatedAuthenticationPolicy delegatedAuthPolicy =
+ new DefaultTenantDelegatedAuthenticationPolicy();
+ tenantDefinition.setDelegatedAuthenticationPolicy(delegatedAuthPolicy);
+
+ authPolicy.setAuthenticationHandlers(new ArrayList<>());
+ delegatedAuthPolicy.setAllowedProviders(new ArrayList<>());
+
syncopeClient.getService(AuthModuleService.class).list().forEach(authModuleTO
-> {
+ LOG.debug("Mapping auth module {} ", authModuleTO.getKey());
+
+ Map<String, Object> map = authModuleTO.getConf().map(authModuleTO,
authModulePropertySourceMapper);
+ properties.putAll(WAPropertySourceLocator.index(map, prefixes));
+
+ if (map.keySet().stream().anyMatch(k -> k.contains("pac4j"))) {
+
delegatedAuthPolicy.getAllowedProviders().add(authModuleTO.getKey());
+ } else {
+
authPolicy.getAuthenticationHandlers().add(authModuleTO.getKey());
+ }
+ });
+
+ authPolicy.setAttributeRepositories(new ArrayList<>());
+
syncopeClient.getService(AttrRepoService.class).list().forEach(attrRepoTO -> {
+ LOG.debug("Mapping attr repo {} ", attrRepoTO.getKey());
+
+ Map<String, Object> map = attrRepoTO.getConf().map(attrRepoTO,
attrRepoPropertySourceMapper);
+ properties.putAll(WAPropertySourceLocator.index(map, prefixes));
+
+ authPolicy.getAttributeRepositories().add(attrRepoTO.getKey());
+ });
+
+ syncopeClient.getService(WAConfigService.class).list().
+ forEach(attr -> properties.put(attr.getSchema(),
String.join(",", attr.getValues())));
+
+ tenantDefinition.setProperties(properties);
+ LOG.debug("Collected Tenant {} properties: {}",
tenantDefinition.getId(), tenantDefinition.getProperties());
+
+ return tenantDefinition;
+ }
+
+ @Override
+ public Optional<TenantDefinition> findTenant(final String tenantId) {
+ return
waRestClient.getSyncopeClient(tenantId).map(this::buildTenantDefinition);
+ }
+
+ @Override
+ public List<TenantDefinition> findTenants() {
+ List<TenantDefinition> tenants = new ArrayList<>();
+ domainOps.list().forEach(domain ->
findTenant(domain.getKey()).ifPresent(tenants::add));
+ return tenants;
+ }
+}
diff --git a/wa/starter/src/main/resources/wa.properties
b/wa/starter/src/main/resources/wa.properties
index 6adbeefd1e..d22b2fb564 100644
--- a/wa/starter/src/main/resources/wa.properties
+++ b/wa/starter/src/main/resources/wa.properties
@@ -37,7 +37,7 @@
spring.web.resources.static-locations=classpath:/thymeleaf/static,classpath:/syn
cas.monitor.endpoints.endpoint.defaults.access=AUTHENTICATED
management.endpoints.access.default=UNRESTRICTED
-management.endpoints.web.exposure.include=info,health,env,loggers,ssoSessions,registeredServices,refresh,authenticationHandlers,authenticationPolicies,resolveAttributes,attributeConsent
+management.endpoints.web.exposure.include=info,health,env,loggers,ssoSessions,registeredServices,refresh,authenticationHandlers,authenticationPolicies,resolveAttributes,attributeConsent,multitenancy
management.endpoint.health.show-details=ALWAYS
management.endpoint.env.show-values=WHEN_AUTHORIZED
spring.cloud.discovery.client.health-indicator.enabled=false
diff --git a/wa/starter/src/test/resources/debug/wa-debug.properties
b/wa/starter/src/test/resources/debug/wa-debug.properties
index 35bd946735..6f14c491ba 100644
--- a/wa/starter/src/test/resources/debug/wa-debug.properties
+++ b/wa/starter/src/test/resources/debug/wa-debug.properties
@@ -16,12 +16,12 @@
# under the License.
spring.main.allow-circular-references=true
-#keymaster.address=http://localhost:9080/syncope/rest/keymaster
-keymaster.address=https://localhost:9443/syncope/rest/keymaster
+keymaster.address=http://localhost:9080/syncope/rest/keymaster
+#keymaster.address=https://localhost:9443/syncope/rest/keymaster
keymaster.username=${anonymousUser}
keymaster.password=${anonymousKey}
-management.endpoints.web.exposure.include=info,health,env,beans,loggers,ssoSessions,registeredServices,refresh,authenticationHandlers,authenticationPolicies,resolveAttributes,attributeConsent
+management.endpoints.web.exposure.include=info,health,env,beans,loggers,ssoSessions,registeredServices,refresh,authenticationHandlers,authenticationPolicies,resolveAttributes,attributeConsent,multitenancy
cas.server.name=http://localhost:8080
cas.server.prefix=${cas.server.name}/syncope-wa