From b84b09709816669763085324a17a8b7d1112f27a Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Sat, 21 Mar 2026 21:02:04 +0100 Subject: [PATCH 1/4] Ship default OSGi config granting administrators access to the Groovy Console Adds a default OSGi configuration for DefaultConfigurationService that allows admin, administrators, and IMS AEM Administrator Product Profile users to execute scripts and schedule jobs. Uses the Felix ConfigAdmin interpolation variable for AEMaaCS compatibility with a fallback to the administrators group for AEM 6.5. Closes #55 --- ...uration.impl.DefaultConfigurationService.cfg.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json diff --git a/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json b/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json new file mode 100644 index 00000000..22ff74d8 --- /dev/null +++ b/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json @@ -0,0 +1,12 @@ +{ + "allowedGroups": [ + "admin", + "administrators", + "$[env:aemCloudAdministrators;default=administrators]" + ], + "allowedScheduledJobsGroups": [ + "admin", + "administrators", + "$[env:aemCloudAdministrators;default=administrators]" + ] +} From cfab9322e811e5df023af643862f059d1c4ebb61 Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Mon, 23 Mar 2026 20:24:36 +0100 Subject: [PATCH 2/4] Switch to programmatic calling of the groups so that we don't need a custom cloud package --- .../configuration/impl/DefaultConfigurationService.groovy | 8 ++++++++ .../impl/ConfigurationServiceProperties.java | 5 +++++ ...onfiguration.impl.DefaultConfigurationService.cfg.json | 6 ++---- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/bundle/src/main/groovy/be/orbinson/aem/groovy/console/configuration/impl/DefaultConfigurationService.groovy b/bundle/src/main/groovy/be/orbinson/aem/groovy/console/configuration/impl/DefaultConfigurationService.groovy index 1f225fa8..ea23e29b 100755 --- a/bundle/src/main/groovy/be/orbinson/aem/groovy/console/configuration/impl/DefaultConfigurationService.groovy +++ b/bundle/src/main/groovy/be/orbinson/aem/groovy/console/configuration/impl/DefaultConfigurationService.groovy @@ -100,6 +100,14 @@ class DefaultConfigurationService implements ConfigurationService { if (bundleContext.getProperty("sling.run.modes") != null) { author = bundleContext.getProperty("sling.run.modes").contains("author") } + if (properties.addAemCloudProductAdministrators()) { + def aemCloudAdministrators = System.getenv("aemCloudAdministrators") + if (aemCloudAdministrators) { + LOG.debug("Adding AEM Cloud product administrators group '{}' to allowed groups", aemCloudAdministrators) + allowedGroups = allowedGroups + [aemCloudAdministrators] as Set + allowedScheduledJobsGroups = allowedScheduledJobsGroups + [aemCloudAdministrators] as Set + } + } } private boolean isAdminOrAllowedGroupMember(SlingHttpServletRequest request, Set groupIds) { diff --git a/bundle/src/main/java/be/orbinson/aem/groovy/console/configuration/impl/ConfigurationServiceProperties.java b/bundle/src/main/java/be/orbinson/aem/groovy/console/configuration/impl/ConfigurationServiceProperties.java index f163eac6..3c4af891 100644 --- a/bundle/src/main/java/be/orbinson/aem/groovy/console/configuration/impl/ConfigurationServiceProperties.java +++ b/bundle/src/main/java/be/orbinson/aem/groovy/console/configuration/impl/ConfigurationServiceProperties.java @@ -43,4 +43,9 @@ description = "If enabled, a script will be able to be replicated from an author and executed on all default replication agents." ) boolean distributedExecutionEnabled() default false; + + @AttributeDefinition(name = "Add AEM Cloud product administrators?", + description = "If enabled, the group referenced by the 'aemCloudAdministrators' environment variable (set by AEM as a Cloud Service) will automatically be added to the allowed groups. Has no effect if the environment variable is not set (e.g. on AEM 6.5)." + ) + boolean addAemCloudProductAdministrators() default true; } diff --git a/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json b/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json index 22ff74d8..ceda8e0a 100644 --- a/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json +++ b/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json @@ -1,12 +1,10 @@ { "allowedGroups": [ "admin", - "administrators", - "$[env:aemCloudAdministrators;default=administrators]" + "administrators" ], "allowedScheduledJobsGroups": [ "admin", - "administrators", - "$[env:aemCloudAdministrators;default=administrators]" + "administrators" ] } From dff30701ab7b76dfdee37e9afe7ed55e75307109 Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Mon, 23 Mar 2026 20:25:45 +0100 Subject: [PATCH 3/4] Don't provide a default configuration service, so that administrators are not default allowed without configuration --- ...iguration.impl.DefaultConfigurationService.cfg.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json diff --git a/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json b/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json deleted file mode 100644 index ceda8e0a..00000000 --- a/ui.config/src/main/content/jcr_root/apps/groovyconsole-config/osgiconfig/config/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "allowedGroups": [ - "admin", - "administrators" - ], - "allowedScheduledJobsGroups": [ - "admin", - "administrators" - ] -} From 0c46be7d9ab3867e27fc7941301b22250a5000c8 Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Mon, 23 Mar 2026 22:34:09 +0100 Subject: [PATCH 4/4] Add integration tests for the allowed groups and aem cloud env-based groups --- .../impl/DefaultConfigurationService.groovy | 26 +++++- .../audit/impl/DefaultAuditServiceTest.groovy | 2 + it.tests/pom.xml | 4 + .../src/main/features/groovyconsole-it.json | 9 ++ it.tests/src/main/features/groovyconsole.json | 2 +- .../console/it/GroovyConsoleServiceIT.java | 91 ++++++++++++++++++- 6 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 it.tests/src/main/features/groovyconsole-it.json diff --git a/bundle/src/main/groovy/be/orbinson/aem/groovy/console/configuration/impl/DefaultConfigurationService.groovy b/bundle/src/main/groovy/be/orbinson/aem/groovy/console/configuration/impl/DefaultConfigurationService.groovy index ea23e29b..07061973 100755 --- a/bundle/src/main/groovy/be/orbinson/aem/groovy/console/configuration/impl/DefaultConfigurationService.groovy +++ b/bundle/src/main/groovy/be/orbinson/aem/groovy/console/configuration/impl/DefaultConfigurationService.groovy @@ -3,10 +3,13 @@ package be.orbinson.aem.groovy.console.configuration.impl import be.orbinson.aem.groovy.console.configuration.ConfigurationService import groovy.transform.Synchronized import groovy.util.logging.Slf4j +import org.apache.jackrabbit.api.JackrabbitSession import org.apache.jackrabbit.api.security.user.User import org.apache.jackrabbit.api.security.user.UserManager import org.apache.sling.api.SlingHttpServletRequest +import javax.jcr.Session import org.apache.sling.api.resource.ResourceResolverFactory +import org.apache.sling.serviceusermapping.ServiceUserMapped import org.osgi.framework.BundleContext import org.osgi.service.component.annotations.Activate import org.osgi.service.component.annotations.Component @@ -22,6 +25,9 @@ class DefaultConfigurationService implements ConfigurationService { @Reference private ResourceResolverFactory resourceResolverFactory + @Reference(target = "(!(subServiceName=*))") + private ServiceUserMapped serviceUserMapped + private boolean emailEnabled private Set emailRecipients @@ -112,16 +118,30 @@ class DefaultConfigurationService implements ConfigurationService { private boolean isAdminOrAllowedGroupMember(SlingHttpServletRequest request, Set groupIds) { resourceResolverFactory.getServiceResourceResolver(null).withCloseable { resourceResolver -> - def userManager = resourceResolver.adaptTo(UserManager); + def userManager = resourceResolver.adaptTo(UserManager) + if (userManager == null) { + // AEM provides a ResourceResolver -> UserManager adapter factory; Sling Starter does not. + // Fall back to obtaining the UserManager directly from the JCR session. + def session = resourceResolver.adaptTo(Session) + if (session instanceof JackrabbitSession) { + userManager = ((JackrabbitSession) session).userManager + } + } if (userManager != null) { - def user = resourceResolver.adaptTo(UserManager).getAuthorizable(request.userPrincipal) as User + def principal = request.getUserPrincipal() + def user = (principal != null ? userManager.getAuthorizable(principal) : null) as User + ?: userManager.getAuthorizable(request.getResourceResolver().getUserID()) as User + if (user == null) { + LOG.debug("Could not find user in UserManager, denying access") + return false + } def memberOfGroupIds = user.memberOf()*.ID LOG.debug("member of group IDs : {}, allowed group IDs : {}", memberOfGroupIds, groupIds) user.admin || (groupIds ? memberOfGroupIds.intersect(groupIds as Iterable) : false) } else { - LOG.debug("UserManager not available, probably in a Sling Based application, falling back to is admin check") + LOG.debug("UserManager not available, falling back to is admin check") return request.getResourceResolver().getUserID() == "admin" } } diff --git a/bundle/src/test/groovy/be/orbinson/aem/groovy/console/audit/impl/DefaultAuditServiceTest.groovy b/bundle/src/test/groovy/be/orbinson/aem/groovy/console/audit/impl/DefaultAuditServiceTest.groovy index 1e006d11..04536d3f 100644 --- a/bundle/src/test/groovy/be/orbinson/aem/groovy/console/audit/impl/DefaultAuditServiceTest.groovy +++ b/bundle/src/test/groovy/be/orbinson/aem/groovy/console/audit/impl/DefaultAuditServiceTest.groovy @@ -8,6 +8,7 @@ import be.orbinson.aem.groovy.console.response.impl.DefaultRunScriptResponse import io.wcm.testing.mock.aem.junit5.AemContext import io.wcm.testing.mock.aem.junit5.AemContextExtension import org.apache.commons.lang3.exception.ExceptionUtils +import org.apache.sling.serviceusermapping.ServiceUserMapped import org.apache.sling.testing.mock.sling.ResourceResolverType import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -26,6 +27,7 @@ class DefaultAuditServiceTest { @BeforeEach void beforeEach() { context.build().resource("/var/groovyconsole").commit(); + context.registerService(ServiceUserMapped, {} as ServiceUserMapped) context.registerInjectActivateService(new DefaultConfigurationService()) auditService = context.registerInjectActivateService(new DefaultAuditService()) } diff --git a/it.tests/pom.xml b/it.tests/pom.xml index 21622fd1..a82753fa 100644 --- a/it.tests/pom.xml +++ b/it.tests/pom.xml @@ -120,6 +120,10 @@ + + + it-test-cloud-group + 180 diff --git a/it.tests/src/main/features/groovyconsole-it.json b/it.tests/src/main/features/groovyconsole-it.json new file mode 100644 index 00000000..c545109d --- /dev/null +++ b/it.tests/src/main/features/groovyconsole-it.json @@ -0,0 +1,9 @@ +{ + "repoinit:TEXT|true": [ + "create user it-test-unprivileged with password ItTest1234", + "create group it-test-cloud-group", + "create user it-test-cloud-user with password ItTest1234", + "add it-test-cloud-user to group it-test-cloud-group", + "set ACL for it-test-cloud-group\n allow jcr:read on /\nend" + ] +} diff --git a/it.tests/src/main/features/groovyconsole.json b/it.tests/src/main/features/groovyconsole.json index da6e593d..27117326 100644 --- a/it.tests/src/main/features/groovyconsole.json +++ b/it.tests/src/main/features/groovyconsole.json @@ -1,5 +1,5 @@ { - "content-packages:ARTIFACTS|required":[ + "content-packages:ARTIFACTS|required": [ { "id": "be.orbinson.aem:aem-groovy-console-all:zip:${project.version}" } diff --git a/it.tests/src/test/java/be/orbinson/aem/groovy/console/it/GroovyConsoleServiceIT.java b/it.tests/src/test/java/be/orbinson/aem/groovy/console/it/GroovyConsoleServiceIT.java index 12785429..da3d3fca 100644 --- a/it.tests/src/test/java/be/orbinson/aem/groovy/console/it/GroovyConsoleServiceIT.java +++ b/it.tests/src/test/java/be/orbinson/aem/groovy/console/it/GroovyConsoleServiceIT.java @@ -31,6 +31,11 @@ class GroovyConsoleServiceIT { private static final String BASE_URL = "http://localhost:" + SLING_PORT; private static final String AUTH_HEADER = "Basic " + Base64.encodeBase64String("admin:admin".getBytes(StandardCharsets.UTF_8)); + // Users and groups created via repoinit in groovyconsole-it.json feature model + private static final String UNPRIVILEGED_USER = "it-test-unprivileged"; + private static final String CLOUD_USER = "it-test-cloud-user"; + private static final String TEST_PASSWORD = "ItTest1234"; + private static CloseableHttpClient httpClient; @BeforeAll @@ -175,6 +180,47 @@ void testGroovyJsonStarImport() throws Exception { assertEquals("{\"a\":1}", response.get("result").getAsString()); } + @Test + void testUnauthenticatedUserCannotExecuteScript() throws Exception { + HttpPost post = new HttpPost(BASE_URL + "/bin/groovyconsole/post"); + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("script", "return 1")); + post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); + // No Authorization header + + try (CloseableHttpResponse response = httpClient.execute(post)) { + int status = response.getStatusLine().getStatusCode(); + assertTrue(status == 401 || status == 403, + "Expected 401 or 403 for unauthenticated request, got " + status); + } + } + + /** + * Verifies that a user without any allowed group membership cannot execute scripts. + * The 'it-test-unprivileged' user is created at startup via repoinit in groovyconsole.json. + */ + @Test + void testNonPrivilegedUserCannotExecuteScript() throws Exception { + int status = executeScriptStatus("return 1", UNPRIVILEGED_USER, TEST_PASSWORD); + assertTrue(status == 401 || status == 403, + "Expected 401 or 403 for non-privileged user, got " + status); + } + + /** + * Verifies that a user in a group referenced by the 'aemCloudAdministrators' system property + * (simulating the AEM Cloud environment variable) is automatically granted access. + * + * The Sling JVM is started with environmentVariable aemCloudAdministrators=it-test-cloud-group (see pom.xml), + * and 'it-test-cloud-user' is added to 'it-test-cloud-group' via repoinit in groovyconsole-it.json. + */ + @Test + void testAemCloudProductAdministratorsGroupGrantsAccess() throws Exception { + JsonObject response = executeScript("return 'cloud-access-granted'", CLOUD_USER, TEST_PASSWORD); + assertNotNull(response, "Could not get response from API"); + assertEquals("", response.get("exceptionStackTrace").getAsString()); + assertEquals("cloud-access-granted", response.get("result").getAsString()); + } + private static boolean isHealthy() { try { HttpGet healthCheck = new HttpGet(BASE_URL + "/system/health.json?tags=systemalive,groovyconsole"); @@ -182,7 +228,20 @@ private static boolean isHealthy() { try (CloseableHttpResponse response = httpClient.execute(healthCheck)) { String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); JsonObject jsonResponse = JsonParser.parseString(body).getAsJsonObject(); - return "OK".equals(jsonResponse.get("overallResult").getAsString()); + if (!"OK".equals(jsonResponse.get("overallResult").getAsString())) { + return false; + } + } + // Also verify the ScriptPostServlet is actually registered and available. + // The health check may pass before the servlet resource providers are bound + // (returning 0 groovyconsole-tagged results counts as OK). A GET to the + // endpoint returns 405 when the servlet is up, 404 when it is not yet ready. + HttpGet servletCheck = new HttpGet(BASE_URL + "/bin/groovyconsole/post"); + servletCheck.addHeader("Authorization", AUTH_HEADER); + try (CloseableHttpResponse response = httpClient.execute(servletCheck)) { + int status = response.getStatusLine().getStatusCode(); + EntityUtils.consume(response.getEntity()); + return status != 404; } } catch (Exception e) { return false; @@ -207,17 +266,35 @@ private static JsonObject doGet(String path) throws IOException { } } - private static JsonObject executeScript(String script) throws IOException { + private static int executeScriptStatus(String script, String user, String password) throws IOException { HttpPost post = new HttpPost(BASE_URL + "/bin/groovyconsole/post"); - List params = new java.util.ArrayList<>(); + post.addHeader("Authorization", authHeader(user, password)); + List params = new ArrayList<>(); params.add(new BasicNameValuePair("script", script)); post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); - post.addHeader("Authorization", AUTH_HEADER); + try (CloseableHttpResponse response = httpClient.execute(post)) { + EntityUtils.consume(response.getEntity()); + return response.getStatusLine().getStatusCode(); + } + } + + private static JsonObject executeScript(String script) throws IOException { + return executeScript(script, AUTH_HEADER); + } + private static JsonObject executeScript(String script, String user, String password) throws IOException { + return executeScript(script, authHeader(user, password)); + } + + private static JsonObject executeScript(String script, String authHeader) throws IOException { + HttpPost post = new HttpPost(BASE_URL + "/bin/groovyconsole/post"); + post.addHeader("Authorization", authHeader); + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("script", script)); + post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); try (CloseableHttpResponse response = httpClient.execute(post)) { assertEquals(200, response.getStatusLine().getStatusCode(), "Expected HTTP 200 but got " + response.getStatusLine()); - String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); try { return JsonParser.parseString(body).getAsJsonObject(); @@ -227,4 +304,8 @@ private static JsonObject executeScript(String script) throws IOException { } } } + + private static String authHeader(String user, String password) { + return "Basic " + Base64.encodeBase64String((user + ":" + password).getBytes(StandardCharsets.UTF_8)); + } }