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


Reply via email to