This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 11edea0a3 UNOMI-977: Restrict router import/export configuration to 
system administrators (#853)
11edea0a3 is described below

commit 11edea0a3b6ba7c5d71d8f4a472fe05dac4e5429
Author: Serge Huber <[email protected]>
AuthorDate: Thu Sep 24 09:36:16 2026 +0200

    UNOMI-977: Restrict router import/export configuration to system 
administrators (#853)
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../src/main/feature/feature.xml                   |   2 +
 extensions/router/router-rest/pom.xml              |  10 ++
 .../rest/ExportConfigurationServiceEndPoint.java   |   3 +
 .../rest/ImportConfigurationServiceEndPoint.java   |   3 +
 .../rest/RouterConfigurationEndPointRoleTest.java  |  44 +++++++
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../apache/unomi/itests/CorePersistenceITs.java    |   1 +
 .../unomi/itests/RouterEndpointRoleSecurityIT.java | 136 +++++++++++++++++++++
 8 files changed, 200 insertions(+)

diff --git 
a/extensions/router/router-karaf-feature/src/main/feature/feature.xml 
b/extensions/router/router-karaf-feature/src/main/feature/feature.xml
index 647b80914..800508f90 100644
--- a/extensions/router/router-karaf-feature/src/main/feature/feature.xml
+++ b/extensions/router/router-karaf-feature/src/main/feature/feature.xml
@@ -20,6 +20,8 @@
         <details>Apache Karaf feature for the Apache Unomi Context Server 
extension</details>
         <feature>wrap</feature>
         <feature>unomi-services</feature>
+        <!-- REST endpoints use @RequiresRole from 
org.apache.unomi.rest.security -->
+        <feature>unomi-rest-api</feature>
         <bundle 
start="false">mvn:org.apache.servicemix.bundles/org.apache.servicemix.bundles.jsch/0.1.55_1</bundle>
         <bundle 
start="false">mvn:commons-net/commons-net/${commons-net.version}</bundle>
         <bundle 
start="false">wrap:mvn:org.apache.kafka/kafka-clients/${kafka.client.version}</bundle>
diff --git a/extensions/router/router-rest/pom.xml 
b/extensions/router/router-rest/pom.xml
index b7b9233db..5d7f84404 100644
--- a/extensions/router/router-rest/pom.xml
+++ b/extensions/router/router-rest/pom.xml
@@ -55,6 +55,11 @@
             <artifactId>unomi-router-api</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.unomi</groupId>
+            <artifactId>unomi-rest</artifactId>
+            <scope>provided</scope>
+        </dependency>
 
         <dependency>
             <groupId>org.osgi</groupId>
@@ -103,6 +108,11 @@
             <artifactId>slf4j-api</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git 
a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
 
b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
index 4371e2d13..a77325160 100644
--- 
a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
+++ 
b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
@@ -17,7 +17,9 @@
 package org.apache.unomi.router.rest;
 
 import org.apache.cxf.rs.security.cors.CrossOriginResourceSharing;
+import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.ProfileService;
+import org.apache.unomi.rest.security.RequiresRole;
 import org.apache.unomi.router.api.ExportConfiguration;
 import org.apache.unomi.router.api.services.ImportExportConfigurationService;
 import org.apache.unomi.router.api.services.ProfileExportService;
@@ -47,6 +49,7 @@ import java.util.List;
         allowCredentials = true
 )
 @Path("/exportConfiguration")
+@RequiresRole(UnomiRoles.ADMINISTRATOR)
 @Component(service=ExportConfigurationServiceEndPoint.class,property = 
"osgi.jaxrs.resource=true")
 public class ExportConfigurationServiceEndPoint extends 
AbstractConfigurationServiceEndpoint<ExportConfiguration> {
 
diff --git 
a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ImportConfigurationServiceEndPoint.java
 
b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ImportConfigurationServiceEndPoint.java
index e8d639df1..cbc7dcec3 100644
--- 
a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ImportConfigurationServiceEndPoint.java
+++ 
b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ImportConfigurationServiceEndPoint.java
@@ -19,7 +19,9 @@ package org.apache.unomi.router.rest;
 import org.apache.cxf.jaxrs.ext.multipart.Attachment;
 import org.apache.cxf.jaxrs.ext.multipart.Multipart;
 import org.apache.cxf.rs.security.cors.CrossOriginResourceSharing;
+import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.rest.security.RequiresRole;
 import org.apache.unomi.router.api.ImportConfiguration;
 import org.apache.unomi.router.api.RouterConstants;
 import org.apache.unomi.router.api.services.ImportExportConfigurationService;
@@ -54,6 +56,7 @@ import java.util.List;
         allowCredentials = true
 )
 @Path("/importConfiguration")
+@RequiresRole(UnomiRoles.ADMINISTRATOR)
 @Component(service=ImportConfigurationServiceEndPoint.class,property = 
"osgi.jaxrs.resource=true")
 public class ImportConfigurationServiceEndPoint extends 
AbstractConfigurationServiceEndpoint<ImportConfiguration> {
 
diff --git 
a/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/RouterConfigurationEndPointRoleTest.java
 
b/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/RouterConfigurationEndPointRoleTest.java
new file mode 100644
index 000000000..ab9cfb8b3
--- /dev/null
+++ 
b/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/RouterConfigurationEndPointRoleTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.unomi.router.rest;
+
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.rest.security.RequiresRole;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Regression: router import/export configuration must stay restricted to 
system administrators.
+ */
+class RouterConfigurationEndPointRoleTest {
+
+    @Test
+    void importEndpointRequiresSystemAdministratorRole() {
+        RequiresRole requiresRole = 
ImportConfigurationServiceEndPoint.class.getAnnotation(RequiresRole.class);
+        assertNotNull(requiresRole, "ImportConfigurationServiceEndPoint must 
declare @RequiresRole");
+        assertArrayEquals(new String[]{UnomiRoles.ADMINISTRATOR}, 
requiresRole.value());
+    }
+
+    @Test
+    void exportEndpointRequiresSystemAdministratorRole() {
+        RequiresRole requiresRole = 
ExportConfigurationServiceEndPoint.class.getAnnotation(RequiresRole.class);
+        assertNotNull(requiresRole, "ExportConfigurationServiceEndPoint must 
declare @RequiresRole");
+        assertArrayEquals(new String[]{UnomiRoles.ADMINISTRATOR}, 
requiresRole.value());
+    }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 6a6881caf..6ad33fab1 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -59,6 +59,7 @@ import org.junit.runners.Suite.SuiteClasses;
         ContextEndpointBaselineIT.class,
         SecurityIT.class,
         RuleServiceIT.class,
+        RouterEndpointRoleSecurityIT.class,
         PrivacyServiceIT.class,
         GroovyActionsServiceIT.class,
         GroovyActionsEndpointRoleSecurityIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java 
b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index a00e0199b..705356297 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -60,6 +60,7 @@ import org.junit.runners.Suite.SuiteClasses;
         ContextEndpointBaselineIT.class,
         SecurityIT.class,
         RuleServiceIT.class,
+        RouterEndpointRoleSecurityIT.class,
         PrivacyServiceIT.class,
         GroovyActionsServiceIT.class,
         GroovyActionsEndpointRoleSecurityIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/RouterEndpointRoleSecurityIT.java
 
b/itests/src/test/java/org/apache/unomi/itests/RouterEndpointRoleSecurityIT.java
new file mode 100644
index 000000000..a076bcdf9
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/RouterEndpointRoleSecurityIT.java
@@ -0,0 +1,136 @@
+/*
+ * 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.unomi.itests;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * HTTP-level checks that system-admin-only REST endpoints reject tenant 
private keys
+ * (including multipart upload / oneshot paths).
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class RouterEndpointRoleSecurityIT extends BaseIT {
+
+    @Test
+    public void importConfiguration_requiresSystemAdministrator() throws 
Exception {
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(
+                new HttpGet(getFullUrl("/cxs/importConfiguration")), 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals("Tenant private key must not list import 
configurations",
+                    403, tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(
+                new HttpGet(getFullUrl("/cxs/importConfiguration")), 
AuthType.JAAS_ADMIN)) {
+            Assert.assertEquals("JAAS admin should list import configurations",
+                    200, jaasAdmin.getStatusLine().getStatusCode());
+        }
+    }
+
+    @Test
+    public void exportConfiguration_requiresSystemAdministrator() throws 
Exception {
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(
+                new HttpGet(getFullUrl("/cxs/exportConfiguration")), 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals(403, 
tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(
+                new HttpGet(getFullUrl("/cxs/exportConfiguration")), 
AuthType.JAAS_ADMIN)) {
+            Assert.assertEquals(200, 
jaasAdmin.getStatusLine().getStatusCode());
+        }
+    }
+
+    @Test
+    public void 
importConfiguration_oneshotUpload_requiresSystemAdministrator() throws 
Exception {
+        HttpPost oneshot = 
multipartPost(getFullUrl("/cxs/importConfiguration/oneshot"),
+                "----UnomiImportBoundary",
+                part("importConfigId", "text/plain", 
"rest-role-security-oneshot"),
+                filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n"));
+
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(oneshot, 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals(403, 
tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        HttpPost oneshotJaas = 
multipartPost(getFullUrl("/cxs/importConfiguration/oneshot"),
+                "----UnomiImportBoundaryJaas",
+                part("importConfigId", "text/plain", 
"rest-role-security-oneshot"),
+                filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n"));
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas, 
AuthType.JAAS_ADMIN)) {
+            // Role gate is what we care about; missing config may yield 500 
after auth succeeds.
+            Assert.assertNotEquals(403, 
jaasAdmin.getStatusLine().getStatusCode());
+            Assert.assertNotEquals(401, 
jaasAdmin.getStatusLine().getStatusCode());
+        }
+    }
+
+    @Test
+    public void exportConfiguration_oneshot_requiresSystemAdministrator() 
throws Exception {
+        String body = 
"{\"itemId\":\"rest-role-security-export\",\"itemType\":\"exportConfig\"}";
+        HttpPost oneshot = new 
HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot"));
+        oneshot.setEntity(new StringEntity(body, 
ContentType.APPLICATION_JSON));
+
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(oneshot, 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals(403, 
tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        HttpPost oneshotJaas = new 
HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot"));
+        oneshotJaas.setEntity(new StringEntity(body, 
ContentType.APPLICATION_JSON));
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas, 
AuthType.JAAS_ADMIN)) {
+            Assert.assertNotEquals(403, 
jaasAdmin.getStatusLine().getStatusCode());
+            Assert.assertNotEquals(401, 
jaasAdmin.getStatusLine().getStatusCode());
+        }
+    }
+
+
+
+    private static HttpPost multipartPost(String url, String boundary, 
String... parts) {
+        HttpPost post = new HttpPost(url);
+        StringBuilder body = new StringBuilder();
+        for (String part : parts) {
+            body.append("--").append(boundary).append("\r\n").append(part);
+        }
+        body.append("--").append(boundary).append("--\r\n");
+        post.setHeader("Content-Type", "multipart/form-data; boundary=" + 
boundary);
+        post.setEntity(new 
ByteArrayEntity(body.toString().getBytes(StandardCharsets.UTF_8)));
+        return post;
+    }
+
+    private static String part(String name, String contentType, String value) {
+        return "Content-Disposition: form-data; name=\"" + name + "\"\r\n"
+                + "Content-Type: " + contentType + "\r\n\r\n"
+                + value + "\r\n";
+    }
+
+    private static String filePart(String name, String filename, String 
contentType, String value) {
+        return "Content-Disposition: form-data; name=\"" + name + "\"; 
filename=\"" + filename + "\"\r\n"
+                + "Content-Type: " + contentType + "\r\n\r\n"
+                + value + "\r\n";
+    }
+}

Reply via email to