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 647b809147..800508f90c 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 @@
Apache Karaf feature for the Apache Unomi Context Server extension
wrap unomi-services + + unomi-rest-api mvn:org.apache.servicemix.bundles/org.apache.servicemix.bundles.jsch/0.1.55_1 mvn:commons-net/commons-net/${commons-net.version} wrap:mvn:org.apache.kafka/kafka-clients/${kafka.client.version} diff --git a/extensions/router/router-rest/pom.xml b/extensions/router/router-rest/pom.xml index b7b9233dbc..5d7f84404b 100644 --- a/extensions/router/router-rest/pom.xml +++ b/extensions/router/router-rest/pom.xml @@ -55,6 +55,11 @@ unomi-router-api provided + + org.apache.unomi + unomi-rest + provided + org.osgi @@ -103,6 +108,11 @@ slf4j-api provided + + org.junit.jupiter + junit-jupiter + test + 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 4371e2d13f..a773251608 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 @@ allowCredentials = true ) @Path("/exportConfiguration") +@RequiresRole(UnomiRoles.ADMINISTRATOR) @Component(service=ExportConfigurationServiceEndPoint.class,property = "osgi.jaxrs.resource=true") public class ExportConfigurationServiceEndPoint extends AbstractConfigurationServiceEndpoint { 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 e8d639df12..cbc7dcec38 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 @@ 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 @@ allowCredentials = true ) @Path("/importConfiguration") +@RequiresRole(UnomiRoles.ADMINISTRATOR) @Component(service=ImportConfigurationServiceEndPoint.class,property = "osgi.jaxrs.resource=true") public class ImportConfigurationServiceEndPoint extends AbstractConfigurationServiceEndpoint { 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 0000000000..ab9cfb8b35 --- /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 41351e5b02..8c00827b0c 100644 --- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java @@ -58,6 +58,7 @@ ContextServletIT.class, SecurityIT.class, RuleServiceIT.class, + RouterEndpointRoleSecurityIT.class, PrivacyServiceIT.class, GroovyActionsServiceIT.class, GraphQLEventIT.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 6cc692a0d1..277cd2eb45 100644 --- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java @@ -59,6 +59,7 @@ ContextServletIT.class, SecurityIT.class, RuleServiceIT.class, + RouterEndpointRoleSecurityIT.class, PrivacyServiceIT.class, GroovyActionsServiceIT.class, GraphQLEventIT.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 0000000000..a076bcdf9b --- /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"; + } +}