This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/storm.git


The following commit(s) were added to refs/heads/master by this push:
     new 313daccd8 Require every UI API endpoint to declare its authorization 
(#9004)
313daccd8 is described below

commit 313daccd823e0187f2eb58c8942adc404f92ed2d
Author: Gianluca Graziadei <[email protected]>
AuthorDate: Tue Aug 25 09:20:11 2026 +0200

    Require every UI API endpoint to declare its authorization (#9004)
    
    * Require every UI API endpoint to declare its authorization
    
    * Prevent credential mask from overriding local config
---
 storm-client/src/jvm/org/apache/storm/Config.java  |  15 ++
 .../jvm/org/apache/storm/utils/ConfigUtils.java    |  37 ++++
 .../org/apache/storm/utils/ConfigUtilsTest.java    | 114 +++++++++++++
 .../apache/storm/command/UploadCredentials.java    |   4 +
 .../org/apache/storm/daemon/nimbus/Nimbus.java     |   2 +-
 .../daemon/nimbus/NimbusGetNimbusConfTest.java     |  72 ++++++++
 .../daemon/ui/filters/AuthorizedUserFilter.java    |  18 +-
 .../daemon/ui/resources/StormApiResource.java      |   2 +
 .../ui/resources/UnauthenticatedNimbusOp.java      |  39 +++++
 .../ui/filters/AuthorizedUserFilterTest.java       | 188 +++++++++++++++++++++
 10 files changed, 489 insertions(+), 2 deletions(-)

diff --git a/storm-client/src/jvm/org/apache/storm/Config.java 
b/storm-client/src/jvm/org/apache/storm/Config.java
index 226a4b4ca..4e3dbe061 100644
--- a/storm-client/src/jvm/org/apache/storm/Config.java
+++ b/storm-client/src/jvm/org/apache/storm/Config.java
@@ -1240,6 +1240,7 @@ public class Config extends HashMap<String, Object> {
      * The password of the keystore that the nimbus TLS server uses.
      */
     @IsString
+    @Password
     public static final String NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PASSWORD = 
"nimbus.thrift.tls.server.keystore.password";
 
     /**
@@ -1258,6 +1259,7 @@ public class Config extends HashMap<String, Object> {
      * The password of the truststore that the nimbus TLS server uses.
      */
     @IsString
+    @Password
     public static final String NIMBUS_THRIFT_TLS_SERVER_TRUSTSTORE_PASSWORD = 
"nimbus.thrift.tls.server.truststore.password";
 
     /**
@@ -1272,6 +1274,7 @@ public class Config extends HashMap<String, Object> {
      * The password of the keystore that the nimbus TLS client uses.
      */
     @IsString
+    @Password
     public static final String NIMBUS_THRIFT_TLS_CLIENT_KEYSTORE_PASSWORD = 
"nimbus.thrift.tls.client.keystore.password";
 
     /**
@@ -1296,6 +1299,7 @@ public class Config extends HashMap<String, Object> {
      * The password of the truststore that the nimbus TLS client uses.
      */
     @IsString
+    @Password
     public static final String NIMBUS_THRIFT_TLS_CLIENT_TRUSTSTORE_PASSWORD = 
"nimbus.thrift.tls.client.truststore.password";
 
     /**
@@ -1351,6 +1355,7 @@ public class Config extends HashMap<String, Object> {
      * The password of the keystore that the supervisor TLS server uses.
      */
     @IsString
+    @Password
     public static final String SUPERVISOR_THRIFT_TLS_SERVER_KEYSTORE_PASSWORD 
= "supervisor.thrift.tls.server.keystore.password";
 
     /**
@@ -1363,6 +1368,7 @@ public class Config extends HashMap<String, Object> {
      * The password of the truststore that the supervisor TLS server uses.
      */
     @IsString
+    @Password
     public static final String 
SUPERVISOR_THRIFT_TLS_SERVER_TRUSTSTORE_PASSWORD = 
"supervisor.thrift.tls.server.truststore.password";
 
     /**
@@ -1375,6 +1381,7 @@ public class Config extends HashMap<String, Object> {
      * The password of the keystore that the supervisor TLS client uses.
      */
     @IsString
+    @Password
     public static final String SUPERVISOR_THRIFT_TLS_CLIENT_KEYSTORE_PASSWORD 
= "supervisor.thrift.tls.client.keystore.password";
 
     /**
@@ -1387,6 +1394,7 @@ public class Config extends HashMap<String, Object> {
      * The password of the truststore that the supervisor TLS client uses.
      */
     @IsString
+    @Password
     public static final String 
SUPERVISOR_THRIFT_TLS_CLIENT_TRUSTSTORE_PASSWORD = 
"supervisor.thrift.tls.client.truststore.password";
 
     /**
@@ -1534,6 +1542,7 @@ public class Config extends HashMap<String, Object> {
      * Keystore password for ZooKeeper client connection over SSL.
      */
     @IsString
+    @Password
     public static final String STORM_ZOOKEEPER_SSL_KEYSTORE_PASSWORD = 
"storm.zookeeper.ssl.keystore.password";
     /**
      * Truststore location for ZooKeeper client connection over SSL.
@@ -1544,6 +1553,7 @@ public class Config extends HashMap<String, Object> {
      * Truststore password for ZooKeeper client connection over SSL.
      */
     @IsString
+    @Password
     public static final String STORM_ZOOKEEPER_SSL_TRUSTSTORE_PASSWORD = 
"storm.zookeeper.ssl.truststore.password";
     /**
      * Enable or disable hostname verification.
@@ -1735,6 +1745,7 @@ public class Config extends HashMap<String, Object> {
      * deny access from workers.
      */
     @IsString
+    @Password
     public static final String STORM_ZOOKEEPER_AUTH_PAYLOAD = 
"storm.zookeeper.auth.payload";
     /**
      * What Network Topography detection classes should we use. Given a list 
of supervisor hostnames (or IP addresses), this class would
@@ -1925,6 +1936,7 @@ public class Config extends HashMap<String, Object> {
      * Netty based messaging: Specifies the truststore password when TLS is 
enabled.
      */
     @IsString
+    @Password
     public static final String STORM_MESSAGING_NETTY_TLS_TRUSTSTORE_PASSWORD = 
"storm.messaging.netty.tls.truststore.password";
 
     /**
@@ -1937,6 +1949,7 @@ public class Config extends HashMap<String, Object> {
      * Netty based messaging: Specifies the keystore password when TLS is 
enabled.
      */
     @IsString
+    @Password
     public static final String STORM_MESSAGING_NETTY_TLS_KEYSTORE_PASSWORD = 
"storm.messaging.netty.tls.keystore.password";
 
     /**
@@ -1949,6 +1962,7 @@ public class Config extends HashMap<String, Object> {
      * Netty based messaging: Specifies the client truststore password when 
TLS is enabled.
      */
     @IsString
+    @Password
     public static final String 
STORM_MESSAGING_NETTY_TLS_CLIENT_TRUSTSTORE_PASSWORD =
         "storm.messaging.netty.tls.client.truststore.password";
 
@@ -1962,6 +1976,7 @@ public class Config extends HashMap<String, Object> {
      * Netty based messaging: Specifies the client keystore password when TLS 
is enabled.
      */
     @IsString
+    @Password
     public static final String 
STORM_MESSAGING_NETTY_TLS_CLIENT_KEYSTORE_PASSWORD =
         "storm.messaging.netty.tls.client.keystore.password";
 
diff --git a/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java 
b/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
index 663a79154..c78b38ff5 100644
--- a/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
+++ b/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
@@ -25,6 +25,7 @@ import java.util.Map;
 import java.util.Random;
 import java.util.Set;
 import java.util.function.BooleanSupplier;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import org.apache.storm.Config;
@@ -42,6 +43,7 @@ public class ConfigUtils {
     public static final double RFC1889_ALPHA = 1.0 / 16.0;
 
     private static final Set<String> passwordConfigKeys = new HashSet<>();
+    private static final Pattern CREDENTIAL_KEY_NAME = 
Pattern.compile("(?i)(password|passwd|secret)");
 
     static {
         for (Class<?> clazz : ConfigValidation.getConfigClasses()) {
@@ -89,6 +91,41 @@ public class ConfigUtils {
         return Maps.transformEntries(conf, maskPasswords);
     }
 
+    /**
+     * Mask credential values before a config map is served over an API. This 
covers what
+     * {@link #maskPasswords(Map)} covers, plus string values whose key name 
denotes a secret: plugins read their
+     * own keys straight out of the config map, so those keys are declared by 
no annotated field and the annotation
+     * scan cannot see them. Only string values are considered, so timeouts 
and class lists whose names merely
+     * mention credentials keep their value.
+     *
+     * @param conf the config to mask
+     * @return a view of the config with credential values replaced
+     */
+    public static Map<String, Object> maskCredentials(final Map<String, 
Object> conf) {
+        Maps.EntryTransformer<String, Object, Object> maskCredentials = new 
Maps.EntryTransformer<String, Object, Object>() {
+            @Override
+            public Object transformEntry(String key, Object value) {
+                if (passwordConfigKeys.contains(key)) {
+                    return "*****";
+                }
+                return value instanceof String && 
CREDENTIAL_KEY_NAME.matcher(key).find() ? "*****" : value;
+            }
+        };
+        return Maps.transformEntries(conf, maskCredentials);
+    }
+
+    /**
+     * Whether a config key holds a credential, and therefore whether {@link 
#maskCredentials(Map)} would replace its
+     * value. Callers that read a config back from a daemon use this to tell 
which entries carry no usable value and
+     * must be taken from their own configuration instead.
+     *
+     * @param key the config key
+     * @return true when the key denotes a credential
+     */
+    public static boolean isCredentialKey(String key) {
+        return passwordConfigKeys.contains(key) || 
CREDENTIAL_KEY_NAME.matcher(key).find();
+    }
+
     public static boolean isLocalMode(Map<String, Object> conf) {
         String mode = (String) conf.get(Config.STORM_CLUSTER_MODE);
         if (mode != null) {
diff --git a/storm-client/test/jvm/org/apache/storm/utils/ConfigUtilsTest.java 
b/storm-client/test/jvm/org/apache/storm/utils/ConfigUtilsTest.java
index 7a5bd14f5..9897eb75b 100644
--- a/storm-client/test/jvm/org/apache/storm/utils/ConfigUtilsTest.java
+++ b/storm-client/test/jvm/org/apache/storm/utils/ConfigUtilsTest.java
@@ -195,4 +195,118 @@ public class ConfigUtilsTest {
         assertThrows(IllegalArgumentException.class, () -> 
ConfigUtils.upstreamFeedbackFreqSecs(
             mockMap(Config.TOPOLOGY_UPSTREAM_FEEDBACK_FREQ_SECS, -1)));
     }
+
+    @Test
+    public void maskPasswords_masksClusterZookeeperCredentials() {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.STORM_ZOOKEEPER_AUTH_PAYLOAD, "zk-user:zk-secret");
+        conf.put(Config.STORM_ZOOKEEPER_TOPOLOGY_AUTH_PAYLOAD, 
"topo-user:topo-secret");
+
+        Map<String, Object> masked = ConfigUtils.maskPasswords(conf);
+
+        assertEquals("*****", masked.get(Config.STORM_ZOOKEEPER_AUTH_PAYLOAD));
+        assertEquals("*****", 
masked.get(Config.STORM_ZOOKEEPER_TOPOLOGY_AUTH_PAYLOAD));
+    }
+
+    @Test
+    public void maskPasswords_masksThriftTlsStorePasswords() {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PASSWORD, 
"nimbus-ks");
+        conf.put(Config.NIMBUS_THRIFT_TLS_SERVER_TRUSTSTORE_PASSWORD, 
"nimbus-ts");
+        conf.put(Config.NIMBUS_THRIFT_TLS_CLIENT_KEYSTORE_PASSWORD, 
"client-ks");
+        conf.put(Config.NIMBUS_THRIFT_TLS_CLIENT_TRUSTSTORE_PASSWORD, 
"client-ts");
+        conf.put(Config.SUPERVISOR_THRIFT_TLS_SERVER_KEYSTORE_PASSWORD, 
"sup-ks");
+        conf.put(Config.SUPERVISOR_THRIFT_TLS_SERVER_TRUSTSTORE_PASSWORD, 
"sup-ts");
+
+        Map<String, Object> masked = ConfigUtils.maskPasswords(conf);
+
+        assertEquals("*****", 
masked.get(Config.NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PASSWORD));
+        assertEquals("*****", 
masked.get(Config.NIMBUS_THRIFT_TLS_SERVER_TRUSTSTORE_PASSWORD));
+        assertEquals("*****", 
masked.get(Config.NIMBUS_THRIFT_TLS_CLIENT_KEYSTORE_PASSWORD));
+        assertEquals("*****", 
masked.get(Config.NIMBUS_THRIFT_TLS_CLIENT_TRUSTSTORE_PASSWORD));
+        assertEquals("*****", 
masked.get(Config.SUPERVISOR_THRIFT_TLS_SERVER_KEYSTORE_PASSWORD));
+        assertEquals("*****", 
masked.get(Config.SUPERVISOR_THRIFT_TLS_SERVER_TRUSTSTORE_PASSWORD));
+    }
+
+    @Test
+    public void maskPasswords_masksZookeeperAndNettyTlsStorePasswords() {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.STORM_ZOOKEEPER_SSL_KEYSTORE_PASSWORD, "zk-ks");
+        conf.put(Config.STORM_ZOOKEEPER_SSL_TRUSTSTORE_PASSWORD, "zk-ts");
+        conf.put(Config.STORM_MESSAGING_NETTY_TLS_KEYSTORE_PASSWORD, 
"netty-ks");
+        conf.put(Config.STORM_MESSAGING_NETTY_TLS_TRUSTSTORE_PASSWORD, 
"netty-ts");
+
+        Map<String, Object> masked = ConfigUtils.maskPasswords(conf);
+
+        assertEquals("*****", 
masked.get(Config.STORM_ZOOKEEPER_SSL_KEYSTORE_PASSWORD));
+        assertEquals("*****", 
masked.get(Config.STORM_ZOOKEEPER_SSL_TRUSTSTORE_PASSWORD));
+        assertEquals("*****", 
masked.get(Config.STORM_MESSAGING_NETTY_TLS_KEYSTORE_PASSWORD));
+        assertEquals("*****", 
masked.get(Config.STORM_MESSAGING_NETTY_TLS_TRUSTSTORE_PASSWORD));
+    }
+
+    @Test
+    public void maskCredentials_masksKeysThatOnlyPluginsDeclare() {
+        Map<String, Object> conf = new HashMap<>();
+        
conf.put("storm.daemon.metrics.reporter.plugin.prometheus.basic_auth_password", 
"plugin-secret");
+        conf.put("storm.zookeeper.auth.password", "zk-pass");
+        conf.put("some.plugin.shared_secret", "shared");
+
+        Map<String, Object> masked = ConfigUtils.maskCredentials(conf);
+
+        assertEquals("*****", 
masked.get("storm.daemon.metrics.reporter.plugin.prometheus.basic_auth_password"));
+        assertEquals("*****", masked.get("storm.zookeeper.auth.password"));
+        assertEquals("*****", masked.get("some.plugin.shared_secret"));
+    }
+
+    @Test
+    public void maskCredentials_masksTheAnnotatedKeysAsWell() {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.STORM_ZOOKEEPER_AUTH_PAYLOAD, "zk-user:zk-secret");
+
+        assertEquals("*****", 
ConfigUtils.maskCredentials(conf).get(Config.STORM_ZOOKEEPER_AUTH_PAYLOAD));
+    }
+
+    @Test
+    public void maskCredentials_leavesNonStringValuesAlone() {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put("task.credentials.poll.secs", 30);
+        conf.put("nimbus.credential.renewers.freq.secs", 600);
+        conf.put("topology.auto-credentials", 
Collections.singletonList("org.example.AutoCreds"));
+        conf.put("nimbus.seeds", Collections.singletonList("nimbus1"));
+
+        Map<String, Object> masked = ConfigUtils.maskCredentials(conf);
+
+        assertEquals(30, masked.get("task.credentials.poll.secs"));
+        assertEquals(600, masked.get("nimbus.credential.renewers.freq.secs"));
+        assertEquals(Collections.singletonList("org.example.AutoCreds"), 
masked.get("topology.auto-credentials"));
+        assertEquals(Collections.singletonList("nimbus1"), 
masked.get("nimbus.seeds"));
+    }
+
+    @Test
+    public void isCredentialKey_recognisesAnnotatedAndPluginDeclaredKeys() {
+        
assertTrue(ConfigUtils.isCredentialKey(Config.STORM_ZOOKEEPER_AUTH_PAYLOAD));
+        
assertTrue(ConfigUtils.isCredentialKey(Config.NIMBUS_THRIFT_TLS_CLIENT_KEYSTORE_PASSWORD));
+        
assertTrue(ConfigUtils.isCredentialKey("storm.daemon.metrics.reporter.plugin.prometheus.basic_auth_password"));
+        assertTrue(ConfigUtils.isCredentialKey("some.plugin.shared_secret"));
+    }
+
+    @Test
+    public void isCredentialKey_ignoresKeysThatOnlyMentionCredentials() {
+        assertFalse(ConfigUtils.isCredentialKey("task.credentials.poll.secs"));
+        
assertFalse(ConfigUtils.isCredentialKey(Config.TOPOLOGY_AUTO_CREDENTIALS));
+        
assertFalse(ConfigUtils.isCredentialKey(Config.NIMBUS_THRIFT_TLS_CLIENT_KEYSTORE_PATH));
+        assertFalse(ConfigUtils.isCredentialKey(Config.TOPOLOGY_NAME));
+    }
+
+    @Test
+    public void maskPasswords_keepsOrdinaryValues() {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(Config.STORM_ZOOKEEPER_SERVERS, 
Collections.singletonList("zk1"));
+        conf.put(Config.NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PATH, 
"/etc/storm/nimbus.jks");
+
+        Map<String, Object> masked = ConfigUtils.maskPasswords(conf);
+
+        assertEquals(Collections.singletonList("zk1"), 
masked.get(Config.STORM_ZOOKEEPER_SERVERS));
+        assertEquals("/etc/storm/nimbus.jks", 
masked.get(Config.NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PATH));
+    }
 }
diff --git a/storm-core/src/jvm/org/apache/storm/command/UploadCredentials.java 
b/storm-core/src/jvm/org/apache/storm/command/UploadCredentials.java
index d68917217..12d76cefe 100644
--- a/storm-core/src/jvm/org/apache/storm/command/UploadCredentials.java
+++ b/storm-core/src/jvm/org/apache/storm/command/UploadCredentials.java
@@ -24,6 +24,7 @@ import org.apache.storm.Config;
 import org.apache.storm.StormSubmitter;
 import org.apache.storm.generated.Nimbus;
 import org.apache.storm.generated.TopologySummary;
+import org.apache.storm.utils.ConfigUtils;
 import org.apache.storm.utils.NimbusClient;
 import org.apache.storm.utils.Utils;
 import org.slf4j.Logger;
@@ -113,6 +114,9 @@ public class UploadCredentials {
          */
         topologyConf.remove("java.security.auth.login.config");
         topologyConf.remove(Config.NIMBUS_THRIFT_CLIENT_USE_TLS);
+        // Nimbus masks credentials before serving a conf, so these entries 
hold no usable value here.
+        // Dropping them lets the client's own configuration supply them, e.g. 
TLS store passwords.
+        topologyConf.keySet().removeIf(ConfigUtils::isCredentialKey);
 
         boolean throwExceptionForEmptyCreds = (boolean) cl.get("e");
         boolean hasCreds = StormSubmitter.pushCredentials(topologyName, 
topologyConf, credentialsMap, (String) cl.get("u"));
diff --git 
a/storm-server/src/main/java/org/apache/storm/daemon/nimbus/Nimbus.java 
b/storm-server/src/main/java/org/apache/storm/daemon/nimbus/Nimbus.java
index 4469f8dfa..1698a5ce8 100644
--- a/storm-server/src/main/java/org/apache/storm/daemon/nimbus/Nimbus.java
+++ b/storm-server/src/main/java/org/apache/storm/daemon/nimbus/Nimbus.java
@@ -4468,7 +4468,7 @@ public class Nimbus implements Iface, Shutdownable, 
DaemonCommon {
         try {
             getNimbusConfCalls.mark();
             checkAuthorization(null, null, "getNimbusConf");
-            return JSONValue.toJSONString(conf);
+            return JSONValue.toJSONString(ConfigUtils.maskCredentials(conf));
         } catch (Exception e) {
             LOG.warn("get nimbus conf exception.", e);
             if (e instanceof TException) {
diff --git 
a/storm-server/src/test/java/org/apache/storm/daemon/nimbus/NimbusGetNimbusConfTest.java
 
b/storm-server/src/test/java/org/apache/storm/daemon/nimbus/NimbusGetNimbusConfTest.java
new file mode 100644
index 000000000..c348697c8
--- /dev/null
+++ 
b/storm-server/src/test/java/org/apache/storm/daemon/nimbus/NimbusGetNimbusConfTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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.storm.daemon.nimbus;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import net.minidev.json.JSONValue;
+import org.apache.storm.Config;
+import org.apache.storm.DaemonConfig;
+import org.apache.storm.LocalCluster;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class NimbusGetNimbusConfTest {
+
+    private static final String MASKED = "*****";
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> parse(String json) {
+        return (Map<String, Object>) JSONValue.parse(json);
+    }
+
+    @Test
+    public void getNimbusConfMasksCredentialsAndKeepsOtherValues() throws 
Exception {
+        Map<String, Object> daemonConf = new HashMap<>();
+        daemonConf.put(DaemonConfig.NIMBUS_AUTHORIZER, 
"org.apache.storm.security.auth.authorizer.NoopAuthorizer");
+        daemonConf.put(DaemonConfig.SUPERVISOR_AUTHORIZER, 
"org.apache.storm.security.auth.authorizer.NoopAuthorizer");
+        daemonConf.put(Config.STORM_ZOOKEEPER_AUTH_PAYLOAD, 
"zk-user:zk-secret");
+        daemonConf.put(Config.NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PASSWORD, 
"nimbus-keystore-secret");
+        daemonConf.put(DaemonConfig.UI_HTTPS_KEYSTORE_PASSWORD, 
"ui-keystore-secret");
+        daemonConf.put(Config.STORM_ZOOKEEPER_SSL_KEYSTORE_PASSWORD, 
"zk-ssl-keystore-secret");
+        
daemonConf.put("storm.daemon.metrics.reporter.plugin.prometheus.basic_auth_password",
 "plugin-secret");
+        daemonConf.put(Config.NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PATH, 
"/etc/storm/nimbus.jks");
+
+        try (LocalCluster cluster = new 
LocalCluster.Builder().withDaemonConf(daemonConf).build()) {
+            Map<String, Object> served = 
parse(cluster.getNimbus().getNimbusConf());
+
+            assertEquals(MASKED, 
served.get(Config.STORM_ZOOKEEPER_AUTH_PAYLOAD),
+                "the cluster ZooKeeper auth payload should be masked");
+            assertEquals(MASKED, 
served.get(Config.NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PASSWORD),
+                "thrift TLS store passwords should be masked");
+            assertEquals(MASKED, 
served.get(DaemonConfig.UI_HTTPS_KEYSTORE_PASSWORD),
+                "UI keystore passwords should be masked");
+
+            assertEquals(MASKED, 
served.get(Config.STORM_ZOOKEEPER_SSL_KEYSTORE_PASSWORD),
+                "ZooKeeper TLS store passwords should be masked");
+            assertEquals(MASKED, 
served.get("storm.daemon.metrics.reporter.plugin.prometheus.basic_auth_password"),
+                "credential keys that only a plugin declares should be masked 
too");
+
+            assertEquals("/etc/storm/nimbus.jks", 
served.get(Config.NIMBUS_THRIFT_TLS_SERVER_KEYSTORE_PATH),
+                "non-credential values should be served unchanged");
+        }
+    }
+}
diff --git 
a/storm-webapp/src/main/java/org/apache/storm/daemon/ui/filters/AuthorizedUserFilter.java
 
b/storm-webapp/src/main/java/org/apache/storm/daemon/ui/filters/AuthorizedUserFilter.java
index 6dd17755f..f26175892 100644
--- 
a/storm-webapp/src/main/java/org/apache/storm/daemon/ui/filters/AuthorizedUserFilter.java
+++ 
b/storm-webapp/src/main/java/org/apache/storm/daemon/ui/filters/AuthorizedUserFilter.java
@@ -27,6 +27,7 @@ import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.ext.Provider;
 import java.io.IOException;
 import java.io.InputStream;
+import java.lang.reflect.Method;
 import java.net.InetAddress;
 import java.security.Principal;
 import java.util.Map;
@@ -39,6 +40,7 @@ import org.apache.storm.daemon.common.JsonResponseBuilder;
 import org.apache.storm.daemon.ui.UIHelpers;
 import org.apache.storm.daemon.ui.resources.AuthNimbusOp;
 import org.apache.storm.daemon.ui.resources.StormApiResource;
+import org.apache.storm.daemon.ui.resources.UnauthenticatedNimbusOp;
 import org.apache.storm.generated.AuthorizationException;
 import org.apache.storm.security.auth.IAuthorizer;
 import org.apache.storm.security.auth.ReqContext;
@@ -100,12 +102,26 @@ public class AuthorizedUserFilter implements 
ContainerRequestFilter {
 
     @Override
     public void filter(ContainerRequestContext containerRequestContext) {
-        AuthNimbusOp annotation = 
resourceInfo.getResourceMethod().getAnnotation(AuthNimbusOp.class);
+        Method resourceMethod = resourceInfo.getResourceMethod();
+        AuthNimbusOp annotation = 
resourceMethod.getAnnotation(AuthNimbusOp.class);
         if (annotation == null) {
+            if (resourceMethod.getAnnotation(UnauthenticatedNimbusOp.class) != 
null) {
+                return;
+            }
+            LOG.error("Endpoint {}.{} declares no authorization; rejecting the 
request.",
+                    resourceMethod.getDeclaringClass().getName(), 
resourceMethod.getName());
+            containerRequestContext.abortWith(
+                    makeResponse(new AuthorizationException("UI request is not 
authorized"),
+                            containerRequestContext, 403)
+            );
             return;
         }
         String op = annotation.value();
         if (op == null) {
+            containerRequestContext.abortWith(
+                    makeResponse(new AuthorizationException("UI request is not 
authorized"),
+                            containerRequestContext, 403)
+            );
             return;
         }
 
diff --git 
a/storm-webapp/src/main/java/org/apache/storm/daemon/ui/resources/StormApiResource.java
 
b/storm-webapp/src/main/java/org/apache/storm/daemon/ui/resources/StormApiResource.java
index 62b461992..c84f86f8b 100644
--- 
a/storm-webapp/src/main/java/org/apache/storm/daemon/ui/resources/StormApiResource.java
+++ 
b/storm-webapp/src/main/java/org/apache/storm/daemon/ui/resources/StormApiResource.java
@@ -107,6 +107,7 @@ public class StormApiResource {
 
     @GET
     @Path("/cluster/configuration")
+    @AuthNimbusOp("getNimbusConf")
     @Produces("application/json")
     public Response getClusterConfiguration(@QueryParam(callbackParameterName) 
String callback) throws TException {
         clusterConfigurationRequestMeter.mark();
@@ -198,6 +199,7 @@ public class StormApiResource {
      */
     @GET
     @Path("/history/summary")
+    @UnauthenticatedNimbusOp("Nimbus filters the history for the authenticated 
remote user")
     @Produces("application/json")
     public Response getHistorySummary(@QueryParam(callbackParameterName) 
String callback) throws TException {
         try (NimbusClient nimbusClient = 
NimbusClient.Builder.withConf(config).build()) {
diff --git 
a/storm-webapp/src/main/java/org/apache/storm/daemon/ui/resources/UnauthenticatedNimbusOp.java
 
b/storm-webapp/src/main/java/org/apache/storm/daemon/ui/resources/UnauthenticatedNimbusOp.java
new file mode 100644
index 000000000..21024c82f
--- /dev/null
+++ 
b/storm-webapp/src/main/java/org/apache/storm/daemon/ui/resources/UnauthenticatedNimbusOp.java
@@ -0,0 +1,39 @@
+/*
+ * 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.storm.daemon.ui.resources;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Marks an endpoint that deliberately carries no {@link AuthNimbusOp} gate, 
because the data it
+ * returns is already restricted for the requesting user further down the call 
chain. Endpoints
+ * without either annotation are rejected.
+ */
+@Documented
+@Retention(value = RetentionPolicy.RUNTIME)
+public @interface UnauthenticatedNimbusOp {
+    /**
+     * Why this endpoint needs no operation gate.
+     *
+     * @return the justification
+     */
+    String value();
+}
diff --git 
a/storm-webapp/src/test/java/org/apache/storm/daemon/ui/filters/AuthorizedUserFilterTest.java
 
b/storm-webapp/src/test/java/org/apache/storm/daemon/ui/filters/AuthorizedUserFilterTest.java
new file mode 100644
index 000000000..5e9e2c5c1
--- /dev/null
+++ 
b/storm-webapp/src/test/java/org/apache/storm/daemon/ui/filters/AuthorizedUserFilterTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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.storm.daemon.ui.filters;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ResourceInfo;
+import jakarta.ws.rs.core.Response;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Map;
+import org.apache.storm.daemon.ui.resources.AuthNimbusOp;
+import org.apache.storm.daemon.ui.resources.UnauthenticatedNimbusOp;
+import org.apache.storm.security.auth.IAuthorizer;
+import org.apache.storm.security.auth.ReqContext;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+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;
+
+public class AuthorizedUserFilterTest {
+
+    /**
+     * Stand-in for a resource class: one endpoint gated by an operation, one 
explicitly opted out,
+     * and one carrying no annotation at all.
+     */
+    public static class SampleResource {
+        @AuthNimbusOp("getNimbusConf")
+        public void gated() {
+        }
+
+        @UnauthenticatedNimbusOp("filtered by Nimbus using the authenticated 
remote user")
+        public void optedOut() {
+        }
+
+        public void unannotated() {
+        }
+    }
+
+    private static AuthorizedUserFilter filterFor(String methodName, 
IAuthorizer aclHandler) throws Exception {
+        Method method = SampleResource.class.getMethod(methodName);
+        ResourceInfo resourceInfo = mock(ResourceInfo.class);
+        when(resourceInfo.getResourceMethod()).thenReturn(method);
+
+        AuthorizedUserFilter filter = new AuthorizedUserFilter();
+        Field field = 
AuthorizedUserFilter.class.getDeclaredField("resourceInfo");
+        field.setAccessible(true);
+        field.set(filter, resourceInfo);
+
+        AuthorizedUserFilter.uiAclHandler = aclHandler;
+        AuthorizedUserFilter.uiImpersonationHandler = null;
+        return filter;
+    }
+
+    private static int statusOfAbort(ContainerRequestContext request) {
+        ArgumentCaptor<Response> response = 
ArgumentCaptor.forClass(Response.class);
+        verify(request).abortWith(response.capture());
+        return response.getValue().getStatus();
+    }
+
+    @Test
+    public void unannotatedEndpointIsDenied() throws Exception {
+        IAuthorizer aclHandler = mock(IAuthorizer.class);
+        when(aclHandler.permit(any(), any(), any())).thenReturn(true);
+        AuthorizedUserFilter filter = filterFor("unannotated", aclHandler);
+        ContainerRequestContext request = mock(ContainerRequestContext.class);
+
+        filter.filter(request);
+
+        assertEquals(403, statusOfAbort(request));
+        verify(aclHandler, never()).permit(any(), any(), any());
+    }
+
+    @Test
+    public void annotatedEndpointIsCheckedAgainstItsOperation() throws 
Exception {
+        IAuthorizer aclHandler = mock(IAuthorizer.class);
+        when(aclHandler.permit(any(ReqContext.class), eq("getNimbusConf"), 
any())).thenReturn(false);
+        AuthorizedUserFilter filter = filterFor("gated", aclHandler);
+        ContainerRequestContext request = mock(ContainerRequestContext.class);
+
+        filter.filter(request);
+
+        assertEquals(403, statusOfAbort(request));
+        verify(aclHandler).permit(any(ReqContext.class), eq("getNimbusConf"), 
any());
+    }
+
+    @Test
+    public void permittedEndpointIsNotAborted() throws Exception {
+        IAuthorizer aclHandler = mock(IAuthorizer.class);
+        when(aclHandler.permit(any(ReqContext.class), eq("getNimbusConf"), 
any())).thenReturn(true);
+        AuthorizedUserFilter filter = filterFor("gated", aclHandler);
+        ContainerRequestContext request = mock(ContainerRequestContext.class);
+
+        filter.filter(request);
+
+        verify(request, never()).abortWith(any());
+    }
+
+    @Test
+    public void explicitlyOptedOutEndpointSkipsTheAclHandler() throws 
Exception {
+        IAuthorizer aclHandler = mock(IAuthorizer.class);
+        AuthorizedUserFilter filter = filterFor("optedOut", aclHandler);
+        ContainerRequestContext request = mock(ContainerRequestContext.class);
+
+        filter.filter(request);
+
+        verify(request, never()).abortWith(any());
+        verify(aclHandler, never()).permit(any(), any(), any());
+    }
+
+    @Test
+    public void unannotatedEndpointIsDeniedEvenWithoutAnAclHandler() throws 
Exception {
+        AuthorizedUserFilter filter = filterFor("unannotated", null);
+        ContainerRequestContext request = mock(ContainerRequestContext.class);
+
+        filter.filter(request);
+
+        assertEquals(403, statusOfAbort(request));
+    }
+
+    @Test
+    public void everyApiEndpointDeclaresItsAuthorization() throws Exception {
+        Class<?> resource = 
Class.forName("org.apache.storm.daemon.ui.resources.StormApiResource");
+        for (Method method : resource.getDeclaredMethods()) {
+            if (method.getAnnotation(jakarta.ws.rs.GET.class) == null
+                && method.getAnnotation(jakarta.ws.rs.POST.class) == null) {
+                continue;
+            }
+            boolean declared = method.getAnnotation(AuthNimbusOp.class) != null
+                || method.getAnnotation(UnauthenticatedNimbusOp.class) != null;
+            assertEquals(true, declared,
+                method.getName() + " must declare @AuthNimbusOp or 
@UnauthenticatedNimbusOp");
+        }
+    }
+
+    @Test
+    public void clusterConfigurationRequiresTheNimbusConfOperation() throws 
Exception {
+        Method method = 
Class.forName("org.apache.storm.daemon.ui.resources.StormApiResource")
+            .getMethod("getClusterConfiguration", String.class);
+
+        AuthNimbusOp annotation = method.getAnnotation(AuthNimbusOp.class);
+        assertEquals("getNimbusConf", annotation == null ? null : 
annotation.value());
+    }
+
+    @Test
+    public void topologyIdLookupIsSkippedForOptedOutEndpoints() throws 
Exception {
+        AuthorizedUserFilter filter = filterFor("optedOut", 
mock(IAuthorizer.class));
+        ContainerRequestContext request = mock(ContainerRequestContext.class);
+
+        filter.filter(request);
+
+        verify(request, never()).getUriInfo();
+    }
+
+    @Test
+    public void aclHandlerReceivesNullTopologyConfForNonTopologyOperations() 
throws Exception {
+        IAuthorizer aclHandler = mock(IAuthorizer.class);
+        when(aclHandler.permit(any(), any(), any())).thenReturn(true);
+        AuthorizedUserFilter filter = filterFor("gated", aclHandler);
+
+        filter.filter(mock(ContainerRequestContext.class));
+
+        ArgumentCaptor<Map> topoConf = ArgumentCaptor.forClass(Map.class);
+        verify(aclHandler).permit(any(ReqContext.class), eq("getNimbusConf"), 
topoConf.capture());
+        assertEquals(null, topoConf.getValue());
+    }
+}

Reply via email to