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 25a7d61a96 Issue #8334 : Allow plugin Hop Server servlets to register
endpoint permissions (#8335)
25a7d61a96 is described below
commit 25a7d61a9606266754275f628d6476065ecc210b
Author: Matt Casters <[email protected]>
AuthorDate: Sun Sep 13 17:22:36 2026 +0200
Issue #8334 : Allow plugin Hop Server servlets to register endpoint
permissions (#8335)
* Issue #8334 : Allow plugin Hop Server servlets to register endpoint
permissions
Plugin /hop/* servlets were default-denied on authenticated Hop Web. Add
@HopServerServlet(requiredPermission), a mapper overlay, and registration on
plugin load.
Also #8333: accept Authorization: Bearer on Hop Web (Hop JDBC HMAC token,
then
IdP JWT) and issue a short-lived token via GET /hop/jdbcToken and File →
Copy
JDBC token.
* Issue #8334 : Eager-init Hop Server servlet and prefix-match plugin paths
Stop the Hop Web catch-22 where plugin /hop/* endpoints stay 403 because
HopServerAuthorizationFilter never reaches HopServerServlet.init(). Load
the Server servlet on startup, and register plugin permissions from
HopWebServletContextListener.
Also prefix-match plugin subpaths (and null-safe pathInfo), cache the JDBC
token HMAC secret, and keep BASIC Bearer requests stateless with
Authorization taking precedence over the session.
* Make HopEnvironment.init reentrant during HopEnvironmentAfterInit
AfterInit fires before the init future is completed. Nested init() or
isInitialized() from that thread waited on the same future and deadlocked
hop-conf / hop-web when a plugin (hopper-edw presentation embed) re-entered
init.
* Issue #8334 : Address review comments on plugin RBAC and Bearer tokens
---
assemblies/web/src/main/resources/WEB-INF/web.xml | 4 +
.../hop/core/security/HopJdbcTokenService.java | 360 +++++++++++++++++++++
.../HopServerEndpointPermissionMapper.java | 123 ++++++-
.../hop/core/security/oidc/HopOidcClient.java | 56 +++-
.../hop/core/security/HopJdbcTokenServiceTest.java | 112 +++++++
.../HopServerEndpointPermissionMapperTest.java | 118 +++++++
.../hop/core/security/oidc/HopOidcClientTest.java | 51 +++
docker/local-auth-config/web.xml | 2 +
.../modules/ROOT/pages/hop-gui/hop-web.adoc | 8 +
.../java/org/apache/hop/core/HopEnvironment.java | 23 +-
.../hop/core/annotations/HopServerServlet.java | 10 +
.../apache/hop/www/HopServerPluginPermissions.java | 101 ++++++
.../java/org/apache/hop/www/HopServerServlet.java | 42 ++-
.../java/org/apache/hop/www/IHopServerPlugin.java | 17 +
.../java/org/apache/hop/www/JdbcTokenServlet.java | 119 +++++++
.../main/java/org/apache/hop/www/WebServer.java | 1 +
.../hop/core/HopEnvironmentReentrantInitTest.java | 101 ++++++
.../hop/www/HopServerPluginPermissionsTest.java | 94 ++++++
.../org/apache/hop/www/HopServerServletTest.java | 63 +++-
.../org/apache/hop/www/JdbcTokenServletTest.java | 94 ++++++
.../ui/hopgui/HopWebServletContextListener.java | 5 +
.../hop/ui/hopgui/security/HopBasicAuthFilter.java | 43 +--
.../hop/ui/hopgui/security/HopBearerSupport.java | 137 ++++++++
.../hop/ui/hopgui/security/HopOidcAuthFilter.java | 15 +-
.../ui/hopgui/security/HopBasicAuthFilterTest.java | 131 ++++++++
.../ui/hopgui/security/HopBearerSupportTest.java | 90 ++++++
.../ui/hopgui/security/HopOidcAuthFilterTest.java | 116 +++++++
.../main/java/org/apache/hop/ui/hopgui/HopGui.java | 58 +++-
.../ui/hopgui/messages/messages_en_US.properties | 9 +
29 files changed, 2057 insertions(+), 46 deletions(-)
diff --git a/assemblies/web/src/main/resources/WEB-INF/web.xml
b/assemblies/web/src/main/resources/WEB-INF/web.xml
index 5e1cff2464..dd8977da7d 100644
--- a/assemblies/web/src/main/resources/WEB-INF/web.xml
+++ b/assemblies/web/src/main/resources/WEB-INF/web.xml
@@ -98,6 +98,10 @@
<servlet>
<servlet-name>Server</servlet-name>
<servlet-class>org.apache.hop.www.HopServerServlet</servlet-class>
+ <!-- Eager init so plugin /hop/* paths are registered with RBAC before
the first request.
+ Otherwise HopServerAuthorizationFilter default-denies unknown
plugin endpoints and
+ never calls chain.doFilter(), so this servlet would never start.
-->
+ <load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>Server</servlet-name>
diff --git
a/core/src/main/java/org/apache/hop/core/security/HopJdbcTokenService.java
b/core/src/main/java/org/apache/hop/core/security/HopJdbcTokenService.java
new file mode 100644
index 0000000000..a4940cc9a9
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/security/HopJdbcTokenService.java
@@ -0,0 +1,360 @@
+/*
+ * 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 com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.MACSigner;
+import com.nimbusds.jose.crypto.MACVerifier;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import java.io.File;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.EnumSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.vfs.HopVfs;
+
+/**
+ * Issues and verifies short-lived HMAC-signed JWTs for JDBC / API clients
talking to Hop Web.
+ *
+ * <p>Hop is not an OAuth2 authorization server. These tokens exist so a user
who already signed in
+ * (BASIC or OIDC) can paste a Bearer credential into a JDBC password field.
Signature key is {@code
+ * HOP_WEB_JDBC_TOKEN_SECRET} or a generated file under the security folder.
+ *
+ * <p>Tokens are not revoked on log off: they stay valid until expiry (default
one hour) with the
+ * roles frozen at issue time. Rotating {@code HOP_WEB_JDBC_TOKEN_SECRET} (or
deleting the secret
+ * file) invalidates every issued token.
+ */
+public final class HopJdbcTokenService {
+
+ public static final String ISSUER = "hop-web";
+ public static final String AUDIENCE = "hop-jdbc";
+ public static final String CLAIM_ROLES = "hop_roles";
+ public static final String ENV_SECRET = "HOP_WEB_JDBC_TOKEN_SECRET";
+ public static final String SECRET_FILENAME = "jdbc-token.secret";
+ public static final Duration DEFAULT_TTL = Duration.ofHours(1);
+
+ private static final SecureRandom RANDOM = new SecureRandom();
+ private static final int SECRET_BYTES = 32;
+
+ private static volatile byte[] secretOverride;
+
+ /** Cached HMAC key loaded from the security folder (not env / test
override). */
+ private static volatile byte[] cachedFileSecret;
+
+ private HopJdbcTokenService() {}
+
+ /**
+ * Issued token plus expiry for JSON / UI.
+ *
+ * @param token compact JWT
+ * @param expiresAt expiry instant
+ */
+ public record IssuedToken(String token, Instant expiresAt) {
+ public long expiresInSeconds() {
+ long seconds = Duration.between(Instant.now(), expiresAt).getSeconds();
+ return Math.max(0L, seconds);
+ }
+ }
+
+ /**
+ * Mint a token for this username and role ids.
+ *
+ * @param username subject
+ * @param roles Hop role ids and/or container role names
+ * @param ttl lifetime
+ * @return signed JWT
+ */
+ public static IssuedToken issue(String username, Collection<String> roles,
Duration ttl)
+ throws JOSEException {
+ if (username == null || username.isBlank()) {
+ throw new IllegalArgumentException("username is required");
+ }
+ Duration lifetime = ttl == null || ttl.isZero() || ttl.isNegative() ?
DEFAULT_TTL : ttl;
+ Instant now = Instant.now();
+ Instant exp = now.plus(lifetime);
+ List<String> roleList = new ArrayList<>();
+ if (roles != null) {
+ for (String role : roles) {
+ if (role != null && !role.isBlank()) {
+ roleList.add(role.trim());
+ }
+ }
+ }
+ JWTClaimsSet claims =
+ new JWTClaimsSet.Builder()
+ .issuer(ISSUER)
+ .audience(AUDIENCE)
+ .subject(username.trim())
+ .issueTime(Date.from(now))
+ .expirationTime(Date.from(exp))
+ .claim(CLAIM_ROLES, roleList)
+ .build();
+ SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims);
+ jwt.sign(new MACSigner(loadSecret()));
+ return new IssuedToken(jwt.serialize(), exp);
+ }
+
+ /**
+ * Verify signature, issuer, audience and expiry. Throws if the token is not
a Hop JDBC token.
+ *
+ * @param token compact JWT
+ * @return claims
+ */
+ public static JWTClaimsSet verify(String token) throws Exception {
+ if (token == null || token.isBlank()) {
+ throw new IllegalArgumentException("token is empty");
+ }
+ SignedJWT jwt = SignedJWT.parse(token.trim());
+ if (!jwt.verify(new MACVerifier(loadSecret()))) {
+ throw new JOSEException("Hop JDBC token signature is invalid");
+ }
+ JWTClaimsSet claims = jwt.getJWTClaimsSet();
+ if (claims.getExpirationTime() == null ||
claims.getExpirationTime().before(new Date())) {
+ throw new JOSEException("Hop JDBC token has expired");
+ }
+ if (!ISSUER.equals(claims.getIssuer())) {
+ throw new JOSEException("Hop JDBC token issuer mismatch");
+ }
+ List<String> aud = claims.getAudience();
+ if (aud == null || !aud.contains(AUDIENCE)) {
+ throw new JOSEException("Hop JDBC token audience mismatch");
+ }
+ if (claims.getSubject() == null || claims.getSubject().isBlank()) {
+ throw new JOSEException("Hop JDBC token has no subject");
+ }
+ return claims;
+ }
+
+ /**
+ * Whether {@code token} looks like and verifies as a Hop JDBC token.
Invalid signatures return
+ * false rather than throwing so callers can fall through to IdP JWT
validation.
+ *
+ * @param token compact JWT
+ * @return true when verify succeeds
+ */
+ public static boolean isHopJdbcToken(String token) {
+ try {
+ verify(token);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * Role names stored in the token (may be empty).
+ *
+ * @param claims verified claims
+ * @return role names
+ */
+ @SuppressWarnings("unchecked")
+ public static Set<String> roleNames(JWTClaimsSet claims) {
+ Set<String> roles = new LinkedHashSet<>();
+ if (claims == null) {
+ return roles;
+ }
+ Object raw = claims.getClaim(CLAIM_ROLES);
+ if (raw instanceof Collection<?> collection) {
+ for (Object item : collection) {
+ if (item != null && !String.valueOf(item).isBlank()) {
+ roles.add(String.valueOf(item).trim());
+ }
+ }
+ }
+ return roles;
+ }
+
+ /** Visible for tests: pin the HMAC secret so tests do not touch the config
folder. */
+ static void overrideSecretForTests(byte[] secret) {
+ if (secret != null && secret.length < SECRET_BYTES) {
+ throw new IllegalArgumentException("HMAC secret must be at least " +
SECRET_BYTES + " bytes");
+ }
+ secretOverride = secret;
+ cachedFileSecret = null;
+ }
+
+ private static byte[] loadSecret() {
+ byte[] override = secretOverride;
+ if (override != null) {
+ return override;
+ }
+ String env = firstNonBlank(System.getenv(ENV_SECRET),
System.getProperty(ENV_SECRET));
+ if (env != null) {
+ byte[] decoded = decodeSecret(env);
+ if (decoded.length >= SECRET_BYTES) {
+ return decoded;
+ }
+ // Treat a passphrase as UTF-8 and reject if too short after encoding.
+ decoded = env.getBytes(StandardCharsets.UTF_8);
+ if (decoded.length >= SECRET_BYTES) {
+ return decoded;
+ }
+ throw new IllegalStateException(
+ ENV_SECRET + " must be at least " + SECRET_BYTES + " bytes (or
base64 of that)");
+ }
+ byte[] cached = cachedFileSecret;
+ if (cached != null) {
+ return cached;
+ }
+ synchronized (HopJdbcTokenService.class) {
+ if (cachedFileSecret != null) {
+ return cachedFileSecret;
+ }
+ cachedFileSecret = loadOrCreateFileSecret();
+ return cachedFileSecret;
+ }
+ }
+
+ private static byte[] loadOrCreateFileSecret() {
+ String path =
+ Const.HOP_CONFIG_FOLDER
+ + Const.FILE_SEPARATOR
+ + HopSecurityConfig.SECURITY_FOLDER
+ + Const.FILE_SEPARATOR
+ + SECRET_FILENAME;
+ try {
+ if (HopVfs.fileExists(path)) {
+ try (InputStream in = HopVfs.getInputStream(path)) {
+ String text = new String(in.readAllBytes(),
StandardCharsets.UTF_8).trim();
+ byte[] decoded = decodeSecret(text);
+ if (decoded.length >= SECRET_BYTES) {
+ restrictOrWarnSecretFile(path);
+ return decoded;
+ }
+ }
+ }
+ byte[] generated = new byte[SECRET_BYTES];
+ RANDOM.nextBytes(generated);
+ String folder =
+ Const.HOP_CONFIG_FOLDER + Const.FILE_SEPARATOR +
HopSecurityConfig.SECURITY_FOLDER;
+ var folderObject = HopVfs.getFileObject(folder);
+ if (!folderObject.exists()) {
+ folderObject.createFolder();
+ }
+ String encoded = Base64.getEncoder().encodeToString(generated);
+ try (OutputStream out = HopVfs.getOutputStream(path, false)) {
+ out.write(encoded.getBytes(StandardCharsets.UTF_8));
+ }
+ restrictOrWarnSecretFile(path);
+ LogChannel.GENERAL.logBasic("Created Hop JDBC token secret at '" + path
+ "'");
+ return generated;
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ "Unable to load or create JDBC token secret at '" + path + "'", e);
+ }
+ }
+
+ /**
+ * Owner-only (0600) on a local secret file. Anyone who can read this file
can mint tokens for any
+ * user, so group/world readability is logged as an error when it cannot be
stripped.
+ */
+ static void restrictOrWarnSecretFile(String vfsPath) {
+ try {
+ FileObject fileObject = HopVfs.getFileObject(vfsPath);
+ URI uri = fileObject.getURI();
+ if (uri == null || !"file".equalsIgnoreCase(uri.getScheme())) {
+ return;
+ }
+ restrictOrWarnLocalPath(Path.of(uri));
+ } catch (Exception e) {
+ LogChannel.GENERAL.logError(
+ "Could not inspect permissions on JDBC token secret '" + vfsPath +
"'", e);
+ }
+ }
+
+ static void restrictOrWarnLocalPath(Path path) {
+ if (path == null || !Files.exists(path)) {
+ return;
+ }
+ Set<PosixFilePermission> ownerOnly =
+ EnumSet.of(PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE);
+ try {
+ Files.setPosixFilePermissions(path, ownerOnly);
+ } catch (UnsupportedOperationException e) {
+ File file = path.toFile();
+ file.setReadable(false, false);
+ file.setWritable(false, false);
+ file.setExecutable(false, false);
+ file.setReadable(true, true);
+ file.setWritable(true, true);
+ return;
+ } catch (Exception e) {
+ LogChannel.GENERAL.logError(
+ "Could not set owner-only permissions on JDBC token secret '" + path
+ "'", e);
+ }
+ try {
+ Set<PosixFilePermission> actual = Files.getPosixFilePermissions(path);
+ boolean shared =
+ actual.stream()
+ .anyMatch(
+ permission ->
+ permission != PosixFilePermission.OWNER_READ
+ && permission != PosixFilePermission.OWNER_WRITE
+ && permission != PosixFilePermission.OWNER_EXECUTE);
+ if (shared) {
+ LogChannel.GENERAL.logError(
+ "JDBC token secret '"
+ + path
+ + "' is group- or world-readable. Anyone who can read this
file can mint tokens"
+ + " for any user. Set permissions to 0600.");
+ }
+ } catch (UnsupportedOperationException ignored) {
+ // Non-POSIX filesystem after a best-effort chmod above.
+ } catch (Exception e) {
+ LogChannel.GENERAL.logError(
+ "Could not read permissions on JDBC token secret '" + path + "'", e);
+ }
+ }
+
+ private static byte[] decodeSecret(String text) {
+ try {
+ return Base64.getDecoder().decode(text);
+ } catch (IllegalArgumentException e) {
+ return text.getBytes(StandardCharsets.UTF_8);
+ }
+ }
+
+ private static String firstNonBlank(String a, String b) {
+ if (a != null && !a.isBlank()) {
+ return a.trim();
+ }
+ if (b != null && !b.isBlank()) {
+ return b.trim();
+ }
+ return null;
+ }
+}
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
index c24060fd1c..a948cbecd1 100644
---
a/core/src/main/java/org/apache/hop/core/security/HopServerEndpointPermissionMapper.java
+++
b/core/src/main/java/org/apache/hop/core/security/HopServerEndpointPermissionMapper.java
@@ -20,6 +20,7 @@ package org.apache.hop.core.security;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
/**
* Maps Hop Server servlet paths ({@code /hop/*}) to the {@link Permission}
required to call them.
@@ -31,7 +32,8 @@ import java.util.Optional;
* <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.
+ * silently widen the authenticated attack surface. Plugin servlets opt in
with {@link
+ * #register(String, Permission)} (typically from {@code
@HopServerServlet(requiredPermission)}).
*
* <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.
@@ -50,10 +52,111 @@ public final class HopServerEndpointPermissionMapper {
private static final Map<String, Permission> API_READ_PERMISSIONS =
buildApiReadTable();
private static final Map<String, Permission> API_WRITE_PERMISSIONS =
buildApiWriteTable();
+ /**
+ * Plugin servlet overlay. Concurrent so plugin load/unload from the servlet
container thread is
+ * safe. Lookup takes the longest prefix across this map and the built-in
table; a built-in wins
+ * only when the matching keys are the same length.
+ */
+ private static final ConcurrentHashMap<String, Permission>
PLUGIN_PERMISSIONS =
+ new ConcurrentHashMap<>();
+
private HopServerEndpointPermissionMapper() {
// utility
}
+ /**
+ * Register a plugin Hop Server path for Hop Web RBAC. Empty/unknown paths
stay default-deny.
+ *
+ * <p>Idempotent when the same path is registered with the same permission
(including when that
+ * path is already a built-in). Refuses to replace a built-in path with a
different permission, to
+ * register {@code /hop} itself, to register under {@value #API_PREFIX}, or
to register a path
+ * nested under a built-in prefix (so a plugin cannot inherit {@code
/hop/status}'s {@code
+ * file.view} while declaring a stronger permission).
+ *
+ * @param path servlet context path, e.g. {@code /hop/sourceModelData}
+ * @param permission required permission
+ * @throws IllegalArgumentException when the path or permission is invalid
or would shadow a
+ * built-in endpoint
+ */
+ public static void register(String path, Permission permission) {
+ String normalized = requirePluginPath(path);
+ if (permission == null) {
+ throw new IllegalArgumentException("Permission is required to register "
+ normalized);
+ }
+ Permission builtIn = ENDPOINT_PERMISSIONS.get(normalized);
+ if (builtIn != null) {
+ if (builtIn != permission) {
+ throw new IllegalArgumentException(
+ "Cannot overwrite built-in Hop Server endpoint '"
+ + normalized
+ + "' ("
+ + builtIn.getId()
+ + ") with "
+ + permission.getId());
+ }
+ return;
+ }
+ PLUGIN_PERMISSIONS.put(normalized, permission);
+ }
+
+ /**
+ * Register using a {@link Permission} id ({@code run.execute}, {@code
file.view}, …). Convenience
+ * for plugin reflection against this class.
+ *
+ * @param path servlet context path
+ * @param permissionId permission id
+ */
+ public static void register(String path, String permissionId) {
+ register(path, Permission.fromId(permissionId));
+ }
+
+ /**
+ * Drop a plugin overlay entry. Built-in paths are unaffected.
+ *
+ * @param path servlet context path
+ */
+ public static void unregister(String path) {
+ String normalized = normalize(path);
+ if (normalized != null) {
+ PLUGIN_PERMISSIONS.remove(normalized);
+ }
+ }
+
+ /** Visible for tests: drop every plugin overlay entry. */
+ static void clearPluginRegistrations() {
+ PLUGIN_PERMISSIONS.clear();
+ }
+
+ /**
+ * Visible for tests: insert an overlay entry without {@link
#register(String, Permission)}
+ * guards, so lookup can be asserted when a nested path is already in the
map.
+ */
+ static void putPluginRegistrationUnchecked(String path, Permission
permission) {
+ PLUGIN_PERMISSIONS.put(normalize(path), permission);
+ }
+
+ private static String requirePluginPath(String path) {
+ String normalized = normalize(path);
+ if (normalized == null) {
+ throw new IllegalArgumentException("Plugin endpoint path is empty");
+ }
+ if (!normalized.startsWith("/hop/") || "/hop".equals(normalized)) {
+ throw new IllegalArgumentException(
+ "Plugin endpoints must be a /hop/<name> path, got '" + path + "'");
+ }
+ if (normalized.equals(API_PREFIX) || normalized.startsWith(API_PREFIX +
"/")) {
+ throw new IllegalArgumentException(
+ "Plugin endpoints cannot register under the JSON API prefix " +
API_PREFIX);
+ }
+ for (String builtIn : ENDPOINT_PERMISSIONS.keySet()) {
+ if (normalized.startsWith(builtIn + "/")) {
+ throw new IllegalArgumentException(
+ "Plugin endpoint '" + normalized + "' is nested under built-in '"
+ builtIn + "'");
+ }
+ }
+ return normalized;
+ }
+
private static Map<String, Permission> buildTable() {
Map<String, Permission> map = new LinkedHashMap<>();
@@ -90,6 +193,8 @@ public final class HopServerEndpointPermissionMapper {
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);
+ // Short-lived HMAC JWT for JDBC / API clients (session already
authenticated).
+ map.put("/hop/jdbcToken", Permission.FILE_VIEW);
// --- Control a running execution: RUN_STOP ---
map.put("/hop/stopPipeline", Permission.RUN_STOP);
@@ -164,11 +269,23 @@ public final class HopServerEndpointPermissionMapper {
isReadMethod(method) ? API_READ_PERMISSIONS : API_WRITE_PERMISSIONS;
return longestMatch(table, normalized);
}
- return longestMatch(ENDPOINT_PERMISSIONS, normalized);
+ PrefixMatch builtIn = longestPrefix(ENDPOINT_PERMISSIONS, normalized);
+ PrefixMatch plugin = longestPrefix(PLUGIN_PERMISSIONS, normalized);
+ if (plugin != null && (builtIn == null || plugin.length() >
builtIn.length())) {
+ return Optional.of(plugin.permission());
+ }
+ return builtIn == null ? Optional.empty() :
Optional.of(builtIn.permission());
}
+ private record PrefixMatch(Permission permission, int length) {}
+
private static Optional<Permission> longestMatch(
Map<String, Permission> table, String normalized) {
+ PrefixMatch match = longestPrefix(table, normalized);
+ return match == null ? Optional.empty() : Optional.of(match.permission());
+ }
+
+ private static PrefixMatch longestPrefix(Map<String, Permission> table,
String normalized) {
Permission best = null;
int bestLen = -1;
for (Map.Entry<String, Permission> entry : table.entrySet()) {
@@ -178,7 +295,7 @@ public final class HopServerEndpointPermissionMapper {
bestLen = key.length();
}
}
- return Optional.ofNullable(best);
+ return best == null ? null : new PrefixMatch(best, bestLen);
}
/** Only GET and HEAD are reads; anything else (including an unknown verb)
is a mutation. */
diff --git
a/core/src/main/java/org/apache/hop/core/security/oidc/HopOidcClient.java
b/core/src/main/java/org/apache/hop/core/security/oidc/HopOidcClient.java
index 68e75be875..cbcbb50880 100644
--- a/core/src/main/java/org/apache/hop/core/security/oidc/HopOidcClient.java
+++ b/core/src/main/java/org/apache/hop/core/security/oidc/HopOidcClient.java
@@ -29,7 +29,6 @@ import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.net.URI;
-import java.net.URL;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -62,6 +61,8 @@ public final class HopOidcClient {
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(20);
private static final Map<String, OidcDiscoveryDocument> DISCOVERY_CACHE =
new ConcurrentHashMap<>();
+ private static final Map<String, JWKSource<SecurityContext>> JWKS_CACHE =
+ new ConcurrentHashMap<>();
private final HopSecurityConfig config;
private final HttpClient httpClient;
@@ -89,9 +90,10 @@ public final class HopOidcClient {
});
}
- /** Drop cached discovery (after config change). */
+ /** Drop cached discovery and JWKS sources (after config change). */
public static void clearDiscoveryCache() {
DISCOVERY_CACHE.clear();
+ JWKS_CACHE.clear();
}
private OidcDiscoveryDocument fetchDiscovery(String issuer) throws Exception
{
@@ -172,8 +174,8 @@ public final class HopOidcClient {
}
/**
- * Validate ID token signature (JWKS) and return claims. Validates issuer
when present in
- * discovery.
+ * Validate ID token signature (JWKS) and return claims. Issuer and audience
are required: bearer
+ * tokens are request-supplied, so a missing claim must not skip the check.
*/
public JWTClaimsSet validateIdToken(String idToken, String expectedNonce)
throws Exception {
OidcDiscoveryDocument doc = getDiscovery();
@@ -181,7 +183,7 @@ public final class HopOidcClient {
throw new IllegalStateException("OIDC discovery missing jwks_uri");
}
ConfigurableJWTProcessor<SecurityContext> processor = new
DefaultJWTProcessor<>();
- JWKSource<SecurityContext> keySource = new RemoteJWKSet<>(new
URL(doc.getJwksUri()));
+ JWKSource<SecurityContext> keySource = jwkSource(doc.getJwksUri());
Set<JWSAlgorithm> algs =
Set.of(
JWSAlgorithm.RS256,
@@ -196,11 +198,39 @@ public final class HopOidcClient {
JWSKeySelector<SecurityContext> keySelector = new
JWSVerificationKeySelector<>(algs, keySource);
processor.setJWSKeySelector(keySelector);
JWTClaimsSet claims = processor.process(idToken, null);
+ validateIdTokenClaims(claims, expectedNonce);
+ return claims;
+ }
+
+ /**
+ * Cached {@link RemoteJWKSet} per {@code jwks_uri}. A new source on every
call would discard
+ * Nimbus' JWKS cache and let unauthenticated Bearer traffic force an
outbound fetch per request.
+ */
+ static JWKSource<SecurityContext> jwkSource(String jwksUri) {
+ if (jwksUri == null || jwksUri.isBlank()) {
+ throw new IllegalStateException("OIDC discovery missing jwks_uri");
+ }
+ return JWKS_CACHE.computeIfAbsent(
+ jwksUri,
+ uri -> {
+ try {
+ return new RemoteJWKSet<>(URI.create(uri).toURL());
+ } catch (Exception e) {
+ throw new IllegalStateException("Invalid jwks_uri: " + uri, e);
+ }
+ });
+ }
+ /**
+ * Issuer, audience, and optional nonce. Package-visible so tests can cover
the claim checks
+ * without a JWKS endpoint.
+ */
+ void validateIdTokenClaims(JWTClaimsSet claims, String expectedNonce) {
String issuer = trimTrailingSlash(config.getOauthIssuerUrl());
- if (claims.getIssuer() != null
- && issuer != null
- && !issuer.equals(trimTrailingSlash(claims.getIssuer()))) {
+ if (claims.getIssuer() == null || claims.getIssuer().isBlank()) {
+ throw new IllegalStateException("ID token missing issuer");
+ }
+ if (issuer != null &&
!issuer.equals(trimTrailingSlash(claims.getIssuer()))) {
throw new IllegalStateException(
"ID token issuer mismatch: " + claims.getIssuer() + " vs " + issuer);
}
@@ -210,15 +240,15 @@ public final class HopOidcClient {
throw new IllegalStateException("ID token nonce mismatch");
}
}
- // Audience: must include our client id when present
List<String> aud = claims.getAudience();
- if (aud != null
- && !aud.isEmpty()
- && config.getOauthClientId() != null
+ if (aud == null || aud.isEmpty()) {
+ throw new IllegalStateException("ID token missing audience");
+ }
+ if (config.getOauthClientId() != null
+ && !config.getOauthClientId().isBlank()
&& !aud.contains(config.getOauthClientId())) {
throw new IllegalStateException("ID token audience does not include
client_id");
}
- return claims;
}
public HopSecurityContext toSecurityContext(JWTClaimsSet claims) {
diff --git
a/core/src/test/java/org/apache/hop/core/security/HopJdbcTokenServiceTest.java
b/core/src/test/java/org/apache/hop/core/security/HopJdbcTokenServiceTest.java
new file mode 100644
index 0000000000..18e802fffb
--- /dev/null
+++
b/core/src/test/java/org/apache/hop/core/security/HopJdbcTokenServiceTest.java
@@ -0,0 +1,112 @@
+/*
+ * 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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class HopJdbcTokenServiceTest {
+
+ @BeforeEach
+ void pinSecret() {
+ byte[] secret = new byte[32];
+ new SecureRandom().nextBytes(secret);
+ HopJdbcTokenService.overrideSecretForTests(secret);
+ }
+
+ @AfterEach
+ void clearSecret() {
+ HopJdbcTokenService.overrideSecretForTests(null);
+ }
+
+ @Test
+ void roundTripPreservesSubjectAndRoles() throws Exception {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue(
+ "[email protected]", List.of("admin", "hop-admin"),
Duration.ofMinutes(5));
+ JWTClaimsSet claims = HopJdbcTokenService.verify(issued.token());
+ assertEquals("[email protected]", claims.getSubject());
+ assertEquals(HopJdbcTokenService.ISSUER, claims.getIssuer());
+ assertTrue(claims.getAudience().contains(HopJdbcTokenService.AUDIENCE));
+ assertEquals(Set.of("admin", "hop-admin"),
HopJdbcTokenService.roleNames(claims));
+ assertTrue(issued.expiresInSeconds() > 0);
+ assertTrue(HopJdbcTokenService.isHopJdbcToken(issued.token()));
+ }
+
+ @Test
+ void expiredTokenIsRejected() throws Exception {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue("user", List.of("user"),
Duration.ofMillis(1));
+ Thread.sleep(20);
+ assertThrows(Exception.class, () ->
HopJdbcTokenService.verify(issued.token()));
+ assertFalse(HopJdbcTokenService.isHopJdbcToken(issued.token()));
+ }
+
+ @Test
+ void randomJwtIsNotAHopToken() {
+ assertFalse(HopJdbcTokenService.isHopJdbcToken("not-a-jwt"));
+ assertFalse(HopJdbcTokenService.isHopJdbcToken("a.b.c"));
+ }
+
+ @Test
+ void verifyCanBeRepeatedWithoutReloadingTheSecret() throws Exception {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue("user", List.of("user"),
Duration.ofMinutes(5));
+ HopJdbcTokenService.verify(issued.token());
+ JWTClaimsSet again = HopJdbcTokenService.verify(issued.token());
+ assertEquals("user", again.getSubject());
+ }
+
+ @Test
+ void blankUsernameIsRejected() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> HopJdbcTokenService.issue(" ", List.of("user"),
Duration.ofMinutes(1)));
+ }
+
+ @Test
+ void secretFilePermissionsAreOwnerOnly(@TempDir Path tmp) throws Exception {
+ assumeTrue(
+
FileSystems.getDefault().supportedFileAttributeViews().contains("posix"),
+ "POSIX file permissions are not supported on this platform");
+ Path secret = tmp.resolve("jdbc-token.secret");
+ Files.writeString(secret, "not-a-secret");
+ Files.setPosixFilePermissions(secret,
PosixFilePermissions.fromString("rw-r--r--"));
+ HopJdbcTokenService.restrictOrWarnLocalPath(secret);
+ assertEquals(
+ Set.of(PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE),
+ Files.getPosixFilePermissions(secret));
+ }
+}
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
index b1a05f54ca..c1de4b3bda 100644
---
a/core/src/test/java/org/apache/hop/core/security/HopServerEndpointPermissionMapperTest.java
+++
b/core/src/test/java/org/apache/hop/core/security/HopServerEndpointPermissionMapperTest.java
@@ -19,13 +19,20 @@ 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class HopServerEndpointPermissionMapperTest {
+ @AfterEach
+ void clearPluginOverlay() {
+ HopServerEndpointPermissionMapper.clearPluginRegistrations();
+ }
+
@Test
void readEndpointsRequireFileView() {
for (String path :
@@ -321,4 +328,115 @@ class HopServerEndpointPermissionMapperTest {
void unmappedApiEndpointsStayUnknown() {
assertFalse(HopServerEndpointPermissionMapper.isKnownEndpoint("/hop/api/v1/nope"));
}
+
+ @Test
+ void jdbcTokenIsARead() {
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/jdbcToken"));
+ }
+
+ @Test
+ void pluginOverlayIsConsultedWhenNotBuiltIn() {
+ HopServerEndpointPermissionMapper.register("/hop/sourceModelData",
Permission.RUN_EXECUTE);
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/sourceModelData"));
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+ HopServerEndpointPermissionMapper.requiredPermission("POST",
"/hop/sourceModelData"));
+
assertTrue(HopServerEndpointPermissionMapper.isKnownEndpoint("/hop/sourceModelData"));
+ }
+
+ @Test
+ void pluginOverlayAcceptsPermissionId() {
+ HopServerEndpointPermissionMapper.register("/hop/sourceModelData",
"run.execute");
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/sourceModelData"));
+ }
+
+ @Test
+ void unregisterDropsTheOverlay() {
+ HopServerEndpointPermissionMapper.register("/hop/sourceModelData",
Permission.RUN_EXECUTE);
+ HopServerEndpointPermissionMapper.unregister("/hop/sourceModelData");
+ assertTrue(
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/sourceModelData").isEmpty());
+
assertFalse(HopServerEndpointPermissionMapper.isKnownEndpoint("/hop/sourceModelData"));
+ }
+
+ @Test
+ void overlayDoesNotReplaceABuiltInPathWithADifferentPermission() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ HopServerEndpointPermissionMapper.register("/hop/startPipeline",
Permission.FILE_VIEW));
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/startPipeline"));
+ }
+
+ @Test
+ void overlayIsIdempotentForTheSameBuiltInPermission() {
+ HopServerEndpointPermissionMapper.register("/hop/jdbcToken",
Permission.FILE_VIEW);
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/jdbcToken"));
+ }
+
+ @Test
+ void overlayRefusesTheJsonApiPrefixAndBareHop() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> HopServerEndpointPermissionMapper.register("/hop",
Permission.FILE_VIEW));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ HopServerEndpointPermissionMapper.register(
+ "/hop/api/v1/metadata", Permission.METADATA_READ));
+ }
+
+ @Test
+ void builtInPathsStillWinOverAShorterPluginPrefix() {
+ HopServerEndpointPermissionMapper.register("/hop/sourceModelData",
Permission.RUN_EXECUTE);
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+ HopServerEndpointPermissionMapper.requiredPermission("/hop/status"));
+ }
+
+ @Test
+ void pluginCannotRegisterUnderABuiltInPrefix() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ HopServerEndpointPermissionMapper.register(
+ "/hop/status/custom", Permission.RUN_EXECUTE));
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/status/custom"));
+ }
+
+ @Test
+ void longerPluginPrefixWinsOverAShorterBuiltIn() {
+ HopServerEndpointPermissionMapper.putPluginRegistrationUnchecked(
+ "/hop/status/custom", Permission.RUN_EXECUTE);
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/status/custom"));
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+ HopServerEndpointPermissionMapper.requiredPermission("/hop/status"));
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/status/other"));
+ }
+
+ @Test
+ void equalLengthKeysPreferTheBuiltIn() {
+ HopServerEndpointPermissionMapper.putPluginRegistrationUnchecked(
+ "/hop/status", Permission.RUN_EXECUTE);
+ assertEquals(
+ Optional.of(Permission.FILE_VIEW),
+ HopServerEndpointPermissionMapper.requiredPermission("/hop/status"));
+ }
}
diff --git
a/core/src/test/java/org/apache/hop/core/security/oidc/HopOidcClientTest.java
b/core/src/test/java/org/apache/hop/core/security/oidc/HopOidcClientTest.java
index b683f832ad..45a9dc105b 100644
---
a/core/src/test/java/org/apache/hop/core/security/oidc/HopOidcClientTest.java
+++
b/core/src/test/java/org/apache/hop/core/security/oidc/HopOidcClientTest.java
@@ -18,8 +18,13 @@
package org.apache.hop.core.security.oidc;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import java.util.List;
import java.util.Map;
@@ -83,4 +88,50 @@ class HopOidcClientTest {
assertTrue(ctx.allows(Permission.RUN_EXECUTE));
assertTrue(!ctx.allows(Permission.FILE_SAVE));
}
+
+ @Test
+ void idTokenClaimsRequireIssuerAndAudience() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setOauthIssuerUrl("https://issuer.example");
+ config.setOauthClientId("hop-web");
+ HopOidcClient client = new HopOidcClient(config);
+
+ JWTClaimsSet missingIssuer =
+ new
JWTClaimsSet.Builder().audience("hop-web").subject("sub-1").build();
+ assertThrows(
+ IllegalStateException.class, () ->
client.validateIdTokenClaims(missingIssuer, null));
+
+ JWTClaimsSet missingAudience =
+ new
JWTClaimsSet.Builder().issuer("https://issuer.example").subject("sub-1").build();
+ assertThrows(
+ IllegalStateException.class, () ->
client.validateIdTokenClaims(missingAudience, null));
+
+ JWTClaimsSet wrongAudience =
+ new JWTClaimsSet.Builder()
+ .issuer("https://issuer.example")
+ .audience("someone-else")
+ .subject("sub-1")
+ .build();
+ assertThrows(
+ IllegalStateException.class, () ->
client.validateIdTokenClaims(wrongAudience, null));
+
+ JWTClaimsSet valid =
+ new JWTClaimsSet.Builder()
+ .issuer("https://issuer.example")
+ .audience("hop-web")
+ .subject("sub-1")
+ .build();
+ client.validateIdTokenClaims(valid, null);
+ }
+
+ @Test
+ void jwkSourceIsCachedPerUri() {
+ JWKSource<SecurityContext> first =
HopOidcClient.jwkSource("https://issuer.example/jwks");
+ JWKSource<SecurityContext> second =
HopOidcClient.jwkSource("https://issuer.example/jwks");
+ assertSame(first, second);
+ HopOidcClient.clearDiscoveryCache();
+ JWKSource<SecurityContext> third =
HopOidcClient.jwkSource("https://issuer.example/jwks");
+ assertNotSame(first, third);
+ HopOidcClient.clearDiscoveryCache();
+ }
}
diff --git a/docker/local-auth-config/web.xml b/docker/local-auth-config/web.xml
index 6200ecfb64..3f113a4417 100644
--- a/docker/local-auth-config/web.xml
+++ b/docker/local-auth-config/web.xml
@@ -95,6 +95,8 @@
<servlet>
<servlet-name>Server</servlet-name>
<servlet-class>org.apache.hop.www.HopServerServlet</servlet-class>
+ <!-- Eager init so plugin /hop/* paths are registered with RBAC before
the first request. -->
+ <load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>Server</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 5ded8b34d6..24f1c19931 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
@@ -510,6 +510,14 @@ After sign-in, your session should include the *Admin*
role (`security.manage`)
Other users without a mapping fall back to the *User* role.
====
+=== JDBC and API Bearer tokens
+
+JDBC drivers and `curl` cannot complete an OIDC browser login. After you sign
in to Hop Web (BASIC or OAUTH2), use *File → Copy JDBC token* or `GET
/hop/jdbcToken` (`file.view`) to mint a short-lived HMAC JWT (`aud=hop-jdbc`).
Send it as `Authorization: Bearer <token>`.
+
+IMPORTANT: These tokens are not revoked when you log off or when an
administrator changes the user's roles. They stay valid until expiry (about one
hour) with the roles frozen at issue time. The only way to invalidate
outstanding tokens is to rotate `HOP_WEB_JDBC_TOKEN_SECRET` (or delete
`HOP_CONFIG_FOLDER/security/jdbc-token.secret`), which invalidates *every*
issued token.
+
+Hop creates `jdbc-token.secret` with owner-only permissions (`0600`) on local
filesystems when it can. Anyone who can read that file can mint tokens for any
user.
+
==== Full web.xml with Tomcat BASIC (single role, EXTERNAL)
The following sample `web.xml` extends Hop Web's default `web.xml` with the
`<security-constraint />` and `<login-config />` elements required for basic
authentication.
diff --git a/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
b/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
index e6979bde47..dbe3cf1969 100644
--- a/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
+++ b/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
@@ -71,6 +71,14 @@ public class HopEnvironment {
/** Indicates whether the Hop environment has been initialized. */
private static AtomicReference<SettableFuture<Boolean>> initialized = new
AtomicReference<>(null);
+ /**
+ * Set on the thread that is executing {@link #init(List)} until that call
completes. Nested
+ * {@code init()} / {@link #isInitialized()} from {@code
HopEnvironmentAfterInit} (or anything it
+ * invokes) must not wait on {@code initialized}'s future: that future is
only completed after
+ * AfterInit returns, so waiting deadlocks the same thread.
+ */
+ private static final ThreadLocal<Boolean> initializing = new ThreadLocal<>();
+
/**
* Initializes the Hop environment. This method performs the following
operations:
*
@@ -115,7 +123,7 @@ public class HopEnvironment {
SettableFuture<Boolean> ready;
if (initialized.compareAndSet(null, ready = SettableFuture.create())) {
-
+ initializing.set(true);
// Swaps out System Properties for a thread safe version.
// This is not that important since we're no longer using System
properties
// However, plugins might still make use of it so keep it around
@@ -184,9 +192,16 @@ public class HopEnvironment {
ready.setException(t);
// If it's a HopException, throw it, otherwise wrap it in a
HopException
throw ((t instanceof HopException hopException) ? hopException : new
HopException(t));
+ } finally {
+ initializing.remove();
}
} else {
+ // Same thread is already inside init() (typically
HopEnvironmentAfterInit). Waiting on the
+ // future would deadlock: it is only completed after AfterInit returns.
+ if (Boolean.TRUE.equals(initializing.get())) {
+ return;
+ }
// A different thread is initializing
ready = initialized.get();
// Block until environment is initialized
@@ -226,9 +241,13 @@ public class HopEnvironment {
* @return true if initialized, false otherwise
*/
public static boolean isInitialized() {
+ // AfterInit runs before the init future is completed. Waiting here from
that thread deadlocks.
+ if (Boolean.TRUE.equals(initializing.get())) {
+ return true;
+ }
Future<Boolean> future = initialized.get();
try {
- return future != null && future.get();
+ return future != null && Boolean.TRUE.equals(future.get());
} catch (Throwable e) {
return false;
}
diff --git
a/engine/src/main/java/org/apache/hop/core/annotations/HopServerServlet.java
b/engine/src/main/java/org/apache/hop/core/annotations/HopServerServlet.java
index 701e7148c9..99d57f0791 100644
--- a/engine/src/main/java/org/apache/hop/core/annotations/HopServerServlet.java
+++ b/engine/src/main/java/org/apache/hop/core/annotations/HopServerServlet.java
@@ -48,4 +48,14 @@ public @interface HopServerServlet {
boolean isSeparateClassLoaderNeeded() default false;
String classLoaderGroup() default "";
+
+ /**
+ * Hop Web RBAC permission id for this servlet (e.g. {@code run.execute},
{@code file.view}).
+ * Empty (default) means the endpoint stays unknown and is default-denied by
{@code
+ * HopServerEndpointPermissionMapper} until something calls {@code
register}. Built-in servlets
+ * are already in that table; plugin servlets should set this.
+ *
+ * @return permission id, or empty
+ */
+ String requiredPermission() default "";
}
diff --git
a/engine/src/main/java/org/apache/hop/www/HopServerPluginPermissions.java
b/engine/src/main/java/org/apache/hop/www/HopServerPluginPermissions.java
new file mode 100644
index 0000000000..43141eb965
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/www/HopServerPluginPermissions.java
@@ -0,0 +1,101 @@
+/*
+ * 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.www;
+
+import java.util.List;
+import org.apache.hop.core.exception.HopPluginException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.plugins.HopServerPluginType;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.security.HopServerEndpointPermissionMapper;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * Registers plugin {@link IHopServerPlugin} context paths with {@link
+ * HopServerEndpointPermissionMapper} so authenticated Hop Web does not
default-deny them.
+ */
+public final class HopServerPluginPermissions {
+
+ private HopServerPluginPermissions() {}
+
+ /**
+ * Register the servlet's {@link IHopServerPlugin#getRequiredPermissionId()}
if it is set.
+ *
+ * @param servlet plugin servlet
+ * @param log log channel for a bad permission id
+ */
+ public static void register(IHopServerPlugin servlet, ILogChannel log) {
+ if (servlet == null) {
+ return;
+ }
+ String permissionId = servlet.getRequiredPermissionId();
+ if (Utils.isEmpty(permissionId)) {
+ return;
+ }
+ String path = servlet.getContextPath();
+ try {
+ HopServerEndpointPermissionMapper.register(path, permissionId);
+ } catch (IllegalArgumentException e) {
+ if (log != null) {
+ log.logError(
+ "Cannot register Hop Web permission '"
+ + permissionId
+ + "' for servlet path '"
+ + path
+ + "'",
+ e);
+ }
+ }
+ }
+
+ /**
+ * Drop the overlay entry for this servlet path.
+ *
+ * @param servlet plugin servlet
+ */
+ public static void unregister(IHopServerPlugin servlet) {
+ if (servlet == null || Utils.isEmpty(servlet.getContextPath())) {
+ return;
+ }
+ HopServerEndpointPermissionMapper.unregister(servlet.getContextPath());
+ }
+
+ /**
+ * Scan the plugin registry and register each Hop Server servlet's required
permission. Safe to
+ * call before {@code HopServerServlet.init()} so Hop Web RBAC knows plugin
paths on the first
+ * request.
+ *
+ * @param log log channel
+ */
+ public static void registerLoadedPlugins(ILogChannel log) {
+ PluginRegistry pluginRegistry = PluginRegistry.getInstance();
+ List<IPlugin> plugins =
pluginRegistry.getPlugins(HopServerPluginType.class);
+ if (plugins == null) {
+ return;
+ }
+ for (IPlugin plugin : plugins) {
+ try {
+ register(pluginRegistry.loadClass(plugin, IHopServerPlugin.class),
log);
+ } catch (HopPluginException e) {
+ if (log != null) {
+ log.logError("Unable to register Hop Web permission for servlet
plugin " + plugin, e);
+ }
+ }
+ }
+ }
+}
diff --git a/engine/src/main/java/org/apache/hop/www/HopServerServlet.java
b/engine/src/main/java/org/apache/hop/www/HopServerServlet.java
index 447066338f..a4216e8148 100644
--- a/engine/src/main/java/org/apache/hop/www/HopServerServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/HopServerServlet.java
@@ -78,11 +78,22 @@ public class HopServerServlet extends HttpServlet {
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
+ if (hopServerPluginRegistry == null) {
+ sendSafeError(
+ resp,
+ HttpServletResponse.SC_SERVICE_UNAVAILABLE,
+ "Hop Server servlet is not initialized.");
+ return;
+ }
String servletPath = req.getPathInfo();
+ if (servletPath == null || servletPath.isEmpty()) {
+ sendSafeError(resp, HttpServletResponse.SC_NOT_FOUND, "Not found.");
+ return;
+ }
if (servletPath.endsWith("/")) {
servletPath = servletPath.substring(0, servletPath.length() - 1);
}
- IHopServerPlugin plugin = hopServerPluginRegistry.get(servletPath);
+ IHopServerPlugin plugin = findPlugin(servletPath);
if (plugin != null) {
try {
plugin.doGet(req, resp);
@@ -101,6 +112,29 @@ public class HopServerServlet extends HttpServlet {
}
}
+ /**
+ * Exact key, then longest registered prefix, matching {@code
HopServerEndpointPermissionMapper}
+ * so {@code /sourceModelData/models} reaches the plugin mounted at {@code
/sourceModelData}.
+ */
+ IHopServerPlugin findPlugin(String servletPath) {
+ IHopServerPlugin exact = hopServerPluginRegistry.get(servletPath);
+ if (exact != null) {
+ return exact;
+ }
+ IHopServerPlugin best = null;
+ int bestLen = -1;
+ for (var entry : hopServerPluginRegistry.entrySet()) {
+ String key = entry.getKey();
+ if (key != null
+ && (servletPath.equals(key) || servletPath.startsWith(key + "/"))
+ && key.length() > bestLen) {
+ best = entry.getValue();
+ bestLen = key.length();
+ }
+ }
+ return best;
+ }
+
private String getServletKey(IHopServerPlugin servlet) {
String key = servlet.getContextPath();
if (key.startsWith("/hop")) {
@@ -169,8 +203,9 @@ public class HopServerServlet extends HttpServlet {
@Override
public void pluginRemoved(Object serviceObject) {
try {
- String key = getServletKey(loadServlet((IPlugin) serviceObject));
- hopServerPluginRegistry.remove(key);
+ IHopServerPlugin plugin = loadServlet((IPlugin) serviceObject);
+ hopServerPluginRegistry.remove(getServletKey(plugin));
+ HopServerPluginPermissions.unregister(plugin);
} catch (HopPluginException e) {
log.logError(MessageFormat.format("Unable to load plugin: {0}",
serviceObject), e);
}
@@ -194,5 +229,6 @@ public class HopServerServlet extends HttpServlet {
hopServerPluginRegistry.put(getServletKey(servlet), servlet);
servlet.setup(pipelineMap, workflowMap);
servlet.setJettyMode(false);
+ HopServerPluginPermissions.register(servlet, log);
}
}
diff --git a/engine/src/main/java/org/apache/hop/www/IHopServerPlugin.java
b/engine/src/main/java/org/apache/hop/www/IHopServerPlugin.java
index cf10e51301..aa2bf27ad5 100644
--- a/engine/src/main/java/org/apache/hop/www/IHopServerPlugin.java
+++ b/engine/src/main/java/org/apache/hop/www/IHopServerPlugin.java
@@ -19,6 +19,7 @@ package org.apache.hop.www;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import org.apache.hop.core.annotations.HopServerServlet;
public interface IHopServerPlugin extends IHopServerServlet {
@@ -31,4 +32,20 @@ public interface IHopServerPlugin extends IHopServerServlet {
void setJettyMode(boolean jettyMode);
boolean isJettyMode();
+
+ /**
+ * Hop Web RBAC permission id for this servlet. Default reads {@link
+ * HopServerServlet#requiredPermission()} on the implementation class. Empty
means default-deny on
+ * authenticated Hop Web.
+ *
+ * @return permission id such as {@code run.execute}, or empty
+ */
+ default String getRequiredPermissionId() {
+ HopServerServlet annotation =
getClass().getAnnotation(HopServerServlet.class);
+ if (annotation == null) {
+ return "";
+ }
+ String permission = annotation.requiredPermission();
+ return permission == null ? "" : permission.trim();
+ }
}
diff --git a/engine/src/main/java/org/apache/hop/www/JdbcTokenServlet.java
b/engine/src/main/java/org/apache/hop/www/JdbcTokenServlet.java
new file mode 100644
index 0000000000..cb9ee33cc7
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/www/JdbcTokenServlet.java
@@ -0,0 +1,119 @@
+/*
+ * 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.www;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.Serial;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import org.apache.hop.core.annotations.HopServerServlet;
+import org.apache.hop.core.json.HopJson;
+import org.apache.hop.core.security.HopJdbcTokenService;
+import org.apache.hop.core.security.HopRole;
+
+/**
+ * Issues a short-lived HMAC JWT for JDBC / API clients. The caller must
already be authenticated
+ * (session cookie or another accepted credential); this does not replace IdP
login.
+ */
+@HopServerServlet(
+ id = "jdbcToken",
+ name = "Issue a short-lived JDBC Bearer token",
+ requiredPermission = "file.view")
+public class JdbcTokenServlet extends BaseHttpServlet implements
IHopServerPlugin {
+
+ @Serial private static final long serialVersionUID = 1L;
+
+ public static final String CONTEXT_PATH = "/hop/jdbcToken";
+
+ public JdbcTokenServlet() {}
+
+ public JdbcTokenServlet(PipelineMap pipelineMap) {
+ super(pipelineMap);
+ }
+
+ @Override
+ public String getContextPath() {
+ return CONTEXT_PATH;
+ }
+
+ @Override
+ public String getService() {
+ return CONTEXT_PATH + " (" + this + ")";
+ }
+
+ @Override
+ public String toString() {
+ return "JDBC token";
+ }
+
+ @Override
+ public void doGet(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ if (isJettyMode() && !request.getContextPath().startsWith(CONTEXT_PATH)) {
+ return;
+ }
+
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.setContentType("application/json");
+ response.setHeader("Cache-Control", "no-store");
+
+ Principal principal = request.getUserPrincipal();
+ if (principal == null || principal.getName() == null ||
principal.getName().isBlank()) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setHeader("WWW-Authenticate", "Bearer");
+ response.getWriter().write("{\"error\":\"Authentication required\"}");
+ return;
+ }
+
+ Set<String> roles = new LinkedHashSet<>();
+ for (HopRole role : HopRole.values()) {
+ if (request.isUserInRole(role.getId()) || request.isUserInRole("hop-" +
role.getId())) {
+ roles.add(role.getId());
+ }
+ }
+ if (roles.isEmpty()) {
+ roles.add(HopRole.USER.getId());
+ }
+
+ try {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue(principal.getName(), roles,
HopJdbcTokenService.DEFAULT_TTL);
+
+ Map<String, Object> body = new LinkedHashMap<>();
+ body.put("tokenType", "Bearer");
+ body.put("token", issued.token());
+ body.put("expiresIn", issued.expiresInSeconds());
+ body.put("username", principal.getName());
+
+ ObjectMapper mapper = HopJson.newMapper();
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.getWriter().write(mapper.writeValueAsString(body));
+ } catch (Exception e) {
+ logError("Failed to issue JDBC token", e);
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ response.getWriter().write("{\"error\":\"Failed to issue JDBC token\"}");
+ }
+ }
+}
diff --git a/engine/src/main/java/org/apache/hop/www/WebServer.java
b/engine/src/main/java/org/apache/hop/www/WebServer.java
index af43f1838b..5af4787a8a 100644
--- a/engine/src/main/java/org/apache/hop/www/WebServer.java
+++ b/engine/src/main/java/org/apache/hop/www/WebServer.java
@@ -233,6 +233,7 @@ public class WebServer {
IHopServerPlugin servlet = pluginRegistry.loadClass(plugin,
IHopServerPlugin.class);
servlet.setup(pipelineMap, workflowMap);
servlet.setJettyMode(true);
+ HopServerPluginPermissions.register(servlet, log);
ServletContextHandler servletContext =
new ServletContextHandler(getContextPath(servlet),
ServletContextHandler.SESSIONS);
diff --git
a/engine/src/test/java/org/apache/hop/core/HopEnvironmentReentrantInitTest.java
b/engine/src/test/java/org/apache/hop/core/HopEnvironmentReentrantInitTest.java
new file mode 100644
index 0000000000..65d14324c0
--- /dev/null
+++
b/engine/src/test/java/org/apache/hop/core/HopEnvironmentReentrantInitTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPointMap;
+import org.apache.hop.core.extension.ExtensionPointPluginType;
+import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.variables.IVariables;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * hopper-edw's {@code HopEnvironmentAfterInit} handler calls {@code
HEnvironment.initEmbed()},
+ * which calls {@link HopEnvironment#init()} again before the outer init
future is completed.
+ * Waiting on that future deadlocks hop-conf / hop-web startup.
+ */
+class HopEnvironmentReentrantInitTest {
+
+ @BeforeEach
+ void setUp() throws Exception {
+ HopEnvironment.reset();
+ ExtensionPointMap.getInstance().reset();
+ ReenterFromAfterInit.reset();
+ HopClientEnvironment.init();
+ ExtensionPointPluginType.getInstance()
+ .registerCustom(
+ ReenterFromAfterInit.class,
+ "test",
+ "HopEnvironmentReentrantInitTest",
+ HopExtensionPoint.HopEnvironmentAfterInit.id,
+ "Re-enter HopEnvironment.init from AfterInit",
+ null);
+ }
+
+ @AfterEach
+ void tearDown() {
+ HopEnvironment.reset();
+ ExtensionPointMap.getInstance().reset();
+ }
+
+ @Test
+ void afterInitMayCallInitAndIsInitializedWithoutDeadlock() throws Exception {
+ assertTimeoutPreemptively(
+ Duration.ofSeconds(60),
+ () -> {
+ HopEnvironment.init();
+ });
+
+ assertTrue(HopEnvironment.isInitialized());
+ assertTrue(ReenterFromAfterInit.called, "HopEnvironmentAfterInit must
run");
+ assertTrue(
+ ReenterFromAfterInit.initializedDuringCall,
+ "isInitialized() must be true during AfterInit");
+ assertTrue(ReenterFromAfterInit.reenteredInit, "nested init() must
return");
+ }
+
+ public static class ReenterFromAfterInit implements
IExtensionPoint<PluginRegistry> {
+ static volatile boolean called;
+ static volatile boolean initializedDuringCall;
+ static volatile boolean reenteredInit;
+
+ static void reset() {
+ called = false;
+ initializedDuringCall = false;
+ reenteredInit = false;
+ }
+
+ @Override
+ public void callExtensionPoint(
+ ILogChannel log, IVariables variables, PluginRegistry pluginRegistry)
throws HopException {
+ called = true;
+ initializedDuringCall = HopEnvironment.isInitialized();
+ HopEnvironment.init();
+ reenteredInit = true;
+ }
+ }
+}
diff --git
a/engine/src/test/java/org/apache/hop/www/HopServerPluginPermissionsTest.java
b/engine/src/test/java/org/apache/hop/www/HopServerPluginPermissionsTest.java
new file mode 100644
index 0000000000..d35cfc5b89
--- /dev/null
+++
b/engine/src/test/java/org/apache/hop/www/HopServerPluginPermissionsTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.security.HopServerEndpointPermissionMapper;
+import org.apache.hop.core.security.Permission;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class HopServerPluginPermissionsTest {
+
+ @BeforeAll
+ static void initLog() {
+ HopLogStore.init();
+ }
+
+ @AfterEach
+ void clearOverlay() throws Exception {
+ HopServerEndpointPermissionMapper.unregister("/hop/sourceModelData");
+ }
+
+ @Test
+ void jdbcTokenServletDeclaresFileView() {
+ JdbcTokenServlet servlet = new JdbcTokenServlet();
+ assertEquals("file.view", servlet.getRequiredPermissionId());
+ assertEquals("/hop/jdbcToken", servlet.getContextPath());
+ }
+
+ @Test
+ void registerHonoursRequiredPermissionFromThePlugin() {
+ IHopServerPlugin plugin =
+ new IHopServerPlugin() {
+ @Override
+ public void setup(PipelineMap pipelineMap, WorkflowMap workflowMap)
{}
+
+ @Override
+ public void doGet(
+ jakarta.servlet.http.HttpServletRequest request,
+ jakarta.servlet.http.HttpServletResponse response) {}
+
+ @Override
+ public String getContextPath() {
+ return "/hop/sourceModelData";
+ }
+
+ @Override
+ public void setJettyMode(boolean jettyMode) {}
+
+ @Override
+ public boolean isJettyMode() {
+ return false;
+ }
+
+ @Override
+ public String getRequiredPermissionId() {
+ return "run.execute";
+ }
+
+ @Override
+ public String getService() {
+ return getContextPath();
+ }
+ };
+
+ HopServerPluginPermissions.register(plugin, new LogChannel("test"));
+ assertEquals(
+ Optional.of(Permission.RUN_EXECUTE),
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/sourceModelData"));
+ HopServerPluginPermissions.unregister(plugin);
+ assertTrue(
+
HopServerEndpointPermissionMapper.requiredPermission("/hop/sourceModelData").isEmpty());
+ }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/HopServerServletTest.java
b/engine/src/test/java/org/apache/hop/www/HopServerServletTest.java
index af1d99a909..7ccd8d9eca 100644
--- a/engine/src/test/java/org/apache/hop/www/HopServerServletTest.java
+++ b/engine/src/test/java/org/apache/hop/www/HopServerServletTest.java
@@ -20,6 +20,7 @@ package org.apache.hop.www;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -62,6 +63,32 @@ class HopServerServletTest {
verify(plugin).doGet(req, resp);
}
+ @Test
+ void doGetDispatchesPrefixPathToRegisteredPlugin() throws Exception {
+ IHopServerPlugin plugin = mock(IHopServerPlugin.class);
+ registry.put("/sourceModelData", plugin);
+
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getPathInfo()).thenReturn("/sourceModelData/models/sales");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+
+ servlet.doGet(req, resp);
+
+ verify(plugin).doGet(req, resp);
+ }
+
+ @Test
+ void doGetSendsNotFoundWhenPathInfoIsNull() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getPathInfo()).thenReturn(null);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(resp.isCommitted()).thenReturn(false);
+
+ servlet.doGet(req, resp);
+
+ verify(resp).sendError(HttpServletResponse.SC_NOT_FOUND, "Not found.");
+ }
+
@Test
void doGetSendsNotFoundWhenPluginMissing() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
@@ -105,7 +132,39 @@ class HopServerServletTest {
}
@Test
- void doPostCatchesFailureWhenRegistryUninitialized() throws Exception {
+ void doGetPrefersTheLongestRegisteredPrefix() throws Exception {
+ IHopServerPlugin shorter = mock(IHopServerPlugin.class);
+ IHopServerPlugin longer = mock(IHopServerPlugin.class);
+ registry.put("/source", shorter);
+ registry.put("/sourceModelData", longer);
+
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getPathInfo()).thenReturn("/sourceModelData/crm");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+
+ servlet.doGet(req, resp);
+
+ verify(longer).doGet(req, resp);
+ verify(shorter, never()).doGet(any(), any());
+ }
+
+ @Test
+ void doGetSendsServiceUnavailableWhenRegistryUninitialized() throws
Exception {
+ HopServerServlet bare = new HopServerServlet();
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getPathInfo()).thenReturn("/any");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(resp.isCommitted()).thenReturn(false);
+
+ bare.doGet(req, resp);
+
+ verify(resp)
+ .sendError(
+ HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Hop Server servlet is
not initialized.");
+ }
+
+ @Test
+ void doPostReportsUnavailableWhenRegistryUninitialized() throws Exception {
HopServerServlet bare = new HopServerServlet();
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getPathInfo()).thenReturn("/any");
@@ -116,6 +175,6 @@ class HopServerServletTest {
verify(resp)
.sendError(
- HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to process
server request.");
+ HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Hop Server servlet is
not initialized.");
}
}
diff --git a/engine/src/test/java/org/apache/hop/www/JdbcTokenServletTest.java
b/engine/src/test/java/org/apache/hop/www/JdbcTokenServletTest.java
new file mode 100644
index 0000000000..5d0a07c4f3
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/JdbcTokenServletTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.security.Principal;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.security.HopJdbcTokenService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class JdbcTokenServletTest {
+
+ @BeforeAll
+ static void initLog() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void pinSecret() {
+ System.setProperty(HopJdbcTokenService.ENV_SECRET,
"abcdefghijklmnopqrstuvwxyz012345");
+ }
+
+ @AfterEach
+ void clearSecret() {
+ System.clearProperty(HopJdbcTokenService.ENV_SECRET);
+ }
+
+ @Test
+ void unauthenticatedRequestIs401WithBearerChallenge() throws Exception {
+ JdbcTokenServlet servlet = new JdbcTokenServlet();
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ when(response.getWriter()).thenReturn(new PrintWriter(body));
+ when(request.getUserPrincipal()).thenReturn(null);
+
+ servlet.doGet(request, response);
+
+ verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(response).setHeader("WWW-Authenticate", "Bearer");
+ assertTrue(body.toString().contains("Authentication required"));
+ }
+
+ @Test
+ void authenticatedRequestIssuesAHopJdbcToken() throws Exception {
+ JdbcTokenServlet servlet = new JdbcTokenServlet();
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ when(response.getWriter()).thenReturn(new PrintWriter(body));
+ Principal principal = () -> "alice";
+ when(request.getUserPrincipal()).thenReturn(principal);
+ when(request.isUserInRole(anyString())).thenReturn(false);
+ when(request.isUserInRole("admin")).thenReturn(true);
+
+ servlet.doGet(request, response);
+
+ verify(response).setStatus(HttpServletResponse.SC_OK);
+ String json = body.toString();
+ assertTrue(json.contains("\"tokenType\":\"Bearer\""));
+ assertTrue(json.contains("\"username\":\"alice\""));
+ int tokenStart = json.indexOf("\"token\":\"") + "\"token\":\"".length();
+ int tokenEnd = json.indexOf('"', tokenStart);
+ String token = json.substring(tokenStart, tokenEnd);
+ assertEquals("alice", HopJdbcTokenService.verify(token).getSubject());
+ }
+}
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebServletContextListener.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebServletContextListener.java
index c496c8ef1b..2059071142 100644
---
a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebServletContextListener.java
+++
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebServletContextListener.java
@@ -21,9 +21,11 @@ import jakarta.servlet.ServletContextEvent;
import java.util.logging.Logger;
import org.apache.hop.core.HopEnvironment;
import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.security.HopSecurity;
import org.apache.hop.core.security.HopSecurityBootstrap;
import org.apache.hop.history.AuditManager;
+import org.apache.hop.www.HopServerPluginPermissions;
import org.eclipse.rap.rwt.engine.RWTServletContextListener;
public class HopWebServletContextListener extends RWTServletContextListener {
@@ -45,6 +47,9 @@ public class HopWebServletContextListener extends
RWTServletContextListener {
}
// Apply HOP_WEB_SECURITY_MODE / bootstrap BASIC users before any request
HopSecurityBootstrap.runOnce();
+ // Register plugin /hop/* permissions before the first request so RBAC
does not 403 them
+ // while HopServerServlet is still lazy-initialized.
+ HopServerPluginPermissions.registerLoadedPlugins(LogChannel.GENERAL);
// Use per-user audit folders in Hop Web when the user is authenticated
AuditManager.setSessionAuditManagerProvider(new
HopWebAuditManagerProvider());
// Session-aware RBAC: menus/toolbars consult HopSecurity for the
UISession principal
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopBasicAuthFilter.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopBasicAuthFilter.java
index 592d21ba8c..06743a3184 100644
---
a/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopBasicAuthFilter.java
+++
b/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopBasicAuthFilter.java
@@ -122,14 +122,22 @@ public class HopBasicAuthFilter implements Filter {
contextPath + HopLoginPage.PATH_LOGIN + "?redirect=" +
urlEncode(redirect));
return;
}
+ if (HopBearerSupport.bearerToken(httpRequest) != null) {
+ HopBearerSupport.challenge(httpResponse);
+ return;
+ }
challengeBasic(httpResponse);
return;
}
- HttpSession session = httpRequest.getSession(true);
- session.setAttribute(SESSION_PRINCIPAL, principal);
- session.removeAttribute(SESSION_FORCE_REAUTH);
- session.removeAttribute(SESSION_REJECT_AUTH);
+ // Bearer is stateless: do not allocate an HttpSession per JDBC/API call.
+ boolean bearer = HopBearerSupport.bearerToken(httpRequest) != null;
+ if (!bearer) {
+ HttpSession session = httpRequest.getSession(true);
+ session.setAttribute(SESSION_PRINCIPAL, principal);
+ session.removeAttribute(SESSION_FORCE_REAUTH);
+ session.removeAttribute(SESSION_REJECT_AUTH);
+ }
chain.doFilter(new HopAuthenticatedRequest(httpRequest, principal),
response);
}
@@ -212,29 +220,26 @@ public class HopBasicAuthFilter implements Filter {
if (session != null) {
session.removeAttribute(SESSION_PRINCIPAL);
}
- // Only accept a fresh Authorization header (API) after force reauth;
form login clears flag
- String header = request.getHeader("Authorization");
- if (header != null && header.regionMatches(true, 0, "Basic ", 0, 6)) {
- HopAuthenticatedPrincipal p = authenticateHeader(header);
- if (p != null) {
- return p;
- }
- }
- return null;
+ return principalFromAuthorization(request);
}
- // Session principal from form login
- HopAuthenticatedPrincipal sessionPrincipal =
resolveSessionPrincipal(request);
- if (sessionPrincipal != null) {
- return sessionPrincipal;
+ // Explicit Authorization wins over an ambient form-login session.
+ HopAuthenticatedPrincipal fromHeader = principalFromAuthorization(request);
+ if (fromHeader != null) {
+ return fromHeader;
}
- // Optional HTTP Basic for API / automation clients
+ return resolveSessionPrincipal(request);
+ }
+
+ private HopAuthenticatedPrincipal
principalFromAuthorization(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.regionMatches(true, 0, "Basic ", 0, 6)) {
return authenticateHeader(header);
}
-
+ if (HopBearerSupport.bearerToken(request) != null) {
+ return HopBearerSupport.authenticate(request, HopSecurityConfig.load());
+ }
return null;
}
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopBearerSupport.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopBearerSupport.java
new file mode 100644
index 0000000000..00bccca05a
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopBearerSupport.java
@@ -0,0 +1,137 @@
+/*
+ * 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 com.nimbusds.jwt.JWTClaimsSet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.apache.hop.core.security.HopJdbcTokenService;
+import org.apache.hop.core.security.HopRole;
+import org.apache.hop.core.security.HopSecurityConfig;
+import org.apache.hop.core.security.HopSecurityContext;
+import org.apache.hop.core.security.HopUserStore;
+import org.apache.hop.core.security.oidc.HopOidcClient;
+
+/**
+ * Shared Bearer handling for Hop Web filters: Hop-issued JDBC HMAC JWTs, then
(in OAUTH2 mode) IdP
+ * JWTs via JWKS.
+ *
+ * <p>IdP fallback validates an <em>ID token</em> (audience is typically the
OAuth client id).
+ * Opaque Google access tokens will not pass JWKS. JDBC clients should use a
Hop-issued token from
+ * {@code File → Copy JDBC token} / {@code GET /hop/jdbcToken}.
+ */
+public final class HopBearerSupport {
+
+ private static final Logger LOG =
Logger.getLogger(HopBearerSupport.class.getName());
+
+ public static final String WWW_AUTHENTICATE_BEARER = "Bearer";
+
+ private HopBearerSupport() {}
+
+ /**
+ * @param request HTTP request
+ * @return the Bearer token or null
+ */
+ public static String bearerToken(HttpServletRequest request) {
+ if (request == null) {
+ return null;
+ }
+ String header = request.getHeader("Authorization");
+ if (header == null || !header.regionMatches(true, 0, "Bearer ", 0, 7)) {
+ return null;
+ }
+ String token = header.substring(7).trim();
+ return token.isEmpty() ? null : token;
+ }
+
+ /**
+ * Authenticate a Bearer token. Tries Hop JDBC HMAC first, then IdP JWT when
OAUTH2 is configured.
+ *
+ * @param request request
+ * @param config security config
+ * @return principal or null
+ */
+ public static HopAuthenticatedPrincipal authenticate(
+ HttpServletRequest request, HopSecurityConfig config) {
+ String token = bearerToken(request);
+ if (token == null) {
+ return null;
+ }
+ try {
+ JWTClaimsSet hopClaims = HopJdbcTokenService.verify(token);
+ return principalFromHopToken(hopClaims);
+ } catch (Exception e) {
+ LOG.log(Level.FINE, "Not a Hop JDBC token, trying IdP JWT if
configured", e);
+ }
+ if (config != null
+ && config.getAuthMode() == HopSecurityConfig.AuthMode.OAUTH2
+ && config.isOauthConfigured()) {
+ try {
+ HopOidcClient client = new HopOidcClient(config);
+ JWTClaimsSet claims = client.validateIdToken(token, null);
+ return principalFromOidc(client, claims);
+ } catch (Exception e) {
+ LOG.log(Level.INFO, "Bearer IdP JWT validation failed", e);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 401 with {@code WWW-Authenticate: Bearer} for API clients.
+ *
+ * @param response response
+ */
+ public static void challenge(HttpServletResponse response) throws
IOException {
+ if (response.isCommitted()) {
+ return;
+ }
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setHeader("WWW-Authenticate", WWW_AUTHENTICATE_BEARER);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.setContentType("text/plain; charset=UTF-8");
+ response.setHeader("Cache-Control", "no-store");
+ response.getWriter().write("Authentication required (Bearer)");
+ }
+
+ static HopAuthenticatedPrincipal principalFromHopToken(JWTClaimsSet claims) {
+ String username = claims.getSubject();
+ Set<String> roles = new
LinkedHashSet<>(HopJdbcTokenService.roleNames(claims));
+
roles.addAll(HopUserStore.expandContainerRoleNames(roles.stream().toList()));
+ if (roles.isEmpty()) {
+ roles.add(HopRole.USER.getId());
+ roles.add("hop-user");
+ }
+ return new HopAuthenticatedPrincipal(username, roles);
+ }
+
+ static HopAuthenticatedPrincipal principalFromOidc(HopOidcClient client,
JWTClaimsSet claims) {
+ HopSecurityContext securityContext = client.toSecurityContext(claims);
+ Set<String> roles = new
LinkedHashSet<>(client.expandRolesForPrincipal(claims));
+ roles.addAll(securityContext.getRoleIds());
+ for (String id : securityContext.getRoleIds()) {
+
roles.addAll(HopUserStore.expandContainerRoleNames(java.util.List.of(id)));
+ }
+ return new HopAuthenticatedPrincipal(securityContext.getUsername(), roles);
+ }
+}
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopOidcAuthFilter.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopOidcAuthFilter.java
index d02d805aec..19cc314e64 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopOidcAuthFilter.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/security/HopOidcAuthFilter.java
@@ -124,6 +124,17 @@ public class HopOidcAuthFilter implements Filter {
return;
}
+ // Explicit Authorization wins over an ambient SSO session, including a
garbage Bearer.
+ if (HopBearerSupport.bearerToken(httpRequest) != null) {
+ HopAuthenticatedPrincipal bearer =
HopBearerSupport.authenticate(httpRequest, config);
+ if (bearer != null) {
+ chain.doFilter(new HopAuthenticatedRequest(httpRequest, bearer),
response);
+ return;
+ }
+ HopBearerSupport.challenge(httpResponse);
+ return;
+ }
+
HopAuthenticatedPrincipal principal = sessionPrincipal(httpRequest);
if (principal != null) {
chain.doFilter(new HopAuthenticatedRequest(httpRequest, principal),
response);
@@ -141,9 +152,7 @@ public class HopOidcAuthFilter implements Filter {
contextPath + HopLoginPage.PATH_LOGIN + "?redirect=" +
urlEncode(redirect));
return;
}
- httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
- httpResponse.setContentType("text/plain; charset=UTF-8");
- httpResponse.getWriter().write("Authentication required (OIDC)");
+ HopBearerSupport.challenge(httpResponse);
}
private void handleStart(
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopBasicAuthFilterTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopBasicAuthFilterTest.java
new file mode 100644
index 0000000000..5b1777520f
--- /dev/null
+++
b/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopBasicAuthFilterTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.security.HopJdbcTokenService;
+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.mockito.ArgumentCaptor;
+
+class HopBasicAuthFilterTest {
+
+ private HopBasicAuthFilter filter;
+ private HttpServletRequest request;
+ private HttpServletResponse response;
+ private FilterChain chain;
+
+ @BeforeAll
+ static void initLog() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ System.setProperty(HopJdbcTokenService.ENV_SECRET,
"abcdefghijklmnopqrstuvwxyz012345");
+
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setAuthMode(HopSecurityConfig.AuthMode.BASIC);
+ HopSecurityConfig.save(config);
+
+ filter = new HopBasicAuthFilter();
+ request = mock(HttpServletRequest.class);
+ response = mock(HttpServletResponse.class);
+ chain = mock(FilterChain.class);
+ when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+ when(request.getContextPath()).thenReturn("");
+ when(request.getRequestURI()).thenReturn("/hop/status");
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getHeader("Accept")).thenReturn("application/json");
+ when(request.getSession(false)).thenReturn(null);
+ }
+
+ @AfterEach
+ void tearDown() {
+ System.clearProperty(HopJdbcTokenService.ENV_SECRET);
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setAuthMode(HopSecurityConfig.AuthMode.NONE);
+ HopSecurityConfig.save(config);
+ HopSecurityConfig.clearCache();
+ }
+
+ @Test
+ void validBearerIsAcceptedWithoutCreatingASession() throws Exception {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue("alice", List.of("admin"),
Duration.ofMinutes(5));
+ when(request.getHeader("Authorization")).thenReturn("Bearer " +
issued.token());
+
+ filter.doFilter(request, response, chain);
+
+ ArgumentCaptor<ServletRequest> captor =
ArgumentCaptor.forClass(ServletRequest.class);
+ verify(chain).doFilter(captor.capture(), eq(response));
+ HopAuthenticatedRequest wrapped = (HopAuthenticatedRequest)
captor.getValue();
+ assertEquals("alice", wrapped.getUserPrincipal().getName());
+ verify(request, never()).getSession(true);
+ }
+
+ @Test
+ void garbageBearerReturns401WithWwwAuthenticate() throws Exception {
+ when(request.getHeader("Authorization")).thenReturn("Bearer a.b.c");
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(response).setHeader("WWW-Authenticate",
HopBearerSupport.WWW_AUTHENTICATE_BEARER);
+ }
+
+ @Test
+ void explicitBearerBeatsAnAmbientSession() throws Exception {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue("bob", List.of("user"),
Duration.ofMinutes(5));
+ when(request.getHeader("Authorization")).thenReturn("Bearer " +
issued.token());
+
+ HttpSession session = mock(HttpSession.class);
+ when(session.getAttribute(HopBasicAuthFilter.SESSION_PRINCIPAL))
+ .thenReturn(new HopAuthenticatedPrincipal("alice", Set.of("admin")));
+ when(request.getSession(false)).thenReturn(session);
+
+ filter.doFilter(request, response, chain);
+
+ ArgumentCaptor<ServletRequest> captor =
ArgumentCaptor.forClass(ServletRequest.class);
+ verify(chain).doFilter(captor.capture(), eq(response));
+ HopAuthenticatedRequest wrapped = (HopAuthenticatedRequest)
captor.getValue();
+ assertEquals("bob", wrapped.getUserPrincipal().getName());
+ }
+}
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopBearerSupportTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopBearerSupportTest.java
new file mode 100644
index 0000000000..19c683e741
--- /dev/null
+++
b/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopBearerSupportTest.java
@@ -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.
+ */
+package org.apache.hop.ui.hopgui.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Duration;
+import java.util.List;
+import org.apache.hop.core.security.HopJdbcTokenService;
+import org.apache.hop.core.security.HopSecurityConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class HopBearerSupportTest {
+
+ @BeforeEach
+ void pinSecret() {
+ System.setProperty(HopJdbcTokenService.ENV_SECRET,
"abcdefghijklmnopqrstuvwxyz012345");
+ }
+
+ @AfterEach
+ void clearSecret() {
+ System.clearProperty(HopJdbcTokenService.ENV_SECRET);
+ }
+
+ @Test
+ void extractsBearerToken() {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getHeader("Authorization")).thenReturn("Bearer abc.def.ghi");
+ assertEquals("abc.def.ghi", HopBearerSupport.bearerToken(request));
+ }
+
+ @Test
+ void authenticatesAHopJdbcToken() throws Exception {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue("alice", List.of("admin"),
Duration.ofMinutes(5));
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getHeader("Authorization")).thenReturn("Bearer " +
issued.token());
+
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setAuthMode(HopSecurityConfig.AuthMode.BASIC);
+ HopAuthenticatedPrincipal principal =
HopBearerSupport.authenticate(request, config);
+ assertEquals("alice", principal.getName());
+ }
+
+ @Test
+ void rejectsGarbageBearer() {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getHeader("Authorization")).thenReturn("Bearer a.b.c");
+
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setAuthMode(HopSecurityConfig.AuthMode.BASIC);
+ assertNull(HopBearerSupport.authenticate(request, config));
+ }
+
+ @Test
+ void challengeSetsWwwAuthenticateBearer() throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ when(response.getWriter()).thenReturn(new PrintWriter(body));
+
+ HopBearerSupport.challenge(response);
+
+ verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(response).setHeader("WWW-Authenticate",
HopBearerSupport.WWW_AUTHENTICATE_BEARER);
+ }
+}
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopOidcAuthFilterTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopOidcAuthFilterTest.java
new file mode 100644
index 0000000000..94cc7b2951
--- /dev/null
+++
b/rap/src/test/java/org/apache/hop/ui/hopgui/security/HopOidcAuthFilterTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.security.HopJdbcTokenService;
+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.mockito.ArgumentCaptor;
+
+class HopOidcAuthFilterTest {
+
+ private HopOidcAuthFilter filter;
+ private HttpServletRequest request;
+ private HttpServletResponse response;
+ private FilterChain chain;
+
+ @BeforeAll
+ static void initLog() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ System.setProperty(HopJdbcTokenService.ENV_SECRET,
"abcdefghijklmnopqrstuvwxyz012345");
+
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setAuthMode(HopSecurityConfig.AuthMode.OAUTH2);
+ HopSecurityConfig.save(config);
+
+ filter = new HopOidcAuthFilter();
+ request = mock(HttpServletRequest.class);
+ response = mock(HttpServletResponse.class);
+ chain = mock(FilterChain.class);
+ when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+ when(request.getContextPath()).thenReturn("");
+ when(request.getRequestURI()).thenReturn("/hop/status");
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getHeader("Accept")).thenReturn("application/json");
+ when(request.getSession(false)).thenReturn(null);
+ }
+
+ @AfterEach
+ void tearDown() {
+ System.clearProperty(HopJdbcTokenService.ENV_SECRET);
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setAuthMode(HopSecurityConfig.AuthMode.NONE);
+ HopSecurityConfig.save(config);
+ HopSecurityConfig.clearCache();
+ }
+
+ @Test
+ void hopJdbcBearerIsAcceptedInOauth2Mode() throws Exception {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue("alice", List.of("admin"),
Duration.ofMinutes(5));
+ when(request.getHeader("Authorization")).thenReturn("Bearer " +
issued.token());
+
+ filter.doFilter(request, response, chain);
+
+ ArgumentCaptor<ServletRequest> captor =
ArgumentCaptor.forClass(ServletRequest.class);
+ verify(chain).doFilter(captor.capture(), eq(response));
+ HopAuthenticatedRequest wrapped = (HopAuthenticatedRequest)
captor.getValue();
+ assertEquals("alice", wrapped.getUserPrincipal().getName());
+ verify(request, never()).getSession(true);
+ }
+
+ @Test
+ void garbageBearerReturns401EvenWhenASessionExists() throws Exception {
+ when(request.getHeader("Authorization")).thenReturn("Bearer a.b.c");
+ HttpSession session = mock(HttpSession.class);
+ when(session.getAttribute(HopOidcAuthFilter.SESSION_PRINCIPAL))
+ .thenReturn(new HopAuthenticatedPrincipal("alice", Set.of("admin")));
+ when(request.getSession(false)).thenReturn(session);
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(response).setHeader("WWW-Authenticate",
HopBearerSupport.WWW_AUTHENTICATE_BEARER);
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 69b16de53c..533cb4f680 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -73,6 +73,7 @@ import org.apache.hop.core.plugins.Plugin;
import org.apache.hop.core.plugins.PluginRegistry;
import org.apache.hop.core.search.ISearchableProvider;
import org.apache.hop.core.search.ISearchablesLocation;
+import org.apache.hop.core.security.HopJdbcTokenService;
import org.apache.hop.core.security.HopSecurity;
import org.apache.hop.core.security.HopSecurityContext;
import org.apache.hop.core.security.HopSecurityPrivilegeMode;
@@ -100,6 +101,7 @@ import org.apache.hop.ui.core.bus.HopGuiEvents;
import org.apache.hop.ui.core.bus.HopGuiEventsHandler;
import org.apache.hop.ui.core.dialog.ErrorDialog;
import org.apache.hop.ui.core.dialog.HopDescribedVariablesDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
import org.apache.hop.ui.core.gui.GuiMenuWidgets;
import org.apache.hop.ui.core.gui.GuiResource;
import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
@@ -209,6 +211,7 @@ public class HopGui
public static final String ID_MAIN_MENU_FILE_EXPORT_TO_SVG =
"10050-menu-file-export-to-svg";
public static final String ID_MAIN_MENU_FILE_CLOSE = "10090-menu-file-close";
public static final String ID_MAIN_MENU_FILE_CLOSE_ALL =
"10100-menu-file-close-all";
+ public static final String ID_MAIN_MENU_FILE_COPY_JDBC_TOKEN =
"10840-menu-file-copy-jdbc-token";
public static final String ID_MAIN_MENU_FILE_LOG_OFF =
"10850-menu-file-log-off";
public static final String ID_MAIN_MENU_FILE_EXIT = "10900-menu-file-exit";
@@ -292,6 +295,8 @@ public class HopGui
/** Username label immediately left of {@link #ID_MAIN_TOOLBAR_LOG_OFF}. */
public static final String ID_MAIN_TOOLBAR_USER = "toolbar-10890-user";
+ public static final String ID_MAIN_TOOLBAR_COPY_JDBC_TOKEN =
"toolbar-10895-copy-jdbc-token";
+
public static final String ID_MAIN_TOOLBAR_LOG_OFF = "toolbar-10900-log-off";
public static final String ID_STATUS_TOOLBAR = "HopGui-Status-Toolbar";
@@ -1291,10 +1296,12 @@ public class HopGui
if (EnvironmentUtils.getInstance().isWeb()) {
mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_FILE_EXIT, false);
} else if (areSessionControlsVisible()) {
- // Log off is Hop Web only
+ // Log off / JDBC token are Hop Web only
mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_FILE_LOG_OFF, false);
+ mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_FILE_COPY_JDBC_TOKEN,
false);
} else {
mainMenuWidgets.removeMenuItem(HopGui.ID_MAIN_MENU_FILE_LOG_OFF);
+ mainMenuWidgets.removeMenuItem(HopGui.ID_MAIN_MENU_FILE_COPY_JDBC_TOKEN);
}
// We build the menu items but don't attach them to the shell.
@@ -1528,6 +1535,53 @@ public class HopGui
// Display-only label; no action
}
+ @GuiMenuElement(
+ root = ID_MAIN_MENU,
+ id = ID_MAIN_MENU_FILE_COPY_JDBC_TOKEN,
+ label = "i18n::HopGui.Menu.File.CopyJdbcToken",
+ parentId = ID_MAIN_MENU_FILE,
+ image = "ui/images/copy.svg",
+ separator = true)
+ @GuiToolbarElement(
+ root = ID_MAIN_TOOLBAR,
+ id = ID_MAIN_TOOLBAR_COPY_JDBC_TOKEN,
+ image = "ui/images/copy.svg",
+ toolTip = "i18n::HopGui.Menu.File.CopyJdbcToken")
+ public void menuFileCopyJdbcToken() {
+ if (!EnvironmentUtils.getInstance().isWeb()) {
+ MessageBox box = new MessageBox(getShell(), SWT.OK |
SWT.ICON_INFORMATION);
+ box.setText(BaseMessages.getString(PKG,
"HopGui.CopyJdbcToken.Desktop.Title"));
+ box.setMessage(BaseMessages.getString(PKG,
"HopGui.CopyJdbcToken.Desktop.Message"));
+ box.open();
+ return;
+ }
+ HopSecurityContext ctx = HopSecurity.getContext();
+ if (ctx == null || !ctx.isAuthenticated()) {
+ MessageBox box = new MessageBox(getShell(), SWT.OK | SWT.ICON_WARNING);
+ box.setText(BaseMessages.getString(PKG,
"HopGui.CopyJdbcToken.Unauthenticated.Title"));
+ box.setMessage(BaseMessages.getString(PKG,
"HopGui.CopyJdbcToken.Unauthenticated.Message"));
+ box.open();
+ return;
+ }
+ try {
+ HopJdbcTokenService.IssuedToken issued =
+ HopJdbcTokenService.issue(
+ ctx.getUsername(), ctx.getRoleIds(),
HopJdbcTokenService.DEFAULT_TTL);
+ GuiResource.getInstance().toClipboard(issued.token());
+ long minutes = Math.max(1L, issued.expiresInSeconds() / 60L);
+ MessageBox box = new MessageBox(getShell(), SWT.OK |
SWT.ICON_INFORMATION);
+ box.setText(BaseMessages.getString(PKG,
"HopGui.CopyJdbcToken.Copied.Title"));
+ box.setMessage(BaseMessages.getString(PKG,
"HopGui.CopyJdbcToken.Copied.Message", minutes));
+ box.open();
+ } catch (Exception e) {
+ new ErrorDialog(
+ getShell(),
+ BaseMessages.getString(PKG, "HopGui.CopyJdbcToken.Error.Title"),
+ BaseMessages.getString(PKG, "HopGui.CopyJdbcToken.Error.Message"),
+ e);
+ }
+ }
+
@GuiMenuElement(
root = ID_MAIN_MENU,
id = ID_MAIN_MENU_FILE_LOG_OFF,
@@ -2012,6 +2066,7 @@ public class HopGui
List<String> hiddenToolbarItems = new ArrayList<>();
if (!areSessionControlsVisible()) {
hiddenToolbarItems.add(ID_MAIN_TOOLBAR_PRIVILEGE);
+ hiddenToolbarItems.add(ID_MAIN_TOOLBAR_COPY_JDBC_TOKEN);
hiddenToolbarItems.add(ID_MAIN_TOOLBAR_LOG_OFF);
}
mainToolbarWidgets.createToolbarWidgets(
@@ -2019,6 +2074,7 @@ public class HopGui
updateLoggedInUserToolbar();
updatePrivilegeModeToolbar();
if (!EnvironmentUtils.getInstance().isWeb()) {
+ mainToolbarWidgets.enableToolbarItem(ID_MAIN_TOOLBAR_COPY_JDBC_TOKEN,
false);
mainToolbarWidgets.enableToolbarItem(ID_MAIN_TOOLBAR_LOG_OFF, false);
}
mainToolbar.pack();
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 2c52cc0463..af397bb1f9 100644
---
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -113,6 +113,15 @@ HopGui.Menu.File.New=&New
HopGui.Menu.File.Open=&Open...
HopGui.Menu.File.Open.Recent=Open Recent...
HopGui.Menu.File.Quit=E&xit
+HopGui.Menu.File.CopyJdbcToken=Copy JDBC &token
+HopGui.CopyJdbcToken.Desktop.Title=Copy JDBC token
+HopGui.CopyJdbcToken.Desktop.Message=JDBC tokens are issued by Hop Web. Sign
in on the web client and use File \u2192 Copy JDBC token.
+HopGui.CopyJdbcToken.Unauthenticated.Title=Copy JDBC token
+HopGui.CopyJdbcToken.Unauthenticated.Message=Sign in first, then copy a JDBC
token.
+HopGui.CopyJdbcToken.Copied.Title=JDBC token copied
+HopGui.CopyJdbcToken.Copied.Message=A Bearer token is on the clipboard. It
expires in about {0} minutes. Log off does not revoke it; rotating
HOP_WEB_JDBC_TOKEN_SECRET invalidates every issued token. Paste it as the
password on an Apache Hop Source Model connection with Authentication \=
Bearer. Do not store it in project metadata.
+HopGui.CopyJdbcToken.Error.Title=Copy JDBC token
+HopGui.CopyJdbcToken.Error.Message=Could not issue a JDBC token
HopGui.Menu.File.LogOff=Log &off...
HopGui.Menu.File.Save=&Save
HopGui.Menu.File.SaveAs=Save &as...