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 668a15fbf UNOMI-976: Restrict Groovy action upload to system 
administrators (#852)
668a15fbf is described below

commit 668a15fbfca583f47809519c9dd52a358b6294ef
Author: Serge Huber <[email protected]>
AuthorDate: Tue Sep 22 16:48:51 2026 +0200

    UNOMI-976: Restrict Groovy action upload to system administrators (#852)
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../karaf-kar/src/main/feature/feature.xml         |   2 +
 extensions/groovy-actions/rest/pom.xml             |  10 ++
 .../groovy/actions/rest/GroovyActionsEndPoint.java |   5 +
 .../rest/GroovyActionsEndPointRoleTest.java        |  37 +++++++
 .../services/impl/GroovyActionsServiceImpl.java    |  23 ++++-
 .../impl/GroovyActionsServiceImplTest.java         |  49 +++++++++
 .../cxs/actions/fieldInitializerAction.groovy      |  27 +++++
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../apache/unomi/itests/CorePersistenceITs.java    |   1 +
 .../GroovyActionsEndpointRoleSecurityIT.java       | 113 +++++++++++++++++++++
 10 files changed, 265 insertions(+), 3 deletions(-)

diff --git a/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml 
b/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
index 5d527b1e8..c16201eda 100644
--- a/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
+++ b/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
@@ -20,6 +20,8 @@
         <details>${project.description}</details>
         <feature>wrap</feature>
         <feature>unomi-services</feature>
+        <!-- REST endpoints use @RequiresRole from 
org.apache.unomi.rest.security -->
+        <feature>unomi-rest-api</feature>
         <configfile 
finalname="/etc/org.apache.unomi.groovy.actions.cfg">mvn:org.apache.unomi/unomi-groovy-actions-services/${project.version}/cfg/groovyactionscfg</configfile>
         <bundle 
start="false">mvn:org.apache.unomi/unomi-groovy-actions-services/${project.version}</bundle>
         <bundle 
start="false">mvn:org.apache.unomi/unomi-groovy-actions-rest/${project.version}</bundle>
diff --git a/extensions/groovy-actions/rest/pom.xml 
b/extensions/groovy-actions/rest/pom.xml
index 3bedcc985..b6aef5cd3 100644
--- a/extensions/groovy-actions/rest/pom.xml
+++ b/extensions/groovy-actions/rest/pom.xml
@@ -57,6 +57,11 @@
             <artifactId>unomi-groovy-actions-services</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.unomi</groupId>
+            <artifactId>unomi-rest</artifactId>
+            <scope>provided</scope>
+        </dependency>
 
         <dependency>
             <groupId>org.osgi</groupId>
@@ -106,6 +111,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>
         <plugins>
diff --git 
a/extensions/groovy-actions/rest/src/main/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPoint.java
 
b/extensions/groovy-actions/rest/src/main/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPoint.java
index 1f41cf6f3..635dfe3d4 100644
--- 
a/extensions/groovy-actions/rest/src/main/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPoint.java
+++ 
b/extensions/groovy-actions/rest/src/main/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPoint.java
@@ -21,7 +21,9 @@ import org.apache.commons.io.IOUtils;
 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.groovy.actions.services.GroovyActionsService;
+import org.apache.unomi.rest.security.RequiresRole;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
@@ -35,6 +37,7 @@ import java.io.IOException;
 @Produces(MediaType.APPLICATION_JSON + ";charset=UTF-8")
 @CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = true)
 @Path("/groovyActions")
+@RequiresRole(UnomiRoles.ADMINISTRATOR)
 @Component(service = GroovyActionsEndPoint.class, property = 
"osgi.jaxrs.resource=true")
 public class GroovyActionsEndPoint {
 
@@ -55,6 +58,7 @@ public class GroovyActionsEndPoint {
      * Uploads a Groovy action script and registers a matching action type.
      * <p>
      * The multipart field {@code file} must be a {@code .groovy} file; the 
action id is derived from the filename.
+     * Restricted to system administrators (JAAS); tenant administrators 
cannot upload scripts.
      *
      * @param file the Groovy script upload
      * @return an empty success response
@@ -79,6 +83,7 @@ public class GroovyActionsEndPoint {
 
     /**
      * Deletes the Groovy action and its action type entry.
+     * Restricted to system administrators (JAAS).
      *
      * @param actionId the action identifier
      * @api.status 204 empty Action deleted.
diff --git 
a/extensions/groovy-actions/rest/src/test/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPointRoleTest.java
 
b/extensions/groovy-actions/rest/src/test/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPointRoleTest.java
new file mode 100644
index 000000000..92ffea28d
--- /dev/null
+++ 
b/extensions/groovy-actions/rest/src/test/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPointRoleTest.java
@@ -0,0 +1,37 @@
+/*
+ * 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.groovy.actions.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: Groovy upload/delete must stay restricted to system 
administrators.
+ */
+class GroovyActionsEndPointRoleTest {
+
+    @Test
+    void endpointRequiresSystemAdministratorRole() {
+        RequiresRole requiresRole = 
GroovyActionsEndPoint.class.getAnnotation(RequiresRole.class);
+        assertNotNull(requiresRole, "GroovyActionsEndPoint must declare 
@RequiresRole");
+        assertArrayEquals(new String[]{UnomiRoles.ADMINISTRATOR}, 
requiresRole.value());
+    }
+}
diff --git 
a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
 
b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
index 40c02a7ae..09307e038 100644
--- 
a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
+++ 
b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
@@ -301,7 +301,7 @@ public class GroovyActionsServiceImpl extends 
AbstractMultiTypeCachingService im
             // Extract Action annotation and register the ActionType
             try {
                 synchronized(compilationLock) {
-                    Action actionAnnotation = 
compilationShell.parse(groovyCodeSource).getClass().getMethod("execute").getAnnotation(Action.class);
+                    Action actionAnnotation = 
compileToClass(groovyCodeSource).getMethod("execute").getAnnotation(Action.class);
                     if (actionAnnotation != null) {
                         contextManager.executeAsSystem(() -> {
                             saveActionType(actionAnnotation);
@@ -407,7 +407,7 @@ public class GroovyActionsServiceImpl extends 
AbstractMultiTypeCachingService im
             try {
                 GroovyCodeSource groovyCodeSource = new 
GroovyCodeSource(script, actionName, "/groovy/script");
                 synchronized(compilationLock) {
-                    
compilationShell.parse(groovyCodeSource).getClass().getMethod("execute");
+                    compileToClass(groovyCodeSource).getMethod("execute");
                 }
                 // Note: We don't extract or save the ActionType here
             } catch (NoSuchMethodException e) {
@@ -471,16 +471,33 @@ public class GroovyActionsServiceImpl extends 
AbstractMultiTypeCachingService im
         }
     }
 
+
     /**
      * Thread-safe script compilation using synchronized shared shell.
      */
     private Class<? extends Script> compileScript(String actionName, String 
scriptContent) {
         GroovyCodeSource codeSource = new GroovyCodeSource(scriptContent, 
actionName, "/groovy/script");
         synchronized(compilationLock) {
-            return compilationShell.parse(codeSource).getClass();
+            return compileToClass(codeSource);
         }
     }
 
+
+    /**
+     * Compiles a script to its Class without instantiating it.
+     * <p>
+     * Deliberately {@code parseClass} and not {@code GroovyShell#parse}: 
{@code parse} returns a
+     * {@code Script} <em>instance</em>, and constructing that instance runs 
the script's field
+     * initializers. An uploaded script carrying a Groovy {@code @Field} 
initializer would therefore
+     * execute at upload/compile time, before any rule ever dispatches it. 
Every caller here only
+     * needs the compiled Class (to read the {@code @Action} annotation or 
check for {@code execute}),
+     * so nothing needs to be instantiated until the action is actually run.
+     */
+    @SuppressWarnings("unchecked")
+    private Class<? extends Script> compileToClass(GroovyCodeSource 
codeSource) {
+        return (Class<? extends Script>) 
compilationShell.getClassLoader().parseClass(codeSource, false);
+    }
+
     /**
      * Compiles a script and creates metadata with timing information.
      */
diff --git 
a/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
 
b/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
index 94536a296..557ff0649 100644
--- 
a/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
+++ 
b/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.unomi.groovy.actions.services.impl;
 
+import groovy.lang.GroovyShell;
 import groovy.lang.Script;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.ExecutionContext;
@@ -47,6 +48,7 @@ import org.osgi.framework.wiring.BundleWiring;
 
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.nio.file.Paths;
 import java.util.*;
 
@@ -59,6 +61,8 @@ import static org.mockito.Mockito.when;
  */
 public class GroovyActionsServiceImplTest {
 
+    private static final String FIELD_INITIALIZER_MARKER = 
"unomi.test.groovyFieldInitializerRan";
+
     private GroovyActionsServiceImpl groovyActionsService;
     private TenantService tenantService;
     private PersistenceService persistenceService;
@@ -172,6 +176,51 @@ public class GroovyActionsServiceImplTest {
         });
     }
 
+    /**
+     * Saving an action must compile it without instantiating it. A Groovy 
{@code @Field} initializer
+     * runs at instantiation, so an implementation that instantiates while 
saving would run whatever
+     * the script's author put in that initializer at save time, before any 
rule dispatches the action.
+     * <p>
+     * The positive control matters as much as the assertion. The same script 
is first run through a
+     * plain {@link GroovyShell}, which must set the marker; without that step 
a script that silently
+     * failed to set it would make the real assertion pass while proving 
nothing.
+     */
+    @Test
+    public void testSaveCompilesWithoutInstantiating() throws Exception {
+        String groovyScript = loadGroovyScript(
+            "/META-INF/cxs/actions/fieldInitializerAction.groovy",
+            "Could not find the field-initializer test Groovy action file");
+        System.clearProperty(FIELD_INITIALIZER_MARKER);
+        try {
+            // Positive control: instantiating the script does run the @Field 
initializer.
+            new GroovyShell().parse(stripActionAnnotation(groovyScript));
+            assertEquals("positive control failed: the @Field initializer did 
not run even via "
+                            + "GroovyShell#parse, so the assertion below would 
prove nothing",
+                    "true", System.getProperty(FIELD_INITIALIZER_MARKER));
+            System.clearProperty(FIELD_INITIALIZER_MARKER);
+
+            // The assertion: saving the very same script must not instantiate 
it.
+            contextManager.executeAsTenant(TENANT_1, () -> {
+                groovyActionsService.save("fieldInitializerAction", 
groovyScript);
+            });
+
+            assertNull("Saving a Groovy action must compile it without 
instantiating it, so a @Field "
+                            + "initializer must not run at save time",
+                    System.getProperty(FIELD_INITIALIZER_MARKER));
+        } finally {
+            System.clearProperty(FIELD_INITIALIZER_MARKER);
+        }
+    }
+
+    /**
+     * Drops the {@code @Action} line so the positive control compiles under a 
bare {@link GroovyShell},
+     * which has neither the service's ImportCustomizer nor its script base 
class. The {@code @Field}
+     * initializer - the only part under test - is untouched.
+     */
+    private static String stripActionAnnotation(String script) {
+        return script.replaceAll("(?m)^@Action\\(.*\\)$", "");
+    }
+
     @Test
     public void testRemoveGroovyAction() throws Exception {
         // First save an action
diff --git 
a/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy
 
b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy
new file mode 100644
index 000000000..530b36e4f
--- /dev/null
+++ 
b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+import groovy.transform.Field
+
+// A Groovy @Field initializer runs when the script class is *instantiated*, 
not when it is
+// compiled. Saving this action must compile it without instantiating it, so 
this marker must not
+// be set afterwards. Setting a system property keeps the probe inert.
+@Field def sideEffect = { 
System.setProperty("unomi.test.groovyFieldInitializerRan", "true") }()
+
+@Action(id = "fieldInitializerAction", actionExecutor = 
"groovy:fieldInitializerAction")
+def execute() {
+    return EventService.NO_CHANGE
+}
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 89609d825..6a6881caf 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -61,6 +61,7 @@ import org.junit.runners.Suite.SuiteClasses;
         RuleServiceIT.class,
         PrivacyServiceIT.class,
         GroovyActionsServiceIT.class,
+        GroovyActionsEndpointRoleSecurityIT.class,
         GraphQLEventIT.class,
         GraphQLListIT.class,
         GraphQLProfileIT.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 c279eeef7..a00e0199b 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -62,6 +62,7 @@ import org.junit.runners.Suite.SuiteClasses;
         RuleServiceIT.class,
         PrivacyServiceIT.class,
         GroovyActionsServiceIT.class,
+        GroovyActionsEndpointRoleSecurityIT.class,
         GraphQLEventIT.class,
         GraphQLListIT.class,
         GraphQLProfileIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/GroovyActionsEndpointRoleSecurityIT.java
 
b/itests/src/test/java/org/apache/unomi/itests/GroovyActionsEndpointRoleSecurityIT.java
new file mode 100644
index 000000000..5802faf26
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/GroovyActionsEndpointRoleSecurityIT.java
@@ -0,0 +1,113 @@
+/*
+ * 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 GroovyActionsEndpointRoleSecurityIT extends BaseIT {
+
+
+
+
+
+    @Test
+    public void groovyActions_requiresSystemAdministrator() throws Exception {
+        String path = 
getFullUrl("/cxs/groovyActions/rest-role-security-it-missing-action");
+
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(new 
HttpDelete(path), AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals("Tenant private key must not delete groovy 
actions",
+                    403, tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(new 
HttpDelete(path), AuthType.JAAS_ADMIN)) {
+            int status = jaasAdmin.getStatusLine().getStatusCode();
+            Assert.assertTrue("JAAS admin delete should be allowed (got " + 
status + ")",
+                    status == 200 || status == 204 || status == 404);
+        }
+    }
+
+    @Test
+    public void groovyActions_upload_requiresSystemAdministrator() throws 
Exception {
+        String script = "// GroovyActionsEndpointRoleSecurityIT probe\nvoid 
execute() {}\n";
+        HttpPost upload = multipartPost(getFullUrl("/cxs/groovyActions/"),
+                "----UnomiGroovyBoundary",
+                filePart("file", "RestRoleSecurityITProbe.groovy", 
"text/plain", script));
+
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(upload, 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals("Tenant private key must not upload groovy 
actions",
+                    403, tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        HttpPost uploadJaas = multipartPost(getFullUrl("/cxs/groovyActions/"),
+                "----UnomiGroovyBoundaryJaas",
+                filePart("file", "RestRoleSecurityITProbe.groovy", 
"text/plain", script));
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(uploadJaas, 
AuthType.JAAS_ADMIN)) {
+            Assert.assertEquals("JAAS admin should be allowed to upload groovy 
actions",
+                    200, jaasAdmin.getStatusLine().getStatusCode());
+        }
+
+        try (CloseableHttpResponse cleanup = executeHttpRequest(
+                new 
HttpDelete(getFullUrl("/cxs/groovyActions/RestRoleSecurityITProbe")), 
AuthType.JAAS_ADMIN)) {
+            int status = cleanup.getStatusLine().getStatusCode();
+            Assert.assertTrue(status == 200 || status == 204 || status == 404);
+        }
+    }
+
+    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