This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 705fbfb036 Enforce RBAC on the embedded Hop Server API in Hop Web
(fixes #8150) (#8169)
705fbfb036 is described below
commit 705fbfb03647c72e1802432d4ed69ed05eed3fda
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Aug 29 14:11:00 2026 +0200
Enforce RBAC on the embedded Hop Server API in Hop Web (fixes #8150) (#8169)
The Hop Server servlet is co-deployed under /hop/* inside the Hop Web
webapp but had no authorization: any authenticated session (including the
READ_ONLY role) could deploy and execute pipelines and workflows via the
API, bypassing the RBAC the RAP UI enforces. In the default open install
(mode NONE) the same endpoints were reachable unauthenticated.
- HopServerEndpointPermissionMapper (core): maps each /hop/* endpoint to
the Permission it requires.
- HopServerAuthorizationFilter (rap), mapped after the auth filters:
* BASIC/EXTERNAL/OAUTH2 - resolve the request principal's roles and
require the endpoint's permission; 403 otherwise. Unknown endpoints
default-deny so new servlets cannot silently widen the surface.
* NONE - no user identity, so the API is all-open or all-closed. Closed
by default (allowUnauthenticatedServerApi=false) so the default image
does not expose unauthenticated execution; opt in via the config flag,
HOP_WEB_ALLOW_UNAUTHENTICATED_SERVER_API, or the Security config UI.
- web.xml (web assembly and the EXTERNAL local-auth-config sample) register
the filter on /hop/*.
- Security -> General tab: checkbox for the NONE opt-in.
- Tests: mapper + filter unit tests; HopServerApiRbacTest (web-tests)
drives the endpoints per role over HTTP; manual curl scripts under
web-tests/api-checks.
- Docs: hop-web.adoc notes the /hop/* authorization and the NONE default.
Standalone hop-server and hop-rest are unchanged.
---
.gitignore | 4 +
assemblies/web/src/main/resources/WEB-INF/web.xml | 11 +
.../hop/core/security/HopSecurityBootstrap.java | 25 +++
.../hop/core/security/HopSecurityConfig.java | 13 ++
.../HopServerEndpointPermissionMapper.java | 158 +++++++++++++
.../HopServerEndpointPermissionMapperTest.java | 182 +++++++++++++++
docker/local-auth-config/web.xml | 10 +
.../modules/ROOT/pages/hop-gui/hop-web.adoc | 5 +
.../security/HopServerAuthorizationFilter.java | 210 +++++++++++++++++
.../security/HopServerAuthorizationFilterTest.java | 250 +++++++++++++++++++++
.../tabs/security/ConfigSecurityGeneralTab.java | 24 +-
.../tabs/messages/messages_en_US.properties | 3 +
web-tests/api-checks/README.md | 90 ++++++++
web-tests/api-checks/exec-test.sh | 75 +++++++
web-tests/api-checks/make-payload.sh | 73 ++++++
web-tests/api-checks/probe-api.sh | 64 ++++++
web-tests/api-checks/run-matrix.sh | 40 ++++
.../apache/hop/web/it/HopServerApiRbacTest.java | 162 +++++++++++++
18 files changed, 1398 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 2975fcb11d..c8b5771d33 100644
--- a/.gitignore
+++ b/.gitignore
@@ -104,4 +104,8 @@ google-key-apache-hop-it.json
# Deliberately NOT a blanket "config/": ~20 tracked directories are named
config, including
# Java packages (org/apache/hop/core/config) and
assemblies/static/src/main/resources/config.
/core/config/
+/rap/config/
config/security/
+
+# Generated by web-tests/api-checks/make-payload.sh during manual runs
+web-tests/api-checks/*.xml
diff --git a/assemblies/web/src/main/resources/WEB-INF/web.xml
b/assemblies/web/src/main/resources/WEB-INF/web.xml
index e50dd656b6..bcd757a33b 100644
--- a/assemblies/web/src/main/resources/WEB-INF/web.xml
+++ b/assemblies/web/src/main/resources/WEB-INF/web.xml
@@ -51,6 +51,17 @@
<url-pattern>/*</url-pattern>
</filter-mapping>
+ <!-- RBAC for the embedded Hop Server API. Runs after the auth filters so
the principal is set.
+ No-op in mode NONE; enforces endpoint permissions in EXTERNAL / BASIC
/ OAUTH2. -->
+ <filter>
+ <filter-name>HopServerAuthorization</filter-name>
+
<filter-class>org.apache.hop.ui.hopgui.security.HopServerAuthorizationFilter</filter-class>
+ </filter>
+ <filter-mapping>
+ <filter-name>HopServerAuthorization</filter-name>
+ <url-pattern>/hop/*</url-pattern>
+ </filter-mapping>
+
<servlet>
<servlet-name>HopGui</servlet-name>
<servlet-class>org.eclipse.rap.rwt.engine.RWTServlet</servlet-class>
diff --git
a/core/src/main/java/org/apache/hop/core/security/HopSecurityBootstrap.java
b/core/src/main/java/org/apache/hop/core/security/HopSecurityBootstrap.java
index 926dd8b6ef..0d25b5efa5 100644
--- a/core/src/main/java/org/apache/hop/core/security/HopSecurityBootstrap.java
+++ b/core/src/main/java/org/apache/hop/core/security/HopSecurityBootstrap.java
@@ -45,6 +45,8 @@ public final class HopSecurityBootstrap {
public static final String ENV_SECURITY_MODE = "HOP_WEB_SECURITY_MODE";
public static final String ENV_ALLOW_DEFAULT_ADMIN =
"HOP_WEB_ALLOW_DEFAULT_ADMIN";
public static final String ENV_SEED_DEMO_USERS = "HOP_WEB_SEED_DEMO_USERS";
+ public static final String ENV_ALLOW_UNAUTHENTICATED_SERVER_API =
+ "HOP_WEB_ALLOW_UNAUTHENTICATED_SERVER_API";
public static final String ENV_OAUTH_ISSUER = "HOP_WEB_OAUTH_ISSUER";
public static final String ENV_OAUTH_CLIENT_ID = "HOP_WEB_OAUTH_CLIENT_ID";
@@ -67,6 +69,7 @@ public final class HopSecurityBootstrap {
try {
HopUserStore.applyEnvironmentModeOverride();
applyOauthEnvironmentOverrides();
+ applyServerApiEnvironmentOverride();
HopSecurityConfig.clearCache();
HopOidcClient.clearDiscoveryCache();
HopSecurityConfig config = HopSecurityConfig.load();
@@ -118,6 +121,28 @@ public final class HopSecurityBootstrap {
}
}
+ /**
+ * Apply {@link #ENV_ALLOW_UNAUTHENTICATED_SERVER_API} into
security-config.json when present.
+ * Only governs mode {@code NONE}; the authenticated modes always enforce
RBAC on {@code /hop/*}.
+ */
+ public static void applyServerApiEnvironmentOverride() {
+ String value = env(ENV_ALLOW_UNAUTHENTICATED_SERVER_API);
+ if (value == null) {
+ return;
+ }
+ HopSecurityConfig config = HopSecurityConfig.load();
+ boolean allow = isTruthy(value);
+ if (config.isAllowUnauthenticatedServerApi() != allow) {
+ config.setAllowUnauthenticatedServerApi(allow);
+ HopSecurityConfig.save(config);
+ LogChannel.GENERAL.logBasic(
+ "Hop Server API in mode NONE set to "
+ + (allow ? "OPEN" : "CLOSED")
+ + " from "
+ + ENV_ALLOW_UNAUTHENTICATED_SERVER_API);
+ }
+ }
+
/** Apply OAuth-related env vars into security-config.json when present. */
public static void applyOauthEnvironmentOverrides() {
HopSecurityConfig config = HopSecurityConfig.load();
diff --git
a/core/src/main/java/org/apache/hop/core/security/HopSecurityConfig.java
b/core/src/main/java/org/apache/hop/core/security/HopSecurityConfig.java
index 8e976fe266..6924c7bbfd 100644
--- a/core/src/main/java/org/apache/hop/core/security/HopSecurityConfig.java
+++ b/core/src/main/java/org/apache/hop/core/security/HopSecurityConfig.java
@@ -74,6 +74,19 @@ public class HopSecurityConfig {
private String mode = AuthMode.NONE.name();
+ /**
+ * Whether the embedded Hop Server API ({@code /hop/*}) is reachable in mode
{@code NONE}.
+ *
+ * <p>In the authenticated modes ({@code BASIC}, {@code EXTERNAL}, {@code
OAUTH2}) the API is
+ * always available and governed by role-based access control. Mode {@code
NONE} has no user
+ * identity, so the API can only be all-open or all-closed: this flag
decides which, and defaults
+ * to {@code false} (closed) so the default open Hop Web install does not
expose unauthenticated
+ * pipeline and workflow execution. Set it to {@code true} (or {@code
+ * HOP_WEB_ALLOW_UNAUTHENTICATED_SERVER_API}) to use Hop Web purely as an
execution server behind
+ * your own network controls.
+ */
+ private boolean allowUnauthenticatedServerApi = false;
+
/**
* Optional explicit mapping from container role name → Hop role id ({@code
admin}, {@code user},
* {@code operator}, {@code readonly}). When empty, built-in aliases in
{@link HopRole} are used.
diff --git
a/core/src/main/java/org/apache/hop/core/security/HopServerEndpointPermissionMapper.java
b/core/src/main/java/org/apache/hop/core/security/HopServerEndpointPermissionMapper.java
new file mode 100644
index 0000000000..e5c4e39d5d
--- /dev/null
+++
b/core/src/main/java/org/apache/hop/core/security/HopServerEndpointPermissionMapper.java
@@ -0,0 +1,158 @@
+/*
+ * 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.hop.core.security;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Maps Hop Server servlet paths ({@code /hop/*}) to the {@link Permission}
required to call them.
+ *
+ * <p>Used by the Hop Web authorization filter so the embedded Hop Server API
honours the same
+ * role-based access control as the RAP UI. Lives in {@code hop-core} so it
carries no servlet
+ * dependency and can be unit tested in isolation; the servlet filter supplies
the request path.
+ *
+ * <p>Matching is by longest context-path prefix, because most servlets accept
trailing path
+ * segments (for example {@code /hop/pipelineStatus/<name>/<id>}). Endpoints
not listed here are
+ * <em>unknown</em>; the filter treats unknown endpoints as default-deny so
that new servlets do not
+ * silently widen the authenticated attack surface.
+ *
+ * <p>Every read endpoint maps to {@link Permission#FILE_VIEW} (which the
built-in {@code READ_ONLY}
+ * role holds), so status and image calls stay available to viewers while
mutations and runs do not.
+ */
+public final class HopServerEndpointPermissionMapper {
+
+ /**
+ * Context path → required permission. Insertion order is longest-first only
where prefixes would
+ * otherwise collide; the lookup does an explicit longest-match so ordering
is not load-bearing.
+ */
+ private static final Map<String, Permission> ENDPOINT_PERMISSIONS =
buildTable();
+
+ private HopServerEndpointPermissionMapper() {
+ // utility
+ }
+
+ private static Map<String, Permission> buildTable() {
+ Map<String, Permission> map = new LinkedHashMap<>();
+
+ // --- Read / inspect: available to READ_ONLY (FILE_VIEW) ---
+ map.put("/hop/status", Permission.FILE_VIEW);
+ map.put("/hop/pipelineStatus", Permission.FILE_VIEW);
+ map.put("/hop/workflowStatus", Permission.FILE_VIEW);
+ map.put("/hop/pipelineImage", Permission.FILE_VIEW);
+ map.put("/hop/workflowImage", Permission.FILE_VIEW);
+ map.put("/hop/getExecInfo", Permission.FILE_VIEW);
+ map.put("/hop/asyncStatus", Permission.FILE_VIEW);
+ // Sniffs live rows from a running pipeline: a read of running state, not
a mutation.
+ map.put("/hop/sniffTransform", Permission.FILE_VIEW);
+
+ // --- Deploy / register a definition on the server: treated as a
save/write ---
+ map.put("/hop/addPipeline", Permission.FILE_SAVE);
+ map.put("/hop/addWorkflow", Permission.FILE_SAVE);
+ map.put("/hop/registerPipeline", Permission.FILE_SAVE);
+ map.put("/hop/registerWorkflow", Permission.FILE_SAVE);
+ map.put("/hop/addExport", Permission.FILE_SAVE);
+ map.put("/hop/registerPackage", Permission.FILE_SAVE);
+
+ // --- Execution info store writes/deletes: metadata-level writes ---
+ map.put("/hop/registerExecInfo", Permission.METADATA_WRITE);
+ map.put("/hop/deleteExecInfo", Permission.METADATA_WRITE);
+
+ // --- Execute: RUN_EXECUTE ---
+ map.put("/hop/prepareExec", Permission.RUN_EXECUTE);
+ map.put("/hop/startExec", Permission.RUN_EXECUTE);
+ map.put("/hop/execPipeline", Permission.RUN_EXECUTE);
+ map.put("/hop/execWorkflow", Permission.RUN_EXECUTE);
+ map.put("/hop/startPipeline", Permission.RUN_EXECUTE);
+ map.put("/hop/startWorkflow", Permission.RUN_EXECUTE);
+ map.put("/hop/asyncRun", Permission.RUN_EXECUTE);
+ // A web service synchronously executes a pipeline and returns its output.
+ map.put("/hop/webService", Permission.RUN_EXECUTE);
+
+ // --- Control a running execution: RUN_STOP ---
+ map.put("/hop/stopPipeline", Permission.RUN_STOP);
+ map.put("/hop/stopWorkflow", Permission.RUN_STOP);
+ map.put("/hop/pausePipeline", Permission.RUN_STOP);
+
+ // --- Remove a deployed definition: FILE_DELETE ---
+ map.put("/hop/removePipeline", Permission.FILE_DELETE);
+ map.put("/hop/removeWorkflow", Permission.FILE_DELETE);
+
+ return map;
+ }
+
+ /**
+ * Required permission for a Hop Server request path.
+ *
+ * @param path servlet path within the app, e.g. {@code /hop/startPipeline}
or {@code
+ * /hop/pipelineStatus/name/id}; a leading context path must already be
stripped
+ * @return the required permission, or empty when the path is not a known
Hop Server endpoint
+ */
+ public static Optional<Permission> requiredPermission(String path) {
+ String normalized = normalize(path);
+ if (normalized == null) {
+ return Optional.empty();
+ }
+ Permission best = null;
+ int bestLen = -1;
+ for (Map.Entry<String, Permission> entry :
ENDPOINT_PERMISSIONS.entrySet()) {
+ String key = entry.getKey();
+ if ((normalized.equals(key) || normalized.startsWith(key + "/")) &&
key.length() > bestLen) {
+ best = entry.getValue();
+ bestLen = key.length();
+ }
+ }
+ return Optional.ofNullable(best);
+ }
+
+ /**
+ * Whether the path is a known built-in Hop Server endpoint. The filter
denies unknown {@code
+ * /hop/*} paths by default.
+ *
+ * @param path servlet path within the app
+ * @return true if a built-in endpoint permission is defined for the path
+ */
+ public static boolean isKnownEndpoint(String path) {
+ return requiredPermission(path).isPresent();
+ }
+
+ private static String normalize(String path) {
+ if (path == null || path.isBlank()) {
+ return null;
+ }
+ String p = path.trim();
+ // Strip a ;jsessionid= or matrix params
+ int semi = p.indexOf(';');
+ if (semi >= 0) {
+ p = p.substring(0, semi);
+ }
+ // Strip a query string if one slipped in
+ int q = p.indexOf('?');
+ if (q >= 0) {
+ p = p.substring(0, q);
+ }
+ // Drop a trailing slash (but keep a bare "/"). Casing is preserved: the
servlet registry keys
+ // endpoints case-sensitively (e.g. camelCase "startPipeline"), so the
table keys must match
+ // exactly on the endpoint segment.
+ while (p.length() > 1 && p.endsWith("/")) {
+ p = p.substring(0, p.length() - 1);
+ }
+ return p;
+ }
+}
diff --git
a/core/src/test/java/org/apache/hop/core/security/HopServerEndpointPermissionMapperTest.java
b/core/src/test/java/org/apache/hop/core/security/HopServerEndpointPermissionMapperTest.java
new file mode 100644
index 0000000000..aa6caa99a3
--- /dev/null
+++
b/core/src/test/java/org/apache/hop/core/security/HopServerEndpointPermissionMapperTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.hop.core.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+class HopServerEndpointPermissionMapperTest {
+
+ @Test
+ void readEndpointsRequireFileView() {
+ for (String path :
+ new String[] {
+ "/hop/status",
+ "/hop/pipelineStatus",
+ "/hop/workflowStatus",
+ "/hop/pipelineImage",
+ "/hop/workflowImage",
+ "/hop/getExecInfo",
+ "/hop/asyncStatus",
+ "/hop/sniffTransform"
+ }) {
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+ HopServerEndpointPermissionMapper.requiredPermission(path),
+ path);
+ }
+ }
+
+ @Test
+ void deployEndpointsRequireFileSave() {
+ for (String path :
+ new String[] {
+ "/hop/addPipeline",
+ "/hop/addWorkflow",
+ "/hop/registerPipeline",
+ "/hop/registerWorkflow",
+ "/hop/addExport",
+ "/hop/registerPackage"
+ }) {
+ assertEquals(
+ Optional.of(Permission.FILE_SAVE),
+ HopServerEndpointPermissionMapper.requiredPermission(path),
+ path);
+ }
+ }
+
+ @Test
+ void executionInfoStoreRequiresMetadataWrite() {
+ assertEquals(
+ Optional.of(Permission.METADATA_WRITE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/registerExecInfo"));
+ assertEquals(
+ Optional.of(Permission.METADATA_WRITE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/deleteExecInfo"));
+ }
+
+ @Test
+ void executeEndpointsRequireRunExecute() {
+ for (String path :
+ new String[] {
+ "/hop/prepareExec",
+ "/hop/startExec",
+ "/hop/execPipeline",
+ "/hop/execWorkflow",
+ "/hop/startPipeline",
+ "/hop/startWorkflow",
+ "/hop/asyncRun",
+ "/hop/webService"
+ }) {
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+ HopServerEndpointPermissionMapper.requiredPermission(path),
+ path);
+ }
+ }
+
+ @Test
+ void controlEndpointsRequireRunStop() {
+ for (String path :
+ new String[] {"/hop/stopPipeline", "/hop/stopWorkflow",
"/hop/pausePipeline"}) {
+ assertEquals(
+ Optional.of(Permission.RUN_STOP),
+ HopServerEndpointPermissionMapper.requiredPermission(path),
+ path);
+ }
+ }
+
+ @Test
+ void removeEndpointsRequireFileDelete() {
+ assertEquals(
+ Optional.of(Permission.FILE_DELETE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/removePipeline"));
+ assertEquals(
+ Optional.of(Permission.FILE_DELETE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/removeWorkflow"));
+ }
+
+ @Test
+ void readOnlyRoleCanReadButNotMutateOrRun() {
+ // Guards the core promise of the fix: the READ_ONLY role passes the read
endpoints and is
+ // refused deploy / run / stop / delete.
+ HopSecurityContext readonly =
+ HopSecurityContext.forUser("viewer",
java.util.Set.of(HopRole.READ_ONLY));
+
+ assertTrue(
+ readonly.allows(
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/status").orElseThrow()));
+ assertTrue(
+ readonly.allows(
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/pipelineStatus")
+ .orElseThrow()));
+
+ assertFalse(
+ readonly.allows(
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/addWorkflow")
+ .orElseThrow()));
+ assertFalse(
+ readonly.allows(
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/startWorkflow")
+ .orElseThrow()));
+ assertFalse(
+ readonly.allows(
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/removePipeline")
+ .orElseThrow()));
+ }
+
+ @Test
+ void longestPrefixMatchHandlesTrailingSegments() {
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/pipelineStatus/my-pipe/1234"));
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/startPipeline/"));
+ }
+
+ @Test
+ void jsessionidAndQueryAreStripped() {
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+ HopServerEndpointPermissionMapper.requiredPermission(
+ "/hop/startWorkflow;jsessionid=ABC123"));
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/startWorkflow?name=x&id=y"));
+ }
+
+ @Test
+ void unknownEndpointsAreNotKnownAndHaveNoPermission() {
+
assertFalse(HopServerEndpointPermissionMapper.isKnownEndpoint("/hop/somethingNew"));
+ assertFalse(HopServerEndpointPermissionMapper.isKnownEndpoint("/hop"));
+ assertFalse(HopServerEndpointPermissionMapper.isKnownEndpoint("/"));
+
assertTrue(HopServerEndpointPermissionMapper.requiredPermission("/hop/nope").isEmpty());
+ }
+
+ @Test
+ void nullAndBlankAreSafe() {
+
assertTrue(HopServerEndpointPermissionMapper.requiredPermission(null).isEmpty());
+ assertTrue(HopServerEndpointPermissionMapper.requiredPermission("
").isEmpty());
+ assertFalse(HopServerEndpointPermissionMapper.isKnownEndpoint(null));
+ }
+}
diff --git a/docker/local-auth-config/web.xml b/docker/local-auth-config/web.xml
index dc0323690f..cb66c81428 100644
--- a/docker/local-auth-config/web.xml
+++ b/docker/local-auth-config/web.xml
@@ -44,10 +44,20 @@
<filter-name>HopOidcAuth</filter-name>
<filter-class>org.apache.hop.ui.hopgui.security.HopOidcAuthFilter</filter-class>
</filter>
+ <!-- RBAC for the embedded Hop Server API. Runs after the auth filters so
the container
+ principal is set. Maps each /hop/* endpoint to the permission its
role must hold. -->
+ <filter>
+ <filter-name>HopServerAuthorization</filter-name>
+
<filter-class>org.apache.hop.ui.hopgui.security.HopServerAuthorizationFilter</filter-class>
+ </filter>
<filter-mapping>
<filter-name>HopBasicAuth</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
+ <filter-mapping>
+ <filter-name>HopServerAuthorization</filter-name>
+ <url-pattern>/hop/*</url-pattern>
+ </filter-mapping>
<servlet>
<servlet-name>HopGui</servlet-name>
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
index 757f6e28de..8f614bba39 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
@@ -147,6 +147,11 @@ There are two complementary layers:
IMPORTANT: The same security constraint must cover the whole application
(`/*`), not only `/ui`.
Hop Web co-deploys the RAP UI, RAP service handlers, and the Hop Server
servlet under `/hop/*` on the same origin.
+NOTE: The Hop Server API under `/hop/*` follows the same authorization as the
rest of Hop Web.
+In the authenticated modes (`BASIC`, `EXTERNAL`, `OAUTH2`) each endpoint
requires the matching permission from the caller's role — for example a
*Read-only* user can call `status` and the status/image endpoints but not
`addPipeline`, `startPipeline`, `execWorkflow`, or the register/remove
endpoints, and an *Operator* can run and stop but not deploy (`add*`) or remove.
+Endpoints that are not recognized are denied by default.
+In mode `NONE` (the default, open install) `/hop/*` is unauthenticated just
like `/ui`; put an authentication layer in front of Hop Web for any shared
deployment.
+
=== Built-in Hop roles
When a user is authenticated, Hop Web reads the servlet `Principal` and
container roles and maps them to one or more of these built-in roles:
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopServerAuthorizationFilter.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopServerAuthorizationFilter.java
new file mode 100644
index 0000000000..b4b9448d9b
--- /dev/null
+++
b/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopServerAuthorizationFilter.java
@@ -0,0 +1,210 @@
+/*
+ * 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.hop.ui.hopgui.security;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.FilterConfig;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.Optional;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.apache.hop.core.security.HopSecurityConfig;
+import org.apache.hop.core.security.HopSecurityContext;
+import org.apache.hop.core.security.HopSecurityContextResolver;
+import org.apache.hop.core.security.HopServerEndpointPermissionMapper;
+import org.apache.hop.core.security.Permission;
+
+/**
+ * Role-based access control for the Hop Server API ({@code /hop/*})
co-deployed inside Hop Web.
+ *
+ * <p>The authentication filters ({@link HopBasicAuthFilter} / {@link
HopOidcAuthFilter}) run first
+ * and, in the authenticated modes, wrap the request with a principal and
roles. This filter maps
+ * the requested endpoint to the {@link Permission} it needs via {@link
+ * HopServerEndpointPermissionMapper} and rejects the call with {@code 403}
when the caller's roles
+ * do not grant it. A {@code READ_ONLY} user can therefore read status and
images but cannot deploy,
+ * run, stop, or remove.
+ *
+ * <p>Behaviour by mode:
+ *
+ * <ul>
+ * <li>{@code NONE} — no user identity, so the server API is all-open or
all-closed. Closed by
+ * default so the default open Hop Web install does not expose
unauthenticated pipeline and
+ * workflow execution; set {@code allowUnauthenticatedServerApi} (or
{@code
+ * HOP_WEB_ALLOW_UNAUTHENTICATED_SERVER_API}) to open it behind your own
network controls.
+ * <li>{@code EXTERNAL} / {@code BASIC} / {@code OAUTH2} — enforce the
endpoint permission against
+ * the authenticated principal's roles.
+ * </ul>
+ *
+ * <p>Unknown {@code /hop/*} paths (a servlet not in the built-in table, e.g.
a third-party plugin)
+ * are denied by default in the authenticated modes, so a newly added servlet
cannot silently widen
+ * the authenticated attack surface. It becomes reachable once its path is
added to the mapper.
+ *
+ * <p>This filter does not authenticate; if no principal is present in an
authenticated mode the
+ * upstream auth filter has already redirected or challenged, so a missing
principal here is treated
+ * as unauthorized.
+ */
+public class HopServerAuthorizationFilter implements Filter {
+
+ private static final Logger LOG =
Logger.getLogger(HopServerAuthorizationFilter.class.getName());
+
+ @Override
+ public void init(FilterConfig filterConfig) {
+ LOG.info(
+ "HopServerAuthorizationFilter initialized (guards /hop/* RBAC in
authenticated modes)");
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
+ throws IOException, ServletException {
+
+ if (!(request instanceof HttpServletRequest httpRequest)
+ || !(response instanceof HttpServletResponse httpResponse)) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ HopSecurityConfig config = HopSecurityConfig.load();
+ HopSecurityConfig.AuthMode mode = config.getAuthMode();
+
+ String path = pathWithinApp(httpRequest);
+ if (path == null || !path.startsWith("/hop")) {
+ // Not a Hop Server endpoint (filter is mapped to /hop/* but stay
defensive).
+ chain.doFilter(request, response);
+ return;
+ }
+
+ // Mode NONE has no user identity, so the server API is either fully open
or fully closed.
+ // Closed by default so the default open Hop Web install does not expose
unauthenticated
+ // pipeline/workflow execution; opt in with allowUnauthenticatedServerApi.
+ if (mode == HopSecurityConfig.AuthMode.NONE) {
+ if (config.isAllowUnauthenticatedServerApi()) {
+ chain.doFilter(request, response);
+ } else {
+ LOG.log(
+ Level.FINE,
+ "Hop Server API disabled in mode NONE for ''{0}''
(allowUnauthenticatedServerApi=false)",
+ path);
+ deny(
+ httpResponse,
+ "Hop Server API is disabled. Enable authentication, or set "
+ + "allowUnauthenticatedServerApi=true to expose it without
authentication.",
+ HttpServletResponse.SC_FORBIDDEN);
+ }
+ return;
+ }
+
+ HopSecurityContext context = resolveContext(httpRequest);
+
+ // Unrestricted context (no real principal): the auth filter should have
handled this; refuse.
+ if (context == null) {
+ deny(httpResponse, "Authentication required",
HttpServletResponse.SC_UNAUTHORIZED);
+ return;
+ }
+
+ Optional<Permission> required =
HopServerEndpointPermissionMapper.requiredPermission(path);
+ if (required.isEmpty()) {
+ // Unknown endpoint: default-deny so new/plugin servlets cannot bypass
RBAC.
+ LOG.log(
+ Level.WARNING,
+ "Denying unmapped Hop Server endpoint ''{0}'' for user ''{1}''
(default-deny)",
+ new Object[] {path, context.getUsername()});
+ deny(httpResponse, "Not authorized for this endpoint",
HttpServletResponse.SC_FORBIDDEN);
+ return;
+ }
+
+ if (!context.allows(required.get())) {
+ LOG.log(
+ Level.INFO,
+ "Hop Server RBAC denied ''{0}'' for user ''{1}'' (requires {2},
roles={3})",
+ new Object[] {path, context.getUsername(), required.get().getId(),
context.getRoleIds()});
+ deny(
+ httpResponse,
+ "Access denied: " + required.get().getId() + " required",
+ HttpServletResponse.SC_FORBIDDEN);
+ return;
+ }
+
+ chain.doFilter(request, response);
+ }
+
+ /**
+ * Resolve the security context from the request principal and container
roles, the same way the
+ * RAP UI session provider does. Returns {@code null} when no real principal
is present.
+ */
+ private HopSecurityContext resolveContext(HttpServletRequest request) {
+ Principal principal;
+ try {
+ principal = request.getUserPrincipal();
+ } catch (UnsupportedOperationException e) {
+ return null;
+ }
+ if (principal == null || principal.getName() == null ||
principal.getName().isBlank()) {
+ return null;
+ }
+ String username = principal.getName().trim();
+ Set<String> roles =
+
HopSecurityContextResolver.collectKnownContainerRoles(request::isUserInRole);
+ HopSecurityContext context = HopSecurityContextResolver.resolve(username,
roles);
+ // A blank principal would have produced an unrestricted context; guard
against that leaking
+ // full access to the server API.
+ if (context == null || context.isUnrestricted()) {
+ return null;
+ }
+ return context;
+ }
+
+ private void deny(HttpServletResponse response, String message, int status)
throws IOException {
+ if (response.isCommitted()) {
+ return;
+ }
+ response.setStatus(status);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.setContentType("text/plain; charset=UTF-8");
+ response.setHeader("Cache-Control", "no-store");
+ response.getWriter().write(message);
+ }
+
+ private static String pathWithinApp(HttpServletRequest request) {
+ String contextPath = request.getContextPath();
+ String uri = request.getRequestURI();
+ if (uri == null) {
+ return null;
+ }
+ if (contextPath != null && !contextPath.isEmpty() &&
uri.startsWith(contextPath)) {
+ uri = uri.substring(contextPath.length());
+ }
+ if (uri.isEmpty()) {
+ return "/";
+ }
+ return uri;
+ }
+
+ @Override
+ public void destroy() {
+ // nothing
+ }
+}
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopServerAuthorizationFilterTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopServerAuthorizationFilterTest.java
new file mode 100644
index 0000000000..219bb6cce4
--- /dev/null
+++
b/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopServerAuthorizationFilterTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.hop.ui.hopgui.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.Path;
+import java.security.Principal;
+import java.util.Set;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.security.HopSecurityConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Unit tests for {@link HopServerAuthorizationFilter}: verifies that {@code
/hop/*} endpoints are
+ * gated by the caller's Hop role in the authenticated modes, and left open in
mode {@code NONE}.
+ */
+class HopServerAuthorizationFilterTest {
+
+ @TempDir static Path configFolder;
+
+ private HopServerAuthorizationFilter filter;
+ private HttpServletRequest request;
+ private HttpServletResponse response;
+ private FilterChain chain;
+ private StringWriter responseBody;
+
+ @BeforeAll
+ static void initEnvironment() {
+ // HopSecurityConfig.save() logs and writes; give it a log store and a
scratch config folder.
+ System.setProperty("HOP_CONFIG_FOLDER",
configFolder.toAbsolutePath().toString());
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ filter = new HopServerAuthorizationFilter();
+ request = mock(HttpServletRequest.class);
+ response = mock(HttpServletResponse.class);
+ chain = mock(FilterChain.class);
+ responseBody = new StringWriter();
+ when(response.getWriter()).thenReturn(new PrintWriter(responseBody));
+ when(request.getContextPath()).thenReturn("");
+ }
+
+ @AfterEach
+ void tearDown() {
+ setMode(HopSecurityConfig.AuthMode.NONE);
+ HopSecurityConfig.clearCache();
+ }
+
+ private void setMode(HopSecurityConfig.AuthMode mode) {
+ setMode(mode, false);
+ }
+
+ private void setMode(HopSecurityConfig.AuthMode mode, boolean
allowUnauthenticatedServerApi) {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setAuthMode(mode);
+ config.setAllowUnauthenticatedServerApi(allowUnauthenticatedServerApi);
+ HopSecurityConfig.save(config);
+ }
+
+ /** Wire the request as an authenticated user with the given built-in role
names. */
+ private void authenticateAs(String username, Set<String> roleNames) {
+ Principal principal = () -> username;
+ when(request.getUserPrincipal()).thenReturn(principal);
+ when(request.isUserInRole(anyString()))
+ .thenAnswer(inv -> roleNames.contains(inv.getArgument(0,
String.class)));
+ }
+
+ private void requestPath(String uri) {
+ when(request.getRequestURI()).thenReturn(uri);
+ }
+
+ @Test
+ void modeNoneClosedByDefault() throws Exception {
+ // Default open Hop Web install: the server API is closed so there is no
unauthenticated
+ // pipeline/workflow execution.
+ setMode(HopSecurityConfig.AuthMode.NONE);
+ requestPath("/hop/startPipeline");
+ filter.doFilter(request, response, chain);
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
+ void modeNoneOpenWhenExplicitlyAllowed() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.NONE, true);
+ requestPath("/hop/startPipeline");
+ // No principal at all — passes through when the operator opted in.
+ filter.doFilter(request, response, chain);
+ verify(chain, times(1)).doFilter(request, response);
+ verify(response, never()).setStatus(anyInt());
+ }
+
+ @Test
+ void readOnlyAllowedOnStatus() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ authenticateAs("viewer", Set.of("readonly"));
+ requestPath("/hop/status");
+ filter.doFilter(request, response, chain);
+ verify(chain, times(1)).doFilter(request, response);
+ }
+
+ @Test
+ void readOnlyDeniedOnAddWorkflow() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ authenticateAs("viewer", Set.of("readonly"));
+ requestPath("/hop/addWorkflow");
+ filter.doFilter(request, response, chain);
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
+ void readOnlyDeniedOnStartAndRemove() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ for (String path :
+ new String[] {"/hop/startWorkflow", "/hop/execWorkflow",
"/hop/removePipeline"}) {
+ chain = mock(FilterChain.class);
+ response = mock(HttpServletResponse.class);
+ when(response.getWriter()).thenReturn(new PrintWriter(new
StringWriter()));
+ authenticateAs("viewer", Set.of("readonly"));
+ requestPath(path);
+ filter.doFilter(request, response, chain);
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+ }
+
+ @Test
+ void operatorAllowedToRunButNotToDeploy() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+
+ // Run endpoint: allowed (RUN_EXECUTE).
+ authenticateAs("operator", Set.of("operator"));
+ requestPath("/hop/startPipeline");
+ filter.doFilter(request, response, chain);
+ verify(chain, times(1)).doFilter(request, response);
+
+ // Deploy endpoint: denied (FILE_SAVE not granted to Operator).
+ chain = mock(FilterChain.class);
+ response = mock(HttpServletResponse.class);
+ when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+ authenticateAs("operator", Set.of("operator"));
+ requestPath("/hop/addPipeline");
+ filter.doFilter(request, response, chain);
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
+ void developerAllowedToDeployAndRun() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ authenticateAs("developer", Set.of("user"));
+ requestPath("/hop/addPipeline");
+ filter.doFilter(request, response, chain);
+ verify(chain, times(1)).doFilter(request, response);
+ }
+
+ @Test
+ void unknownEndpointDeniedByDefault() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ authenticateAs("admin", Set.of("admin"));
+ requestPath("/hop/somethingBrandNew");
+ filter.doFilter(request, response, chain);
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
+ void missingPrincipalInAuthenticatedModeIsUnauthorized() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ when(request.getUserPrincipal()).thenReturn(null);
+ requestPath("/hop/startPipeline");
+ filter.doFilter(request, response, chain);
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ @Test
+ void adminAllowedEverywhere() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ for (String path :
+ new String[] {
+ "/hop/addWorkflow", "/hop/execWorkflow", "/hop/removeWorkflow",
"/hop/status"
+ }) {
+ chain = mock(FilterChain.class);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(resp.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+ authenticateAs("admin", Set.of("admin"));
+ requestPath(path);
+ filter.doFilter(request, resp, chain);
+ verify(chain, times(1)).doFilter(request, resp);
+ }
+ }
+
+ @Test
+ void contextPathIsStrippedBeforeMatching() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ when(request.getContextPath()).thenReturn("/hop-web");
+ authenticateAs("viewer", Set.of("readonly"));
+ when(request.getRequestURI()).thenReturn("/hop-web/hop/addPipeline");
+ filter.doFilter(request, response, chain);
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
+ void denyResponseCarriesForbiddenStatus() throws Exception {
+ setMode(HopSecurityConfig.AuthMode.BASIC);
+ authenticateAs("viewer", Set.of("readonly"));
+ requestPath("/hop/startWorkflow");
+ filter.doFilter(request, response, chain);
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ assertEquals(true,
responseBody.toString().toLowerCase().contains("run.execute"));
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/ConfigSecurityGeneralTab.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/ConfigSecurityGeneralTab.java
index a8dc37d358..c71f745a2b 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/ConfigSecurityGeneralTab.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/ConfigSecurityGeneralTab.java
@@ -29,6 +29,7 @@ import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.CTabFolder;
import org.eclipse.swt.layout.FormAttachment;
import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Combo;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
@@ -43,6 +44,7 @@ public class ConfigSecurityGeneralTab implements
ISecurityConfigSection {
private Combo wMode;
private Text wWelcome;
+ private Button wAllowServerApi;
public ConfigSecurityGeneralTab() {
// Instantiated by ConfigSecurityTab / @GuiTab system
@@ -99,7 +101,21 @@ public class ConfigSecurityGeneralTab implements
ISecurityConfigSection {
wWelcome.setLayoutData(fdWelcome);
last = wWelcome;
- SecurityConfigUi.addHint(content, last, "ConfigSecurityTab.Welcome.Hint",
margin);
+ last = SecurityConfigUi.addHint(content, last,
"ConfigSecurityTab.Welcome.Hint", margin);
+
+ wAllowServerApi = new Button(content, SWT.CHECK);
+ PropsUi.setLook(wAllowServerApi);
+ wAllowServerApi.setText(BaseMessages.getString(PKG,
"ConfigSecurityTab.AllowServerApi.Label"));
+ wAllowServerApi.setToolTipText(
+ BaseMessages.getString(PKG,
"ConfigSecurityTab.AllowServerApi.Tooltip"));
+ FormData fdAllowServerApi = new FormData();
+ fdAllowServerApi.left = new FormAttachment(mid, margin);
+ fdAllowServerApi.top = new FormAttachment(last, margin * 2);
+ fdAllowServerApi.right = new FormAttachment(100, 0);
+ wAllowServerApi.setLayoutData(fdAllowServerApi);
+ last = wAllowServerApi;
+
+ SecurityConfigUi.addHint(content, last,
"ConfigSecurityTab.AllowServerApi.Hint", margin);
SecurityConfigUi.finishTabLayout(content);
}
@@ -112,6 +128,9 @@ public class ConfigSecurityGeneralTab implements
ISecurityConfigSection {
wWelcome.setText(Const.NVL(config.getWelcomeMessage(), ""));
wWelcome.setMessage(HopSecurityConfig.DEFAULT_WELCOME_MESSAGE);
}
+ if (wAllowServerApi != null && !wAllowServerApi.isDisposed()) {
+ wAllowServerApi.setSelection(config.isAllowUnauthenticatedServerApi());
+ }
}
@Override
@@ -122,6 +141,9 @@ public class ConfigSecurityGeneralTab implements
ISecurityConfigSection {
if (wWelcome != null && !wWelcome.isDisposed()) {
config.setWelcomeMessage(wWelcome.getText());
}
+ if (wAllowServerApi != null && !wAllowServerApi.isDisposed()) {
+ config.setAllowUnauthenticatedServerApi(wAllowServerApi.getSelection());
+ }
}
public String getSelectedMode() {
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/configuration/tabs/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/configuration/tabs/messages/messages_en_US.properties
index 63cffe1e78..cd7b20019b 100644
---
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/configuration/tabs/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/configuration/tabs/messages/messages_en_US.properties
@@ -28,6 +28,9 @@ ConfigSecurityTab.Mode.Hint=NONE = open access. EXTERNAL =
trust servlet contain
ConfigSecurityTab.Welcome.Label=Login welcome message
ConfigSecurityTab.Welcome.Tooltip=Shown on the Hop Web sign-in page (BASIC and
OAUTH2). Leave empty to use the default.
ConfigSecurityTab.Welcome.Hint=Optional custom text under the Apache Hop Web
title on the login page.
+ConfigSecurityTab.AllowServerApi.Label=Expose the Hop Server API without
authentication
+ConfigSecurityTab.AllowServerApi.Tooltip=Only applies to mode NONE. The
authenticated modes always require the matching role permission on /hop/*.
+ConfigSecurityTab.AllowServerApi.Hint=Off by default: the embedded Hop Server
API (/hop/*) is closed in mode NONE so it cannot run pipelines or workflows
without authentication. Enable only to use Hop Web as an execution server
behind your own network controls.
ConfigSecurityTab.Oauth.Group=OAuth2 / OpenID Connect
ConfigSecurityTab.Oauth.Issuer=Issuer URL
ConfigSecurityTab.Oauth.ClientId=Client ID
diff --git a/web-tests/api-checks/README.md b/web-tests/api-checks/README.md
new file mode 100644
index 0000000000..e0a2e0f417
--- /dev/null
+++ b/web-tests/api-checks/README.md
@@ -0,0 +1,90 @@
+<!--
+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.
+-->
+
+# Hop Server API access checks (manual)
+
+Manual reproduction and verification scripts for the RBAC guard on the embedded
+Hop Server API (`/hop/*`) in Hop Web — issue
+https://github.com/apache/hop/issues/8150.
+
+These drive the endpoints with plain `curl`, so they are handy for exploring a
+running container by hand or from a browser. The **automated** regression test
+is `HopServerApiRbacTest` in this module (`web-tests`); these scripts are the
+human-facing counterpart, not part of the CI gate.
+
+## Expected behaviour
+
+| Mode | Caller | Read (`status`) | Deploy / run / remove |
+|---|---|---|---|
+| `BASIC` / `EXTERNAL` / `OAUTH2` | Read-only role | 200 | 403 |
+| `BASIC` / `EXTERNAL` / `OAUTH2` | Operator | 200 | run/stop yes,
deploy/remove 403 |
+| `BASIC` / `EXTERNAL` / `OAUTH2` | User / Admin | 200 | 200 |
+| `NONE` (default) | anyone | 403 | 403 |
+| `NONE` + `allowUnauthenticatedServerApi` | anyone | 200 | 200 |
+
+## Scripts
+
+| Script | Purpose |
+|---|---|
+| `probe-api.sh <base-url> [user:pass]` | Sweep every `/hop/*` endpoint for
one identity; report reachable / login-redirect / blocked |
+| `make-payload.sh <pipeline.hpl> [out.xml]` | Build a valid `addPipeline`
body from a `.hpl` (embeds a `local` run configuration) |
+| `exec-test.sh <base-url> [user:pass]` | End-to-end: `addPipeline` ->
`startPipeline` -> `pipelineStatus` -> `removePipeline`; exit 0 = the pipeline
ran |
+| `run-matrix.sh <base-url>` | Run `exec-test.sh` for anonymous + the four
demo roles and print a summary |
+
+## Bring up a container to test against
+
+From the repository root, using the local development image:
+
+```bash
+# BASIC auth, demo users admin/developer/operator/viewer (password = username)
+./docker/run-hop-web-local-with-basic.sh --quick # ->
http://localhost:8080
+
+# default configuration, mode NONE (server API closed by default)
+docker run -d --name hopweb-none -p 8081:8080 hop-web:local
+
+# mode NONE, server API explicitly opened
+docker run -d --name hopweb-none-open -p 8082:8080 \
+ -e HOP_WEB_ALLOW_UNAUTHENTICATED_SERVER_API=true hop-web:local
+```
+
+## Run the checks
+
+```bash
+cd web-tests/api-checks
+
+# Per-endpoint verdict for the read-only role (status 200, mutations 403)
+./probe-api.sh http://localhost:8080 viewer:viewer
+
+# Full deploy+run matrix across roles
+./run-matrix.sh http://localhost:8080
+
+# The finding: a read-only user must not be able to run a pipeline
+./make-payload.sh
../../integration-tests/beam_directrunner/0001-generate-rows.hpl payload.xml
+./exec-test.sh http://localhost:8080 viewer:viewer # expect: blocked
+
+# Default NONE image must refuse the server API
+./exec-test.sh http://localhost:8081 # expect: blocked
+```
+
+### In a browser
+
+Log in to `http://localhost:8080` as `viewer`, then in the address bar:
+
+* `http://localhost:8080/hop/status/?xml=Y` — renders (read allowed)
+* `http://localhost:8080/hop/startWorkflow/?name=x` — `403 Access denied:
run.execute required`
diff --git a/web-tests/api-checks/exec-test.sh
b/web-tests/api-checks/exec-test.sh
new file mode 100755
index 0000000000..fce1d998c2
--- /dev/null
+++ b/web-tests/api-checks/exec-test.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# End-to-end proof: upload a pipeline over /hop/addPipeline and execute it
+# over /hop/startPipeline, then read back its status. Cleans up after itself.
+#
+# ./exec-test.sh <base-url> [user:password]
+#
+# Exit 0 = the pipeline RAN (finding confirmed for this identity)
+# Exit 1 = blocked at some step (authorization held)
+set -uo pipefail
+
+BASE="${1:-http://localhost:8080}"
+CREDS="${2:-}"
+AUTH=(); LABEL="anonymous"
+[[ -n "${CREDS}" ]] && { AUTH=(-u "${CREDS}"); LABEL="${CREDS%%:*}"; }
+
+PAYLOAD="${PAYLOAD:-payload.xml}"
+[[ -f "${PAYLOAD}" ]] || { echo "missing ${PAYLOAD} — run make-payload.sh
first"; exit 2; }
+NAME="${PIPELINE_NAME:-New pipeline}"
+
+echo "=== execution test against ${BASE} as ${LABEL}"
+
+echo "--- 1. POST /hop/addPipeline"
+add=$(curl -s "${AUTH[@]}" --max-time 30 -X POST --data-binary "@${PAYLOAD}" \
+ -H 'Content-Type: text/xml' "${BASE}/hop/addPipeline/?xml=Y")
+id=$(sed -n 's:.*<id>\(.*\)</id>.*:\1:p' <<<"${add}" | head -1)
+if [[ -z "${id}" ]]; then
+ echo " BLOCKED — no pipeline id returned"
+ head -c 300 <<<"${add}"; echo; exit 1
+fi
+echo " registered, id=${id}"
+
+echo "--- 2. GET /hop/startPipeline"
+start=$(curl -s "${AUTH[@]}" --max-time 30 -G "${BASE}/hop/startPipeline/" \
+ --data-urlencode "name=${NAME}" --data-urlencode "id=${id}" --data
'xml=Y')
+if ! grep -qi "<result>OK</result>" <<<"${start}"; then
+ echo " BLOCKED at start"; head -c 300 <<<"${start}"; echo; exit 1
+fi
+echo " started"
+
+sleep 4
+echo "--- 3. GET /hop/pipelineStatus"
+st=$(curl -s "${AUTH[@]}" --max-time 30 -G "${BASE}/hop/pipelineStatus/" \
+ --data-urlencode "name=${NAME}" --data-urlencode "id=${id}" --data
'xml=Y')
+desc=$(sed -n 's:.*<status_desc>\(.*\)</status_desc>.*:\1:p' <<<"${st}" | head
-1)
+errs=$(sed -n 's:.*<nr_errors>\(.*\)</nr_errors>.*:\1:p' <<<"${st}" | head -1)
+echo " status=${desc:-?} errors=${errs:-?}"
+grep -o '<transformName>[^<]*</transformName>' <<<"${st}" | sed -E
's!<transformName>(.*)</transformName>! transform ran: \1!'
+
+echo "--- 4. cleanup /hop/removePipeline"
+curl -s "${AUTH[@]}" --max-time 30 -G "${BASE}/hop/removePipeline/" \
+ --data-urlencode "name=${NAME}" --data-urlencode "id=${id}" --data
'xml=Y' >/dev/null
+echo " removed"
+
+if [[ "${desc}" == "Finished" ]]; then
+ echo
+ echo ">>> CONFIRMED: '${LABEL}' uploaded and executed a pipeline via the Hop
Server API."
+ exit 0
+fi
+echo; echo ">>> pipeline did not finish cleanly (status=${desc:-?})"; exit 1
diff --git a/web-tests/api-checks/make-payload.sh
b/web-tests/api-checks/make-payload.sh
new file mode 100755
index 0000000000..84dfc580ee
--- /dev/null
+++ b/web-tests/api-checks/make-payload.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# Build a valid /hop/addPipeline request body (PipelineConfiguration XML).
+#
+# ./make-payload.sh <pipeline.hpl> [out.xml]
+#
+# The servlet expects:
+# <pipeline_configuration>
+# <pipeline>...</pipeline> <- pipeline meta (a
.hpl body)
+# <pipeline_execution_configuration>...</...> <- run settings
+# <metastore_json>base64(gzip(json))</metastore_json> <- metadata, incl.
run configuration
+# </pipeline_configuration>
+#
+# metastore_json mirrors Hop's HttpUtil.encodeBase64ZippedString:
base64(gzip(utf8)).
+set -uo pipefail
+
+HPL="${1:-}"
+OUT="${2:-payload.xml}"
+
+if [[ -z "${HPL}" || ! -f "${HPL}" ]]; then
+ echo "usage: make-payload.sh <pipeline.hpl> [out.xml]" >&2
+ exit 1
+fi
+
+# Extract the <pipeline>...</pipeline> element from the .hpl (drops the XML
declaration/licence).
+pipeline_xml="$(awk '/<pipeline>/{f=1} f{print} /<\/pipeline>/{exit}'
"${HPL}")"
+if [[ -z "${pipeline_xml}" ]]; then
+ echo "no <pipeline> element found in ${HPL}" >&2
+ exit 1
+fi
+
+# Ship a 'local' run configuration inline so the target needs no pre-existing
metadata.
+metadata='{"pipeline-run-configuration":[{"engineRunConfiguration":{"Local":{"feedback_size":"50000","sample_size":"100","sample_type_in_gui":"Last","rowset_size":"10000","safe_mode":false,"show_feedback":false,"topo_sort":false,"gather_metrics":false}},"name":"local","configurationVariables":[],"description":"","dataProfile":"","defaultSelection":true}]}'
+
+# base64(gzip(json)) - the encoding Hop's SerializableMetadataProvider reads
back.
+metastore="$(printf '%s' "${metadata}" | gzip -c | base64 | tr -d '\n')"
+
+exec_cfg='<pipeline_execution_configuration>
+<pass_export>N</pass_export>
+<parameters/>
+<variables/>
+<log_level>Basic</log_level>
+<log_file>N</log_file>
+<clear_log>Y</clear_log>
+<run_configuration>local</run_configuration>
+<gather_metrics>N</gather_metrics>
+</pipeline_execution_configuration>'
+
+{
+ echo "<pipeline_configuration>"
+ echo "${pipeline_xml}"
+ echo "${exec_cfg}"
+ echo "<metastore_json>${metastore}</metastore_json>"
+ echo "</pipeline_configuration>"
+} >"${OUT}"
+
+name="$(printf '%s' "${pipeline_xml}" | sed -n
's:.*<name>\(.*\)</name>.*:\1:p' | head -1)"
+echo "${OUT} written ($(wc -c <"${OUT}") bytes), pipeline name: ${name:-?}"
diff --git a/web-tests/api-checks/probe-api.sh
b/web-tests/api-checks/probe-api.sh
new file mode 100755
index 0000000000..86ee7067a4
--- /dev/null
+++ b/web-tests/api-checks/probe-api.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# Probe the Hop Server API surface exposed by a Hop Web deployment.
+#
+# ./probe-api.sh <base-url> [user:password]
+#
+# Reports, for every /hop/* endpoint, whether it is reachable and whether the
+# response is a login redirect / 401 (auth enforced) or real server output.
+set -uo pipefail
+
+BASE="${1:-http://localhost:8080}"
+CREDS="${2:-}"
+AUTH=(); LABEL="anonymous"
+[[ -n "${CREDS}" ]] && { AUTH=(-u "${CREDS}"); LABEL="${CREDS%%:*}"; }
+
+ENDPOINTS=(
+ status addPipeline addWorkflow addExport execPipeline execWorkflow
+ getExecInfo deleteExecInfo registerExecInfo registerPackage
+ registerPipeline registerWorkflow removePipeline removeWorkflow
+ pausePipeline prepareExec startExec startPipeline startWorkflow
+ stopPipeline stopWorkflow sniffTransform pipelineStatus workflowStatus
+ pipelineImage workflowImage webService asyncRun asyncStatus
+)
+
+printf '\n== %s as %s ==\n\n' "${BASE}" "${LABEL}"
+printf '%-18s %-6s %-10s %s\n' ENDPOINT HTTP VERDICT NOTE
+printf '%-18s %-6s %-10s %s\n' ------------------ ------ ---------- ----
+
+for ep in "${ENDPOINTS[@]}"; do
+ body=$(curl -s "${AUTH[@]}" --max-time 10 "${BASE}/hop/${ep}/?xml=Y"
2>/dev/null)
+ code=$(curl -s "${AUTH[@]}" --max-time 10 -o /dev/null -w '%{http_code}' \
+ "${BASE}/hop/${ep}/?xml=Y" 2>/dev/null)
+ note=""
+ case "${code}" in
+ 200|204|500)
+ if grep -qi "hop-login\|<form\|sign in\|password" <<<"${body}"; then
+ verdict="LOGIN"; note="served the login page"
+ else
+ verdict="REACHED"; note="$(head -c 60 <<<"${body}" | tr -d '\n\r' )"
+ fi ;;
+ 301|302|303|307) verdict="REDIRECT"; note="probably -> /login" ;;
+ 401|403) verdict="BLOCKED" ; note="auth enforced" ;;
+ 404) verdict="404" ; note="not registered in this build" ;;
+ 000) verdict="NO-CONN" ; note="server not reachable" ;;
+ *) verdict="?" ; note="" ;;
+ esac
+ printf '%-18s %-6s %-10s %s\n' "${ep}" "${code}" "${verdict}" "${note}"
+done
+echo
diff --git a/web-tests/api-checks/run-matrix.sh
b/web-tests/api-checks/run-matrix.sh
new file mode 100755
index 0000000000..23264cd1f6
--- /dev/null
+++ b/web-tests/api-checks/run-matrix.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# Run the execution test for every demo identity and summarise.
+# ./run-matrix.sh [base-url]
+BASE="${1:-http://localhost:8080}"
+declare -a RESULTS
+for id in "" viewer:viewer operator:operator developer:developer admin:admin;
do
+ label="${id%%:*}"; [[ -z "${id}" ]] && label="anonymous"
+ if ./exec-test.sh "${BASE}" "${id}" >/tmp/m.$$ 2>&1; then
+ RESULTS+=("${label}|EXECUTED")
+ else
+ RESULTS+=("${label}|blocked")
+ fi
+done
+rm -f /tmp/m.$$
+echo
+printf '%-12s %-12s %s\n' IDENTITY 'HOP ROLE' 'CAN RUN A PIPELINE VIA /hop/*?'
+printf '%-12s %-12s %s\n' ------------ ------------
------------------------------
+declare -A ROLE=([anonymous]="-" [viewer]="Read-only" [operator]="Operator"
[developer]="User" [admin]="Admin")
+declare -A UI=([anonymous]="-" [viewer]="NO (no run)" [operator]="yes"
[developer]="yes" [admin]="yes")
+for r in "${RESULTS[@]}"; do
+ who="${r%%|*}"; verdict="${r##*|}"
+ printf '%-12s %-12s %s\n' "${who}" "${ROLE[$who]}" "${verdict} [UI allows
run: ${UI[$who]}]"
+done
+echo
diff --git
a/web-tests/src/test/java/org/apache/hop/web/it/HopServerApiRbacTest.java
b/web-tests/src/test/java/org/apache/hop/web/it/HopServerApiRbacTest.java
new file mode 100644
index 0000000000..387f6e99ca
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopServerApiRbacTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.hop.web.it;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Base64;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.images.PullPolicy;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * API-level integration test for the RBAC guard on the embedded Hop Server
API ({@code /hop/*}) in
+ * Hop Web. Drives the endpoints directly over HTTP (no UI / Selenium): the
authorization decision
+ * is observable purely from the status code — {@code 403} means the request
was refused before the
+ * servlet ran, any other code means it passed authorization.
+ *
+ * <p>Starts its own Hop Web container in mode {@code BASIC} with the demo
users seeded
+ * (admin/developer/operator/viewer, password = username).
+ *
+ * <p>For manual, curl-based exploration of the same endpoints (including from
a browser) see the
+ * scripts under {@code web-tests/api-checks}.
+ *
+ * <p><b>Opt-in.</b> The image under test must contain the RBAC filter (issue
#8150). To avoid
+ * turning the daily job red while the published image predates the fix, this
test only runs when
+ * {@code -Dhopweb.rbac.it=true} is set. Point it at a fixed image with {@code
+ * -Dhopweb.image=<image>} (default {@code hop-web:local}). Once the fix ships
in the published
+ * image, the guard can be removed.
+ */
+@EnabledIfSystemProperty(named = "hopweb.rbac.it", matches = "(?i)true|1|yes")
+class HopServerApiRbacTest {
+
+ private static final int HOP_WEB_PORT = 8080;
+ private static final Duration TIMEOUT = Duration.ofSeconds(20);
+
+ private static GenericContainer<?> container;
+ private static String baseUrl;
+
+ @BeforeAll
+ static void startContainer() {
+ String image = System.getProperty("hopweb.image", "hop-web:local");
+ container =
+ new GenericContainer<>(DockerImageName.parse(image))
+ .withExposedPorts(HOP_WEB_PORT)
+ .withEnv("HOP_WEB_SECURITY_MODE", "BASIC")
+ .withEnv("HOP_WEB_SEED_DEMO_USERS", "true")
+ .withImagePullPolicy(
+ image.endsWith(":local") ? PullPolicy.defaultPolicy() :
PullPolicy.alwaysPull())
+ .waitingFor(Wait.forHttp("/login").forStatusCode(200))
+ .withStartupTimeout(Duration.ofSeconds(120));
+ container.start();
+ baseUrl = "http://" + container.getHost() + ":" +
container.getMappedPort(HOP_WEB_PORT);
+ }
+
+ @AfterAll
+ static void stopContainer() {
+ if (container != null) {
+ container.stop();
+ }
+ }
+
+ // --- helpers -------------------------------------------------------------
+
+ private int get(String path, String user) throws Exception {
+ HttpRequest.Builder b =
+ HttpRequest.newBuilder(URI.create(baseUrl +
path)).timeout(TIMEOUT).GET();
+ return send(b, user);
+ }
+
+ private int send(HttpRequest.Builder builder, String user) throws Exception {
+ if (user != null) {
+ String creds =
+ Base64.getEncoder().encodeToString((user + ":" +
user).getBytes(StandardCharsets.UTF_8));
+ builder.header("Authorization", "Basic " + creds);
+ }
+ // Do not follow the login redirect: a 3xx to /login is itself the
"unauthenticated" signal.
+ HttpResponse<String> response =
+ HttpClient.newBuilder()
+ .connectTimeout(TIMEOUT)
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build()
+ .send(builder.build(), HttpResponse.BodyHandlers.ofString());
+ return response.statusCode();
+ }
+
+ // --- tests ---------------------------------------------------------------
+
+ @Test
+ void readOnlyMayReadStatus() throws Exception {
+ // FILE_VIEW is granted to READ_ONLY.
+ assertEquals(200, get("/hop/status/?xml=Y", "viewer"));
+ }
+
+ @Test
+ void readOnlyMayNotDeployOrRunOrRemove() throws Exception {
+ assertEquals(403, get("/hop/addWorkflow/?xml=Y", "viewer"), "addWorkflow");
+ assertEquals(403, get("/hop/addPipeline/?xml=Y", "viewer"), "addPipeline");
+ assertEquals(403, get("/hop/startPipeline/?xml=Y", "viewer"),
"startPipeline");
+ assertEquals(403, get("/hop/execWorkflow/?xml=Y", "viewer"),
"execWorkflow");
+ assertEquals(403, get("/hop/removePipeline/?xml=Y", "viewer"),
"removePipeline");
+ }
+
+ @Test
+ void operatorMayRunButNotDeploy() throws Exception {
+ // Operator has RUN_EXECUTE but not FILE_SAVE.
+ assertNotEquals(403, get("/hop/startPipeline/?xml=Y", "operator"),
"startPipeline (run)");
+ assertEquals(403, get("/hop/addPipeline/?xml=Y", "operator"), "addPipeline
(deploy)");
+ }
+
+ @Test
+ void developerMayDeploy() throws Exception {
+ // User role has FILE_SAVE; authz passes (any non-403 code means it got
past the filter).
+ assertNotEquals(403, get("/hop/addPipeline/?xml=Y", "developer"));
+ }
+
+ @Test
+ void adminPassesEverywhere() throws Exception {
+ assertNotEquals(403, get("/hop/addWorkflow/?xml=Y", "admin"),
"addWorkflow");
+ assertNotEquals(403, get("/hop/execWorkflow/?xml=Y", "admin"),
"execWorkflow");
+ assertNotEquals(403, get("/hop/removeWorkflow/?xml=Y", "admin"),
"removeWorkflow");
+ }
+
+ @Test
+ void unknownEndpointDeniedEvenForAdmin() throws Exception {
+ assertEquals(403, get("/hop/somethingBrandNew/?xml=Y", "admin"));
+ }
+
+ @Test
+ void unauthenticatedIsRefused() throws Exception {
+ // No credentials: the auth filter redirects browser navigations to /login
(3xx) or
+ // challenges API clients (401). Either way it is not a successful 2xx.
+ int code = get("/hop/status/?xml=Y", null);
+ assertNotEquals(200, code, "anonymous must not reach the server API");
+ }
+}