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..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 @@ -100,20 +106,42 @@ 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) { 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/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/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)); + } }