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";
+ }
+}