This is an automated email from the ASF dual-hosted git repository.
wuchong pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git
The following commit(s) were added to refs/heads/main by this push:
new 3acc1c14a [server] Manage multiple users for sasl/plain authentication
via cluster properties (#3047)
3acc1c14a is described below
commit 3acc1c14acf72351bff13f171ea287a0f074076a
Author: Hongshun Wang <[email protected]>
AuthorDate: Fri Jul 10 14:07:35 2026 +0800
[server] Manage multiple users for sasl/plain authentication via cluster
properties (#3047)
---
.../org/apache/fluss/config/ConfigOptions.java | 30 ++
.../apache/fluss/security/acl/FlussPrincipal.java | 28 ++
.../procedure/AppendClusterConfigsProcedure.java | 67 +++++
.../CollectionClusterConfigsProcedureBase.java | 79 ++++++
.../fluss/flink/procedure/ProcedureManager.java | 3 +
.../procedure/SubtractClusterConfigsProcedure.java | 71 +++++
.../flink/procedure/FlinkProcedureITCase.java | 211 +++++++++++++-
.../main/java/org/apache/fluss/rpc/RpcServer.java | 7 +
.../rpc/netty/server/FlussProtocolPlugin.java | 164 ++++++++++-
.../apache/fluss/rpc/netty/server/NettyServer.java | 9 +
.../rpc/netty/authenticate/AuthenticationTest.java | 6 +-
.../authenticate/SaslAuthenticationITCase.java | 311 +++++++++++++++++++++
.../apache/fluss/server/DynamicConfigManager.java | 191 ++++++++++++-
.../apache/fluss/server/DynamicServerConfig.java | 32 ++-
.../fluss/server/authorizer/DefaultAuthorizer.java | 21 +-
.../apache/fluss/server/config/ConfigRedactor.java | 34 +++
.../fluss/server/config/ConfigRedactors.java | 39 +++
.../fluss/server/config/MapConfigRedactor.java | 61 ++++
.../fluss/server/config/ValueConfigRedactor.java | 42 +++
.../server/coordinator/CoordinatorServer.java | 18 +-
.../apache/fluss/server/tablet/TabletServer.java | 28 +-
.../fluss/server/DynamicConfigChangeTest.java | 243 ++++++++++++++++
.../server/authorizer/DefaultAuthorizerTest.java | 35 ++-
website/docs/engine-flink/procedures.md | 74 ++++-
.../maintenance/operations/updating-configs.md | 2 +-
25 files changed, 1752 insertions(+), 54 deletions(-)
diff --git
a/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
b/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
index 2f6617b20..73eccf1b7 100644
--- a/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
+++ b/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
@@ -298,6 +298,16 @@ public class ConfigOptions {
+ "and each super user should be specified
in the format `principal_type:principal_name`, e.g., `User:admin;User:bob`. "
+ "This configuration is critical for
defining administrative privileges in the system.");
+ public static final ConfigOption<Boolean>
SECURITY_ACL_PRINCIPAL_IGNORE_CASE =
+ key("security.acl.principal.ignore-case")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Whether to perform case-insensitive matching on
principal name and type "
+ + "during ACL authorization checks. When
set to true, principals "
+ + "such as 'User:Admin' and 'user:admin'
will be treated as the same principal. "
+ + "Default is false for strict
case-sensitive matching.");
+
public static final ConfigOption<Integer> MAX_BUCKET_NUM =
key("max.bucket.num")
.intType()
@@ -533,6 +543,26 @@ public class ConfigOptions {
+ "Each listener can be associated with a
specific authentication protocol. "
+ "Listeners not included in the map will
use PLAINTEXT by default, which does not require authentication.");
+ public static final ConfigOption<Map<String, String>>
SERVER_SASL_CREDENTIALS =
+ key("security.sasl.plain.credentials")
+ .mapType()
+ .noDefaultValue()
+ .withDescription(
+ "Map of user credentials for SASL/PLAIN
authentication in 'username:password' format. "
+ + "For example:
'admin:admin-secret,bob:bob-secret'. "
+ + "This is syntactic sugar that
auto-generates the JAAS config string.");
+
+ public static final ConfigOption<String> SERVER_SASL_PLAIN_JAAS_CONFIG =
+ key("security.sasl.plain.jaas.config")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "JAAS configuration string for server-side
SASL/PLAIN authentication. "
+ + "The value should use PlainLoginModule
and define users with "
+ + "'user_<username>=\"<password>\"'
options. This option is generated "
+ + "from 'security.sasl.plain.credentials'
when that credential map is set, "
+ + "and can also be configured directly for
compatibility.");
+
public static final ConfigOption<Integer> TABLET_SERVER_ID =
key("tablet-server.id")
.intType()
diff --git
a/fluss-common/src/main/java/org/apache/fluss/security/acl/FlussPrincipal.java
b/fluss-common/src/main/java/org/apache/fluss/security/acl/FlussPrincipal.java
index 6084ec1a2..7a60e9c7b 100644
---
a/fluss-common/src/main/java/org/apache/fluss/security/acl/FlussPrincipal.java
+++
b/fluss-common/src/main/java/org/apache/fluss/security/acl/FlussPrincipal.java
@@ -79,6 +79,34 @@ public class FlussPrincipal implements Principal {
return Objects.hash(name, type);
}
+ /**
+ * Tests whether this principal matches another principal, with optional
case-insensitive
+ * comparison for both name and type.
+ *
+ * @param other the other principal to match against
+ * @param ignoreCase if {@code true}, name and type are compared
case-insensitively
+ * @return {@code true} if the principals match
+ */
+ public boolean matches(FlussPrincipal other, boolean ignoreCase) {
+ if (other == null) {
+ return false;
+ }
+ if (!ignoreCase) {
+ return this.equals(other);
+ }
+ return equalsIgnoreCase(name, other.name) && equalsIgnoreCase(type,
other.type);
+ }
+
+ private static boolean equalsIgnoreCase(String a, String b) {
+ if (a == b) {
+ return true;
+ }
+ if (a == null || b == null) {
+ return false;
+ }
+ return a.equalsIgnoreCase(b);
+ }
+
@Override
public String toString() {
return "FlussPrincipal{" + "name='" + name + '\'' + ", type='" + type
+ '\'' + '}';
diff --git
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/AppendClusterConfigsProcedure.java
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/AppendClusterConfigsProcedure.java
new file mode 100644
index 000000000..2ccdc820f
--- /dev/null
+++
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/AppendClusterConfigsProcedure.java
@@ -0,0 +1,67 @@
+/*
+ * 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.fluss.flink.procedure;
+
+import org.apache.fluss.config.cluster.AlterConfigOpType;
+
+import org.apache.flink.table.annotation.ArgumentHint;
+import org.apache.flink.table.annotation.DataTypeHint;
+import org.apache.flink.table.annotation.ProcedureHint;
+import org.apache.flink.table.procedure.ProcedureContext;
+
+/**
+ * Procedure to append values to collection-type cluster configurations
dynamically.
+ *
+ * <p>This procedure appends new values to existing list-type or map-type
configurations. The APPEND
+ * operation only works on collection configurations (e.g., {@code
+ * security.sasl.plain.credentials}). The changes are:
+ *
+ * <ul>
+ * <li>Validated by the CoordinatorServer before persistence
+ * <li>Persisted in ZooKeeper for durability
+ * <li>Applied to all relevant servers (Coordinator and TabletServers)
+ * <li>Survives server restarts
+ * </ul>
+ *
+ * <p>Usage examples:
+ *
+ * <pre>
+ * -- Append a user to the SASL credentials map
+ * CALL sys.append_cluster_configs('security.sasl.plain.credentials',
'bob:bob-secret');
+ *
+ * -- Append multiple key-value pairs at one time
+ * CALL sys.append_cluster_configs(
+ * 'security.sasl.plain.credentials',
+ * 'bob:bob-secret',
+ * 'security.sasl.plain.credentials',
+ * 'alice:alice-secret');
+ * </pre>
+ *
+ * <p><b>Note:</b> APPEND operations are only supported for list-type or
map-type configuration
+ * keys. The server will reject the change if the configuration key is not a
collection type.
+ */
+public class AppendClusterConfigsProcedure extends
CollectionClusterConfigsProcedureBase {
+
+ @ProcedureHint(
+ argument = {@ArgumentHint(name = "config_pairs", type =
@DataTypeHint("STRING"))},
+ isVarArgs = true)
+ public String[] call(ProcedureContext context, String... configPairs)
throws Exception {
+ return alterCollectionClusterConfigs(
+ configPairs, AlterConfigOpType.APPEND, "appended", "to",
"append");
+ }
+}
diff --git
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/CollectionClusterConfigsProcedureBase.java
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/CollectionClusterConfigsProcedureBase.java
new file mode 100644
index 000000000..8479a2847
--- /dev/null
+++
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/CollectionClusterConfigsProcedureBase.java
@@ -0,0 +1,79 @@
+/*
+ * 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.fluss.flink.procedure;
+
+import org.apache.fluss.config.cluster.AlterConfig;
+import org.apache.fluss.config.cluster.AlterConfigOpType;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Base procedure for modifying collection-type cluster configurations. */
+abstract class CollectionClusterConfigsProcedureBase extends ProcedureBase {
+
+ protected String[] alterCollectionClusterConfigs(
+ String[] configPairs,
+ AlterConfigOpType opType,
+ String successVerb,
+ String successPreposition,
+ String failureVerb)
+ throws Exception {
+ try {
+ if (configPairs == null || configPairs.length == 0) {
+ throw new IllegalArgumentException(
+ "config_pairs cannot be null or empty. "
+ + "Please specify valid configuration pairs.");
+ }
+
+ if (configPairs.length % 2 != 0) {
+ throw new IllegalArgumentException(
+ "config_pairs must be set in pairs. "
+ + "Please specify valid configuration pairs.");
+ }
+
+ List<AlterConfig> alterConfigs = new ArrayList<>();
+ List<String> resultMessages = new ArrayList<>();
+
+ for (int i = 0; i < configPairs.length; i += 2) {
+ String configKey = configPairs[i].trim();
+ if (configKey.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Config key cannot be null or empty. "
+ + "Please specify a valid configuration
key.");
+ }
+ String configValue = configPairs[i + 1];
+
+ alterConfigs.add(new AlterConfig(configKey, configValue,
opType));
+ resultMessages.add(
+ String.format(
+ "Successfully %s '%s' %s configuration '%s'. ",
+ successVerb, configValue, successPreposition,
configKey));
+ }
+
+ admin.alterClusterConfigs(alterConfigs).get();
+
+ return resultMessages.toArray(new String[0]);
+ } catch (IllegalArgumentException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException(
+ String.format("Failed to %s cluster config: %s",
failureVerb, e.getMessage()),
+ e);
+ }
+ }
+}
diff --git
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/ProcedureManager.java
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/ProcedureManager.java
index fc2632e85..654f707c2 100644
---
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/ProcedureManager.java
+++
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/ProcedureManager.java
@@ -73,6 +73,9 @@ public class ProcedureManager {
SET_CLUSTER_CONFIGS("sys.set_cluster_configs",
SetClusterConfigsProcedure.class),
GET_CLUSTER_CONFIGS("sys.get_cluster_configs",
GetClusterConfigsProcedure.class),
RESET_CLUSTER_CONFIGS("sys.reset_cluster_configs",
ResetClusterConfigsProcedure.class),
+ APPEND_CLUSTER_CONFIGS("sys.append_cluster_configs",
AppendClusterConfigsProcedure.class),
+ SUBTRACT_CLUSTER_CONFIGS(
+ "sys.subtract_cluster_configs",
SubtractClusterConfigsProcedure.class),
ADD_SERVER_TAG("sys.add_server_tag", AddServerTagProcedure.class),
REMOVE_SERVER_TAG("sys.remove_server_tag",
RemoveServerTagProcedure.class),
REBALANCE("sys.rebalance", RebalanceProcedure.class),
diff --git
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/SubtractClusterConfigsProcedure.java
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/SubtractClusterConfigsProcedure.java
new file mode 100644
index 000000000..9e6de7fb3
--- /dev/null
+++
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/SubtractClusterConfigsProcedure.java
@@ -0,0 +1,71 @@
+/*
+ * 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.fluss.flink.procedure;
+
+import org.apache.fluss.config.cluster.AlterConfigOpType;
+
+import org.apache.flink.table.annotation.ArgumentHint;
+import org.apache.flink.table.annotation.DataTypeHint;
+import org.apache.flink.table.annotation.ProcedureHint;
+import org.apache.flink.table.procedure.ProcedureContext;
+
+/**
+ * Procedure to subtract (remove) values from collection-type cluster
configurations dynamically.
+ *
+ * <p>This procedure removes values from existing collection configurations.
For list-type
+ * configurations, SUBTRACT removes the exact list value. For map-type
configurations, SUBTRACT
+ * removes the entry by map key: the supplied value must still be a valid
{@code key:value} pair,
+ * but only the key is used to find the entry to remove. The SUBTRACT
operation only works on
+ * collection configurations (e.g., {@code security.sasl.plain.credentials}).
If the collection
+ * becomes empty after subtraction, the configuration key is removed entirely.
The changes are:
+ *
+ * <ul>
+ * <li>Validated by the CoordinatorServer before persistence
+ * <li>Persisted in ZooKeeper for durability
+ * <li>Applied to all relevant servers (Coordinator and TabletServers)
+ * <li>Survives server restarts
+ * </ul>
+ *
+ * <p>Usage examples:
+ *
+ * <pre>
+ * -- Remove user "bob" from the SASL credentials map. For map configs, only
"bob" is matched.
+ * CALL sys.subtract_cluster_configs('security.sasl.plain.credentials',
'bob:any-secret');
+ *
+ * -- Remove multiple key-value pairs at one time
+ * CALL sys.subtract_cluster_configs(
+ * 'security.sasl.plain.credentials',
+ * 'bob:bob-secret',
+ * 'security.sasl.plain.credentials',
+ * 'alice:alice-secret');
+ * </pre>
+ *
+ * <p><b>Note:</b> SUBTRACT operations are only supported for list-type or
map-type configuration
+ * keys. The server will reject the change if the configuration key is not a
collection type.
+ * Subtracting a list value or map key that does not exist in the collection
is a no-op.
+ */
+public class SubtractClusterConfigsProcedure extends
CollectionClusterConfigsProcedureBase {
+
+ @ProcedureHint(
+ argument = {@ArgumentHint(name = "config_pairs", type =
@DataTypeHint("STRING"))},
+ isVarArgs = true)
+ public String[] call(ProcedureContext context, String... configPairs)
throws Exception {
+ return alterCollectionClusterConfigs(
+ configPairs, AlterConfigOpType.SUBTRACT, "subtracted", "from",
"subtract");
+ }
+}
diff --git
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/FlinkProcedureITCase.java
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/FlinkProcedureITCase.java
index 8b2817e32..5dfaf3f7f 100644
---
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/FlinkProcedureITCase.java
+++
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/FlinkProcedureITCase.java
@@ -63,6 +63,7 @@ import static
org.apache.fluss.server.testutils.FlussClusterExtension.BUILTIN_DA
import static org.apache.fluss.testutils.DataTestUtils.row;
import static org.apache.fluss.testutils.common.CommonTestUtils.retry;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** ITCase for Flink Procedure. */
@@ -139,6 +140,8 @@ public abstract class FlinkProcedureITCase {
"+I[sys.list_acl]",
"+I[sys.set_cluster_configs]",
"+I[sys.reset_cluster_configs]",
+ "+I[sys.append_cluster_configs]",
+ "+I[sys.subtract_cluster_configs]",
"+I[sys.add_server_tag]",
"+I[sys.remove_server_tag]",
"+I[sys.rebalance]",
@@ -783,6 +786,209 @@ public abstract class FlinkProcedureITCase {
});
}
+ @Test
+ void testAddAndDeleteUser() throws Exception {
+ String bobBootstrapServers =
+ String.join(
+ ",",
+ FLUSS_CLUSTER_EXTENSION
+ .getClientConfig("CLIENT")
+ .get(ConfigOptions.BOOTSTRAP_SERVERS));
+ String bobCatalog = "bob_catalog";
+ String createCatalogDDL =
+ String.format(
+ "create catalog %s with ("
+ + "'type' = 'fluss', "
+ + "'bootstrap.servers' = '%s', "
+ + "'client.security.protocol' = 'sasl', "
+ + "'client.security.sasl.mechanism' = 'PLAIN',
"
+ + "'client.security.sasl.username' = 'bob', "
+ + "'client.security.sasl.password' =
'bob_pass'"
+ + ")",
+ bobCatalog, bobBootstrapServers);
+
+ assertThatThrownBy(() -> tEnv.executeSql(createCatalogDDL).await())
+ .hasMessageContaining("Invalid username or password");
+
+ // Step 1: Add user "bob" via append_cluster_configs
+ try (CloseableIterator<Row> resultIterator =
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.append_cluster_configs('%s', 'bob:bob_pass')",
+ CATALOG_NAME,
ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .collect()) {
+ List<Row> results = CollectionUtil.iteratorToList(resultIterator);
+ assertThat(results).hasSize(1);
+ assertThat(results.get(0).getField(0))
+ .asString()
+ .contains("Successfully appended")
+ .contains(ConfigOptions.SERVER_SASL_CREDENTIALS.key());
+ }
+
+ // Verify user "bob" was added
+ try (CloseableIterator<Row> resultIterator =
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.get_cluster_configs('%s')",
+ CATALOG_NAME,
ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .collect()) {
+ List<Row> results = CollectionUtil.iteratorToList(resultIterator);
+ assertThat(results).hasSize(1);
+
assertThat(results.stream().map(Row::toString).collect(Collectors.toList()))
+ .containsExactly(
+ "+I[security.sasl.plain.credentials,
root:******,guest:******,bob:******, DYNAMIC_SERVER_CONFIG]");
+ }
+
+ // Verify "bob" can authenticate by creating a catalog with bob's
credentials.
+ // Use retry to wait for ZK config notification to propagate to all
TabletServers.
+ retry(
+ Duration.ofSeconds(30),
+ () ->
+ assertThatNoException()
+ .isThrownBy(() ->
tEnv.executeSql(createCatalogDDL).await()));
+
+ // Grant bob DESCRIBE permission on cluster so bob can query configs
+ tEnv.executeSql(
+ String.format(
+ "Call %s.sys.add_acl('CLUSTER', 'ALLOW',
'User:bob', 'DESCRIBE', '*')",
+ CATALOG_NAME))
+ .await();
+
+ // Bob should be able to get cluster configs
+ try (CloseableIterator<Row> resultIterator =
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.get_cluster_configs('%s')",
+ bobCatalog,
ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .collect()) {
+ List<Row> results = CollectionUtil.iteratorToList(resultIterator);
+
assertThat(results.stream().map(Row::toString).collect(Collectors.toList()))
+ .containsExactly(
+ "+I[security.sasl.plain.credentials,
root:******,guest:******,bob:******, DYNAMIC_SERVER_CONFIG]");
+ }
+ tEnv.executeSql("drop catalog " + bobCatalog);
+
+ // Step 2: Delete user "bob" via subtract_cluster_configs
+ tEnv.executeSql(
+ String.format(
+ "Call %s.sys.subtract_cluster_configs('%s',
'bob:bob_pass')",
+ CATALOG_NAME,
ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .await();
+
+ // Verify "bob" was deleted from config
+ try (CloseableIterator<Row> resultIterator =
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.get_cluster_configs('%s')",
+ CATALOG_NAME,
ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .collect()) {
+ List<Row> results = CollectionUtil.iteratorToList(resultIterator);
+ // After subtracting the only dynamically-added entry, the config
may be empty
+
assertThat(results.stream().map(Row::toString).collect(Collectors.toList()))
+ .containsExactly(
+ "+I[security.sasl.plain.credentials,
root:******,guest:******, DYNAMIC_SERVER_CONFIG]");
+ }
+
+ // Verify "bob" can no longer authenticate.
+ // Use retry to wait for ZK config notification to propagate to all
TabletServers.
+ retry(
+ Duration.ofSeconds(30),
+ () ->
+ assertThatThrownBy(() ->
tEnv.executeSql(createCatalogDDL).await())
+ .hasMessageContaining("Invalid username or
password"));
+ // Cleanup: remove bob's ACL
+ tEnv.executeSql(
+ String.format(
+ "Call %s.sys.drop_acl('CLUSTER', 'ALLOW',
'User:bob', 'DESCRIBE', '*')",
+ CATALOG_NAME))
+ .await();
+ // Try to append a map entry with the same key as the existing "root"
entry
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.append_cluster_configs('%s', 'root:another-pass')",
+ CATALOG_NAME,
+
ConfigOptions.SERVER_SASL_CREDENTIALS
+ .key()))
+ .await())
+ .hasMessageContaining("duplicate map entry keys")
+ .hasMessageContaining("root");
+
+ // Try to append a user with no colon (invalid format)
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.append_cluster_configs('%s', 'usernameonly')",
+ CATALOG_NAME,
+
ConfigOptions.SERVER_SASL_CREDENTIALS
+ .key()))
+ .await())
+ .hasMessageContaining("Invalid map entry");
+
+ // Try to append a user with invalid username characters (@)
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.append_cluster_configs('%s', 'user@domain:pass')",
+ CATALOG_NAME,
+
ConfigOptions.SERVER_SASL_CREDENTIALS
+ .key()))
+ .await())
+ .hasMessageContaining("contains invalid characters");
+
+ // Try to append a user with invalid username characters (-)
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.append_cluster_configs('%s', 'user-name:pass')",
+ CATALOG_NAME,
+
ConfigOptions.SERVER_SASL_CREDENTIALS
+ .key()))
+ .await())
+ .hasMessageContaining("contains invalid characters");
+
+ // Try to append a user with double-quote in password (breaks JAAS
value)
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.append_cluster_configs('%s', 'eve:pass\"word')",
+ CATALOG_NAME,
+
ConfigOptions.SERVER_SASL_CREDENTIALS
+ .key()))
+ .await())
+ .hasMessageContaining("contains invalid characters");
+
+ // Try to append a user with semicolon in password (breaks JAAS
statement)
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "Call
%s.sys.append_cluster_configs('%s', 'eve:pass;word')",
+ CATALOG_NAME,
+
ConfigOptions.SERVER_SASL_CREDENTIALS
+ .key()))
+ .await())
+ .hasMessageContaining("contains invalid characters");
+
+ // Subtract a non-existent user - should be a no-op (no error)
+ tEnv.executeSql(
+ String.format(
+ "Call %s.sys.subtract_cluster_configs('%s',
'nonexistent:whatever')",
+ CATALOG_NAME,
ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .await();
+
+ tEnv.executeSql(
+ String.format(
+ "Call %s.sys.subtract_cluster_configs('%s',
'special_user:P@ss!w0rd#$&*()')",
+ CATALOG_NAME,
ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .await();
+ }
+
@Test
void testDropKvSnapshotLeaseProcedure() throws Exception {
tEnv.executeSql(
@@ -838,10 +1044,7 @@ public abstract class FlinkProcedureITCase {
conf.setString(ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT:sasl");
conf.setString("security.sasl.enabled.mechanisms", "plain");
conf.setString(
- "security.sasl.plain.jaas.config",
- "org.apache.fluss.security.auth.sasl.plain.PlainLoginModule
required "
- + " user_root=\"password\" "
- + " user_guest=\"password2\";");
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
"root:password,guest:passwords");
conf.set(ConfigOptions.SUPER_USERS, "User:root");
conf.set(ConfigOptions.AUTHORIZER_ENABLED, true);
return conf;
diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/RpcServer.java
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/RpcServer.java
index bae71acaa..317a12380 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/RpcServer.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/RpcServer.java
@@ -19,12 +19,14 @@ package org.apache.fluss.rpc;
import org.apache.fluss.cluster.Endpoint;
import org.apache.fluss.config.Configuration;
+import org.apache.fluss.config.cluster.ServerReconfigurable;
import org.apache.fluss.metrics.groups.MetricGroup;
import org.apache.fluss.rpc.netty.server.NettyServer;
import org.apache.fluss.rpc.netty.server.RequestsMetrics;
import org.apache.fluss.utils.AutoCloseableAsync;
import java.io.IOException;
+import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
@@ -79,4 +81,9 @@ public interface RpcServer extends AutoCloseableAsync {
* @return The RPC server provided scheduled executor
*/
ScheduledExecutorService getScheduledExecutor();
+
+ /** Returns server components that should receive dynamic config updates.
*/
+ default List<ServerReconfigurable> getServerReconfigurables() {
+ return Collections.emptyList();
+ }
}
diff --git
a/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/FlussProtocolPlugin.java
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/FlussProtocolPlugin.java
index 54bf34ecf..25ae18941 100644
---
a/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/FlussProtocolPlugin.java
+++
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/FlussProtocolPlugin.java
@@ -17,10 +17,11 @@
package org.apache.fluss.rpc.netty.server;
-import org.apache.fluss.annotation.VisibleForTesting;
import org.apache.fluss.cluster.ServerType;
import org.apache.fluss.config.ConfigOptions;
import org.apache.fluss.config.Configuration;
+import org.apache.fluss.config.cluster.ServerReconfigurable;
+import org.apache.fluss.exception.ConfigException;
import org.apache.fluss.rpc.RpcGatewayService;
import org.apache.fluss.rpc.protocol.ApiManager;
import org.apache.fluss.rpc.protocol.NetworkProtocolPlugin;
@@ -28,15 +29,47 @@ import org.apache.fluss.security.auth.AuthenticationFactory;
import org.apache.fluss.security.auth.PlainTextAuthenticationPlugin;
import org.apache.fluss.shaded.netty4.io.netty.channel.ChannelHandler;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
/** Build-in protocol plugin for Fluss. */
-public class FlussProtocolPlugin implements NetworkProtocolPlugin {
+public class FlussProtocolPlugin implements NetworkProtocolPlugin,
ServerReconfigurable {
+
+ private static final String PLAIN_CREDENTIALS_CONFIG =
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key();
+
+ /** Pattern to match {@code user_<username>="<password>"} entries in JAAS
config strings. */
+ private static final Pattern JAAS_USER_PATTERN =
Pattern.compile("user_(\\w+)=\"([^\"]*)\"");
+
+ /**
+ * Valid username pattern. Only letters, digits, and underscores are
allowed because the
+ * username is used as part of the JAAS option key {@code user_<username>}.
+ */
+ private static final Pattern VALID_USERNAME_PATTERN =
Pattern.compile("\\w+");
+
+ /**
+ * Characters forbidden in passwords. These would break the map format or
the generated JAAS
+ * config string: comma (entry separator), colon (key-value separator),
double-quote (JAAS value
+ * delimiter), semicolon (JAAS statement terminator), backslash (escape
char), and control
+ * characters.
+ */
+ private static final Pattern INVALID_PASSWORD_PATTERN =
+ Pattern.compile("[,:\"\\\\;]|[\\x00-\\x1F\\x7F]");
+
private final ApiManager apiManager;
private final List<String> listeners;
private final RequestsMetrics requestsMetrics;
private Configuration conf;
+ /** Initial credentials from `security.sasl.plain.jaas.config`. */
+ private Map<String, String> initialPlainCredentialsFromJaasConfig;
+
+ /** Current config `security.sasl.plain.credentials`. */
+ private Map<String, String> currentPlainCredentials;
public FlussProtocolPlugin(
ServerType serverType, List<String> listeners, RequestsMetrics
requestsMetrics) {
@@ -52,7 +85,9 @@ public class FlussProtocolPlugin implements
NetworkProtocolPlugin {
@Override
public void setup(Configuration conf) {
- this.conf = conf;
+ this.conf = new Configuration(conf);
+ this.initialPlainCredentialsFromJaasConfig =
parseCredentialsFromJaasConfig(conf);
+ enrichWithJaasConfig(conf);
}
@Override
@@ -72,7 +107,7 @@ public class FlussProtocolPlugin implements
NetworkProtocolPlugin {
conf.get(ConfigOptions.NETTY_CONNECTION_MAX_IDLE_TIME).getSeconds(),
(int)
conf.get(ConfigOptions.NETTY_SERVER_MAX_REQUEST_SIZE).getBytes(),
Optional.ofNullable(
-
AuthenticationFactory.loadServerAuthenticatorSuppliers(conf)
+
AuthenticationFactory.loadServerAuthenticatorSuppliers(this.conf)
.get(listenerName))
.orElse(PlainTextAuthenticationPlugin.PlainTextServerAuthenticator::new));
}
@@ -82,8 +117,123 @@ public class FlussProtocolPlugin implements
NetworkProtocolPlugin {
return new FlussRequestHandler(service);
}
- @VisibleForTesting
- ApiManager getApiManager() {
- return apiManager;
+ // --- ServerReconfigurable ---
+
+ @Override
+ public void validate(Configuration newConfig) throws ConfigException {
+ Map<String, String> newCredentials = readPlainCredentials(newConfig);
+ if (Objects.equals(newCredentials, currentPlainCredentials)) {
+ return;
+ }
+ if (newCredentials != null && !newCredentials.isEmpty()) {
+ int index = 0;
+ for (Map.Entry<String, String> credential :
newCredentials.entrySet()) {
+ validateUsername(credential.getKey());
+ validatePassword(index, credential.getKey(),
credential.getValue());
+ index++;
+ }
+ }
+
+ // Generate the merged JAAS config value to ensure it is valid.
+ generateMergedJaasConfig(newCredentials);
+ }
+
+ @Override
+ public void reconfigure(Configuration newConfig) throws ConfigException {
+ enrichWithJaasConfig(newConfig);
+ }
+
+ /**
+ * Enriches the plugin's configuration with a generated JAAS config string
by merging:
+ *
+ * <ol>
+ * <li>Existing credentials parsed from the current {@code
security.sasl.plain.jaas.config}
+ * <li>New credentials from the {@code security.sasl.plain.credentials}
map in {@code
+ * newConfig}
+ * </ol>
+ *
+ * <p>New credentials take priority when a username exists in both
sources. If the credentials
+ * map is not present in {@code newConfig}, the configuration is returned
unchanged.
+ */
+ private void enrichWithJaasConfig(Configuration newConfig) throws
ConfigException {
+ Map<String, String> newCredentials = readPlainCredentials(newConfig);
+ if (Objects.equals(newCredentials, currentPlainCredentials)) {
+ return;
+ }
+
+ conf.setString(
+ ConfigOptions.SERVER_SASL_PLAIN_JAAS_CONFIG,
+ generateMergedJaasConfig(newCredentials));
+ currentPlainCredentials = newCredentials;
+ }
+
+ private static Map<String, String> readPlainCredentials(Configuration
config)
+ throws ConfigException {
+ try {
+ return config.get(ConfigOptions.SERVER_SASL_CREDENTIALS);
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ throw new ConfigException(
+ String.format(
+ "Failed to parse %s: %s",
PLAIN_CREDENTIALS_CONFIG, e.getMessage()),
+ e);
+ }
+ }
+
+ private static void validateUsername(String username) throws
ConfigException {
+ if (!VALID_USERNAME_PATTERN.matcher(username).matches()) {
+ throw new ConfigException(
+ String.format(
+ "%s: username '%s' contains invalid characters. "
+ + "Only letters, digits, and underscores
are allowed.",
+ PLAIN_CREDENTIALS_CONFIG, username));
+ }
+ }
+
+ private static void validatePassword(int index, String username, String
password)
+ throws ConfigException {
+ if (password == null ||
INVALID_PASSWORD_PATTERN.matcher(password).find()) {
+ throw new ConfigException(
+ String.format(
+ "%s[%d]: password for user '%s' contains invalid
characters. "
+ + "Commas, colons, quotes, semicolons,
backslashes, and control characters are not allowed.",
+ PLAIN_CREDENTIALS_CONFIG, index, username));
+ }
+ }
+
+ /**
+ * Generates the merged JAAS config string by combining existing
credentials from the current
+ * {@code security.sasl.plain.jaas.config} with the given new credentials
map. New credentials
+ * take priority on username conflict.
+ *
+ * @param newCredentials map of username to password from
SERVER_SASL_CREDENTIALS
+ * @return the generated JAAS config string
+ */
+ private String generateMergedJaasConfig(Map<String, String>
newCredentials) {
+ Map<String, String> mergedCredentials =
+ new LinkedHashMap<>(initialPlainCredentialsFromJaasConfig);
+ if (newCredentials != null) {
+ mergedCredentials.putAll(newCredentials);
+ }
+
+ StringBuilder sb =
+ new StringBuilder(
+
"org.apache.fluss.security.auth.sasl.plain.PlainLoginModule required");
+ for (Map.Entry<String, String> entry : mergedCredentials.entrySet()) {
+ sb.append(String.format(" user_%s=\"%s\"", entry.getKey(),
entry.getValue()));
+ }
+ sb.append(";");
+ return sb.toString();
+ }
+
+ private static Map<String, String>
parseCredentialsFromJaasConfig(Configuration configuration) {
+ Map<String, String> credentials = new LinkedHashMap<>();
+ String existingJaas =
configuration.getString(ConfigOptions.SERVER_SASL_PLAIN_JAAS_CONFIG);
+ if (existingJaas != null) {
+ Matcher matcher = JAAS_USER_PATTERN.matcher(existingJaas);
+ while (matcher.find()) {
+ credentials.put(matcher.group(1), matcher.group(2));
+ }
+ }
+ return credentials;
}
}
diff --git
a/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/NettyServer.java
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/NettyServer.java
index 7f2198566..03d798fb3 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/NettyServer.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/NettyServer.java
@@ -21,6 +21,7 @@ import org.apache.fluss.cluster.Endpoint;
import org.apache.fluss.cluster.ServerType;
import org.apache.fluss.config.ConfigOptions;
import org.apache.fluss.config.Configuration;
+import org.apache.fluss.config.cluster.ServerReconfigurable;
import org.apache.fluss.metrics.groups.MetricGroup;
import org.apache.fluss.rpc.RpcGateway;
import org.apache.fluss.rpc.RpcGatewayService;
@@ -272,6 +273,14 @@ public final class NettyServer implements RpcServer {
protocolName, protocols.keySet()));
}
+ @Override
+ public List<ServerReconfigurable> getServerReconfigurables() {
+ return protocols.stream()
+ .filter(protocol -> protocol instanceof ServerReconfigurable)
+ .map(protocol -> (ServerReconfigurable) protocol)
+ .collect(Collectors.toList());
+ }
+
@Override
public ScheduledExecutorService getScheduledExecutor() {
checkState(isRunning, "Netty server has not been started yet.");
diff --git
a/fluss-rpc/src/test/java/org/apache/fluss/rpc/netty/authenticate/AuthenticationTest.java
b/fluss-rpc/src/test/java/org/apache/fluss/rpc/netty/authenticate/AuthenticationTest.java
index 6f4977f8b..9b98d735a 100644
---
a/fluss-rpc/src/test/java/org/apache/fluss/rpc/netty/authenticate/AuthenticationTest.java
+++
b/fluss-rpc/src/test/java/org/apache/fluss/rpc/netty/authenticate/AuthenticationTest.java
@@ -242,11 +242,7 @@ public class AuthenticationTest {
configuration.setString(
ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT1:mutual,CLIENT2:sasl");
configuration.setString("security.sasl.enabled.mechanisms", "plain");
- configuration.setString(
- "security.sasl.plain.jaas.config",
- "org.apache.fluss.security.auth.sasl.plain.PlainLoginModule
required "
- + " user_root=\"password\" "
- + " user_guest=\"password2\";");
+ configuration.setString("security.sasl.plain.credentials",
"root:password,guest:password2");
configuration.set(ConfigOptions.SUPER_USERS, "User:root");
configuration.set(ConfigOptions.AUTHORIZER_ENABLED, true);
// 3 worker threads is enough for this test
diff --git
a/fluss-rpc/src/test/java/org/apache/fluss/rpc/netty/authenticate/SaslAuthenticationITCase.java
b/fluss-rpc/src/test/java/org/apache/fluss/rpc/netty/authenticate/SaslAuthenticationITCase.java
index b02e02d63..b8a2a155b 100644
---
a/fluss-rpc/src/test/java/org/apache/fluss/rpc/netty/authenticate/SaslAuthenticationITCase.java
+++
b/fluss-rpc/src/test/java/org/apache/fluss/rpc/netty/authenticate/SaslAuthenticationITCase.java
@@ -22,7 +22,9 @@ import org.apache.fluss.cluster.ServerNode;
import org.apache.fluss.cluster.ServerType;
import org.apache.fluss.config.ConfigOptions;
import org.apache.fluss.config.Configuration;
+import org.apache.fluss.config.cluster.ServerReconfigurable;
import org.apache.fluss.exception.AuthenticationException;
+import org.apache.fluss.exception.ConfigException;
import org.apache.fluss.metrics.groups.MetricGroup;
import org.apache.fluss.metrics.util.NOPMetricsGroup;
import org.apache.fluss.rpc.TestingTabletGatewayService;
@@ -180,6 +182,315 @@ public class SaslAuthenticationITCase {
testAuthentication(clientConfig);
}
+ @Test
+ void testAddAndDeleteUser() throws Exception {
+ // Start a server with username/password list config (admin and alice)
+ Configuration serverConfig = new Configuration();
+
serverConfig.setString(ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT:sasl");
+ serverConfig.setString("security.sasl.enabled.mechanisms", "plain");
+ serverConfig.setString(
+ "security.sasl.plain.credentials",
"admin:admin-secret,alice:alice-secret");
+
serverConfig.setString(ConfigOptions.NETTY_SERVER_NUM_WORKER_THREADS.key(),
"3");
+
+ MetricGroup metricGroup = NOPMetricsGroup.newInstance();
+ TestingAuthenticateGatewayService service = new
TestingAuthenticateGatewayService();
+ try (NetUtils.Port port = getAvailablePort();
+ NettyServer nettyServer =
+ new NettyServer(
+ serverConfig,
+ Collections.singletonList(
+ new Endpoint("localhost",
port.getPort(), "CLIENT")),
+ service,
+ metricGroup,
+
RequestsMetrics.createCoordinatorServerRequestMetrics(
+ metricGroup))) {
+ nettyServer.start();
+ ServerNode serverNode =
+ new ServerNode(1, "localhost", port.getPort(),
ServerType.TABLET_SERVER);
+ ServerReconfigurable reconfigurable =
nettyServer.getServerReconfigurables().get(0);
+
+ // Verify "admin" can authenticate
+ try (NettyClient client = createSaslClient("admin",
"admin-secret")) {
+ verifyListTables(client, serverNode);
+ }
+
+ // Verify "bob" cannot authenticate initially
+ try (NettyClient client = createSaslClient("bob", "bob-secret")) {
+ assertThatThrownBy(() -> verifyListTables(client, serverNode))
+ .cause()
+ .isExactlyInstanceOf(AuthenticationException.class)
+ .hasMessageContaining("Invalid username or password");
+ }
+
+ // Add user "bob" via reconfigure
+ Configuration addBobConfig = new Configuration();
+
addBobConfig.setString(ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT:sasl");
+ addBobConfig.setString("security.sasl.enabled.mechanisms",
"plain");
+ addBobConfig.setString(
+ "security.sasl.plain.credentials",
+ "admin:admin-secret,alice:alice-secret,bob:bob-secret");
+ reconfigurable.validate(addBobConfig);
+ reconfigurable.reconfigure(addBobConfig);
+
+ // Verify "bob" can now authenticate
+ try (NettyClient client = createSaslClient("bob", "bob-secret")) {
+ verifyListTables(client, serverNode);
+ }
+
+ // Delete user "admin" via reconfigure
+ Configuration removeAdminConfig = new Configuration();
+ removeAdminConfig.setString(
+ ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT:sasl");
+ removeAdminConfig.setString("security.sasl.enabled.mechanisms",
"plain");
+ removeAdminConfig.setString(
+ "security.sasl.plain.credentials",
"alice:alice-secret,bob:bob-secret");
+ reconfigurable.validate(removeAdminConfig);
+ reconfigurable.reconfigure(removeAdminConfig);
+
+ // Verify "admin" can no longer authenticate
+ try (NettyClient client = createSaslClient("admin",
"admin-secret")) {
+ assertThatThrownBy(() -> verifyListTables(client, serverNode))
+ .cause()
+ .isExactlyInstanceOf(AuthenticationException.class)
+ .hasMessageContaining("Invalid username or password");
+ }
+
+ // Verify "bob" still works
+ try (NettyClient client = createSaslClient("bob", "bob-secret")) {
+ verifyListTables(client, serverNode);
+ }
+ }
+ }
+
+ @Test
+ void testReconfigureMergesUsersWithExistingJaasConfig() throws Exception {
+ // Start server with legacy jaas.config containing "admin" and "alice"
+ Configuration serverConfig = new Configuration();
+
serverConfig.setString(ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT:sasl");
+ serverConfig.setString("security.sasl.enabled.mechanisms", "plain");
+ serverConfig.setString(
+ "security.sasl.plain.jaas.config",
+ "org.apache.fluss.security.auth.sasl.plain.PlainLoginModule
required"
+ + " user_admin=\"admin-secret\""
+ + " user_alice=\"alice-secret\";");
+
serverConfig.setString(ConfigOptions.NETTY_SERVER_NUM_WORKER_THREADS.key(),
"3");
+
+ MetricGroup metricGroup = NOPMetricsGroup.newInstance();
+ TestingAuthenticateGatewayService service = new
TestingAuthenticateGatewayService();
+ try (NetUtils.Port port = getAvailablePort();
+ NettyServer nettyServer =
+ new NettyServer(
+ serverConfig,
+ Collections.singletonList(
+ new Endpoint("localhost",
port.getPort(), "CLIENT")),
+ service,
+ metricGroup,
+
RequestsMetrics.createCoordinatorServerRequestMetrics(
+ metricGroup))) {
+ nettyServer.start();
+ ServerNode serverNode =
+ new ServerNode(1, "localhost", port.getPort(),
ServerType.TABLET_SERVER);
+ ServerReconfigurable reconfigurable =
nettyServer.getServerReconfigurables().get(0);
+
+ // Verify existing users from jaas.config can authenticate
+ try (NettyClient client = createSaslClient("admin",
"admin-secret")) {
+ verifyListTables(client, serverNode);
+ }
+ try (NettyClient client = createSaslClient("alice",
"alice-secret")) {
+ verifyListTables(client, serverNode);
+ }
+
+ // Verify "bob" cannot authenticate before reconfigure
+ try (NettyClient client = createSaslClient("bob", "bob-secret")) {
+ assertThatThrownBy(() -> verifyListTables(client, serverNode))
+ .cause()
+ .isExactlyInstanceOf(AuthenticationException.class)
+ .hasMessageContaining("Invalid username or password");
+ }
+
+ // Reconfigure with SERVER_SASL_CREDENTIALS containing only "bob".
+ // The merge logic should keep existing users (admin, alice) AND
add bob.
+ Configuration newConfig = new Configuration();
+
newConfig.setString(ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT:sasl");
+ newConfig.setString("security.sasl.plain.credentials",
"bob:bob-secret");
+ reconfigurable.validate(newConfig);
+ reconfigurable.reconfigure(newConfig);
+
+ // After merge: admin, alice (from existing jaas.config) + bob
(from users list)
+ try (NettyClient client = createSaslClient("admin",
"admin-secret")) {
+ verifyListTables(client, serverNode);
+ }
+ try (NettyClient client = createSaslClient("alice",
"alice-secret")) {
+ verifyListTables(client, serverNode);
+ }
+ try (NettyClient client = createSaslClient("bob", "bob-secret")) {
+ verifyListTables(client, serverNode);
+ }
+
+ newConfig.removeKey("security.sasl.plain.credentials");
+ reconfigurable.validate(newConfig);
+ reconfigurable.reconfigure(newConfig);
+ // Verify existing users from jaas.config can authenticate
+ try (NettyClient client = createSaslClient("alice",
"alice-secret")) {
+ verifyListTables(client, serverNode);
+ }
+
+ // Verify "bob" cannot authenticate before reconfigure
+ try (NettyClient client = createSaslClient("bob", "bob-secret")) {
+ assertThatThrownBy(() -> verifyListTables(client, serverNode))
+ .cause()
+ .isExactlyInstanceOf(AuthenticationException.class)
+ .hasMessageContaining("Invalid username or password");
+ }
+ }
+ }
+
+ @Test
+ void testValidateRejectsInvalidUsernameCharacters() throws Exception {
+ Configuration serverConfig = new Configuration();
+
serverConfig.setString(ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT:sasl");
+ serverConfig.setString("security.sasl.enabled.mechanisms", "plain");
+ serverConfig.setString(
+ "security.sasl.plain.jaas.config",
+ "org.apache.fluss.security.auth.sasl.plain.PlainLoginModule
required"
+ + " user_admin=\"admin-secret\";");
+
serverConfig.setString(ConfigOptions.NETTY_SERVER_NUM_WORKER_THREADS.key(),
"3");
+
+ MetricGroup metricGroup = NOPMetricsGroup.newInstance();
+ TestingAuthenticateGatewayService service = new
TestingAuthenticateGatewayService();
+ try (NetUtils.Port port = getAvailablePort();
+ NettyServer nettyServer =
+ new NettyServer(
+ serverConfig,
+ Collections.singletonList(
+ new Endpoint("localhost",
port.getPort(), "CLIENT")),
+ service,
+ metricGroup,
+
RequestsMetrics.createCoordinatorServerRequestMetrics(
+ metricGroup))) {
+ nettyServer.start();
+ ServerReconfigurable reconfigurable =
nettyServer.getServerReconfigurables().get(0);
+
+ // Username with spaces
+ Configuration badUsername = new Configuration();
+ badUsername.setString("security.sasl.plain.credentials", "bad
user:password");
+ assertThatThrownBy(() -> reconfigurable.validate(badUsername))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("contains invalid characters");
+
+ // Username with special chars (@)
+ Configuration atUsername = new Configuration();
+ atUsername.setString("security.sasl.plain.credentials",
"user@domain:password");
+ assertThatThrownBy(() -> reconfigurable.validate(atUsername))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("contains invalid characters");
+
+ // Username with dots
+ Configuration dotUsername = new Configuration();
+ dotUsername.setString("security.sasl.plain.credentials",
"user.name:password");
+ assertThatThrownBy(() -> reconfigurable.validate(dotUsername))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("contains invalid characters");
+
+ // Username with hyphens
+ Configuration hyphenUsername = new Configuration();
+ hyphenUsername.setString("security.sasl.plain.credentials",
"user-name:password");
+ assertThatThrownBy(() -> reconfigurable.validate(hyphenUsername))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("contains invalid characters");
+
+ // Valid username with underscores should pass
+ Configuration validUsername = new Configuration();
+ validUsername.setString("security.sasl.plain.credentials",
"user_name:pw");
+ reconfigurable.validate(validUsername);
+ }
+ }
+
+ @Test
+ void testValidateRejectsInvalidPasswordCharacters() throws Exception {
+ Configuration serverConfig = new Configuration();
+
serverConfig.setString(ConfigOptions.SERVER_SECURITY_PROTOCOL_MAP.key(),
"CLIENT:sasl");
+ serverConfig.setString("security.sasl.enabled.mechanisms", "plain");
+ serverConfig.setString(
+ "security.sasl.plain.jaas.config",
+ "org.apache.fluss.security.auth.sasl.plain.PlainLoginModule
required"
+ + " user_admin=\"admin-secret\";");
+
serverConfig.setString(ConfigOptions.NETTY_SERVER_NUM_WORKER_THREADS.key(),
"3");
+
+ MetricGroup metricGroup = NOPMetricsGroup.newInstance();
+ TestingAuthenticateGatewayService service = new
TestingAuthenticateGatewayService();
+ try (NetUtils.Port port = getAvailablePort();
+ NettyServer nettyServer =
+ new NettyServer(
+ serverConfig,
+ Collections.singletonList(
+ new Endpoint("localhost",
port.getPort(), "CLIENT")),
+ service,
+ metricGroup,
+
RequestsMetrics.createCoordinatorServerRequestMetrics(
+ metricGroup))) {
+ nettyServer.start();
+ ServerReconfigurable reconfigurable =
nettyServer.getServerReconfigurables().get(0);
+
+ // Password with comma (breaks map format)
+ Configuration commaPassword = new Configuration();
+ commaPassword.setString("security.sasl.plain.credentials",
"bob:pass,word");
+ assertThatThrownBy(() -> reconfigurable.validate(commaPassword))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("Failed to parse
security.sasl.plain.credentials");
+
+ // Password with colon (breaks map key-value format)
+ Configuration colonPassword = new Configuration();
+ colonPassword.setString("security.sasl.plain.credentials",
"bob:'pass:word'");
+ assertThatThrownBy(() -> reconfigurable.validate(colonPassword))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("contains invalid characters");
+
+ // Password with double-quote (breaks JAAS value)
+ Configuration quotePassword = new Configuration();
+ quotePassword.setString("security.sasl.plain.credentials",
"bob:pass\"word");
+ assertThatThrownBy(() -> reconfigurable.validate(quotePassword))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("password for user 'bob' contains
invalid characters");
+
+ // Password with semicolon (breaks JAAS statement)
+ Configuration semicolonPassword = new Configuration();
+ semicolonPassword.setString("security.sasl.plain.credentials",
"bob:pass;word");
+ assertThatThrownBy(() ->
reconfigurable.validate(semicolonPassword))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("contains invalid characters");
+
+ // Password with backslash (escape char)
+ Configuration backslashPassword = new Configuration();
+ backslashPassword.setString("security.sasl.plain.credentials",
"bob:pass\\word");
+ assertThatThrownBy(() ->
reconfigurable.validate(backslashPassword))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining("contains invalid characters");
+
+ // Valid password with special chars (!, @, #, $, etc.) should pass
+ Configuration validPassword = new Configuration();
+ validPassword.setString("security.sasl.plain.credentials",
"bob:P@ss!w0rd#$%^&*()");
+ reconfigurable.validate(validPassword);
+ }
+ }
+
+ private NettyClient createSaslClient(String username, String password) {
+ Configuration clientConfig = new Configuration();
+ clientConfig.setString("client.security.protocol", "sasl");
+ clientConfig.setString("client.security.sasl.mechanism", "plain");
+ clientConfig.setString("client.security.sasl.username", username);
+ clientConfig.setString("client.security.sasl.password", password);
+ return new NettyClient(clientConfig,
TestingClientMetricGroup.newInstance());
+ }
+
+ private void verifyListTables(NettyClient client, ServerNode serverNode)
throws Exception {
+ ListTablesRequest request = new
ListTablesRequest().setDatabaseName("test-database");
+ ListTablesResponse response =
+ (ListTablesResponse)
+ client.sendRequest(serverNode, ApiKeys.LIST_TABLES,
request).get();
+
assertThat(response.getTableNamesList()).isEqualTo(Collections.singletonList("test-table"));
+ }
+
private void testAuthentication(Configuration clientConfig) throws
Exception {
testAuthentication(clientConfig, getDefaultServerConfig());
}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/DynamicConfigManager.java
b/fluss-server/src/main/java/org/apache/fluss/server/DynamicConfigManager.java
index a6b678c2e..74d6147c7 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/DynamicConfigManager.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/DynamicConfigManager.java
@@ -18,6 +18,8 @@
package org.apache.fluss.server;
import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.config.ConfigOption;
+import org.apache.fluss.config.ConfigOptions;
import org.apache.fluss.config.Configuration;
import org.apache.fluss.config.cluster.AlterConfig;
import org.apache.fluss.config.cluster.ConfigEntry;
@@ -36,6 +38,7 @@ import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
/** Manager for dynamic configurations. */
public class DynamicConfigManager {
@@ -104,7 +107,9 @@ public class DynamicConfigManager {
if (!dynamicDefaultConfigs.containsKey(key)) {
ConfigEntry configEntry =
new ConfigEntry(
- key, value,
ConfigEntry.ConfigSource.INITIAL_SERVER_CONFIG);
+ key,
+
dynamicServerConfig.redactConfigValue(key, value),
+
ConfigEntry.ConfigSource.INITIAL_SERVER_CONFIG);
configEntries.add(configEntry);
}
});
@@ -112,7 +117,9 @@ public class DynamicConfigManager {
(key, value) -> {
ConfigEntry configEntry =
new ConfigEntry(
- key, value,
ConfigEntry.ConfigSource.DYNAMIC_SERVER_CONFIG);
+ key,
+ dynamicServerConfig.redactConfigValue(key,
value),
+
ConfigEntry.ConfigSource.DYNAMIC_SERVER_CONFIG);
configEntries.add(configEntry);
});
@@ -129,21 +136,29 @@ public class DynamicConfigManager {
List<AlterConfig> alterConfigs, Map<String, String> configsProps) {
alterConfigs.forEach(
alterConfigOp -> {
- String configPropName = alterConfigOp.key();
- if (!dynamicServerConfig.isAllowedConfig(configPropName)) {
+ String configKey = alterConfigOp.key();
+ if (!dynamicServerConfig.isAllowedConfig(configKey)) {
throw new ConfigException(
String.format(
"The config key %s is not allowed to
be changed dynamically.",
- configPropName));
+ configKey));
}
- String configPropValue = alterConfigOp.value();
+ String configValue = alterConfigOp.value();
switch (alterConfigOp.opType()) {
case SET:
- configsProps.put(configPropName, configPropValue);
+ configsProps.put(configKey, configValue);
break;
case DELETE:
- configsProps.remove(configPropName);
+ configsProps.remove(configKey);
+ break;
+ case APPEND:
+ validateListOrMapType(configKey);
+ appendCollectionConfig(configsProps, configKey,
configValue);
+ break;
+ case SUBTRACT:
+ validateListOrMapType(configKey);
+ subtractCollectionConfig(configsProps, configKey,
configValue);
break;
default:
throw new ConfigException(
@@ -152,6 +167,149 @@ public class DynamicConfigManager {
});
}
+ private void appendCollectionConfig(
+ Map<String, String> dynamicConfigs, String configKey, String
configValue) {
+ if (isMapType(configKey)) {
+ appendMapConfig(dynamicConfigs, configKey, configValue);
+ return;
+ }
+
+ appendListConfig(dynamicConfigs, configKey, configValue);
+ }
+
+ private void appendListConfig(
+ Map<String, String> dynamicConfigs, String configKey, String
configValue) {
+ String existingValue = getExistingConfigValue(dynamicConfigs,
configKey);
+ if (existingValue == null || existingValue.isEmpty()) {
+ dynamicConfigs.put(configKey, configValue);
+ } else {
+ dynamicConfigs.put(configKey, existingValue + "," + configValue);
+ }
+ }
+
+ private void subtractCollectionConfig(
+ Map<String, String> dynamicConfigs, String configKey, String
configValue) {
+ if (isMapType(configKey)) {
+ subtractMapConfig(dynamicConfigs, configKey, configValue);
+ return;
+ }
+
+ subtractListConfig(dynamicConfigs, configKey, configValue);
+ }
+
+ private void subtractListConfig(
+ Map<String, String> dynamicConfigs, String configKey, String
configValue) {
+ String existingValue = getExistingConfigValue(dynamicConfigs,
configKey);
+ if (existingValue == null || existingValue.isEmpty()) {
+ return;
+ }
+
+ List<String> items = new ArrayList<>();
+ for (String item : existingValue.split(",")) {
+ String trimmed = item.trim();
+ if (!trimmed.isEmpty()) {
+ items.add(trimmed);
+ }
+ }
+ items.removeIf(v -> v.equals(configValue));
+ if (items.isEmpty()) {
+ dynamicConfigs.put(configKey, null);
+ } else {
+ dynamicConfigs.put(configKey, String.join(",", items));
+ }
+ }
+
+ private void appendMapConfig(
+ Map<String, String> dynamicConfigs, String configKey, String
configValue) {
+ validateMapEntry(configKey, configValue);
+ String mapEntryKey = getMapEntryKey(configValue);
+ String existingValue = getExistingConfigValue(dynamicConfigs,
configKey);
+ if (existingValue == null || existingValue.isEmpty()) {
+ dynamicConfigs.put(configKey, configValue);
+ return;
+ }
+
+ String existingEntry = findMapEntry(existingValue, mapEntryKey);
+ if (existingEntry != null) {
+ throw new ConfigException(
+ configKey
+ + " must not contain duplicate map entry keys: '"
+ + mapEntryKey
+ + "'.");
+ }
+ dynamicConfigs.put(configKey, existingValue + "," + configValue);
+ }
+
+ private void subtractMapConfig(
+ Map<String, String> dynamicConfigs, String configKey, String
configValue) {
+ validateMapEntry(configKey, configValue);
+ String targetMapEntryKey = getMapEntryKey(configValue);
+ String existingValue = getExistingConfigValue(dynamicConfigs,
configKey);
+ if (existingValue == null || existingValue.isEmpty()) {
+ return;
+ }
+
+ List<String> entries = new ArrayList<>();
+ boolean removed = false;
+ for (String entry : existingValue.split(",")) {
+ String trimmed = entry.trim();
+ if (trimmed.isEmpty()) {
+ continue;
+ }
+ if (Objects.equals(getMapEntryKey(trimmed), targetMapEntryKey)) {
+ removed = true;
+ continue;
+ }
+ entries.add(trimmed);
+ }
+ if (!removed) {
+ return;
+ }
+ if (entries.isEmpty()) {
+ dynamicConfigs.put(configKey, null);
+ } else {
+ dynamicConfigs.put(configKey, String.join(",", entries));
+ }
+ }
+
+ private static String getMapEntryKey(String entry) {
+ int separatorIndex = entry.indexOf(':');
+ if (separatorIndex <= 0) {
+ throw new ConfigException(
+ String.format("Map item is not a key-value pair: '%s'.",
entry));
+ }
+ return entry.substring(0, separatorIndex);
+ }
+
+ private static String findMapEntry(String value, String targetKey) {
+ for (String entry : value.split(",")) {
+ String trimmed = entry.trim();
+ if (!trimmed.isEmpty() && Objects.equals(getMapEntryKey(trimmed),
targetKey)) {
+ return trimmed;
+ }
+ }
+ return null;
+ }
+
+ private static void validateMapEntry(String configKey, String configValue)
{
+ Configuration configuration = new Configuration();
+ configuration.setString(configKey, configValue);
+ try {
+ configuration.get(ConfigOptions.getConfigOption(configKey));
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ throw new ConfigException(
+ String.format("Invalid map entry for config '%s': %s",
configKey, configValue),
+ e);
+ }
+ }
+
+ private String getExistingConfigValue(Map<String, String> dynamicConfigs,
String configKey) {
+ if (dynamicConfigs.containsKey(configKey)) {
+ return dynamicConfigs.get(configKey);
+ }
+ return dynamicServerConfig.getInitialServerConfigs().get(configKey);
+ }
+
@VisibleForTesting
protected void alterServerConfigs(Map<String, String> configsProps) throws
Exception {
dynamicServerConfig.updateDynamicConfig(configsProps, false);
@@ -160,6 +318,23 @@ public class DynamicConfigManager {
zooKeeperClient.upsertServerEntityConfig(configsProps);
}
+ private static void validateListOrMapType(String configKey) {
+ ConfigOption<?> configOption =
ConfigOptions.getConfigOption(configKey);
+ if (configOption == null
+ || (!configOption.isList() && configOption.getClazz() !=
Map.class)) {
+ throw new ConfigException(
+ String.format(
+ "APPEND/SUBTRACT operations are only supported for
list-typed or map-typed config keys, "
+ + "but '%s' is not a list or map type.",
+ configKey));
+ }
+ }
+
+ private static boolean isMapType(String configKey) {
+ ConfigOption<?> configOption =
ConfigOptions.getConfigOption(configKey);
+ return configOption != null && configOption.getClazz() == Map.class;
+ }
+
private class ConfigChangedNotificationHandler
implements ZkNodeChangeNotificationWatcher.NotificationHandler {
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/DynamicServerConfig.java
b/fluss-server/src/main/java/org/apache/fluss/server/DynamicServerConfig.java
index df82f606d..710c6b675 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/DynamicServerConfig.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/DynamicServerConfig.java
@@ -25,10 +25,13 @@ import org.apache.fluss.config.ConfigurationUtils;
import org.apache.fluss.config.cluster.ConfigValidator;
import org.apache.fluss.config.cluster.ServerReconfigurable;
import org.apache.fluss.exception.ConfigException;
+import org.apache.fluss.server.config.ConfigRedactor;
+import org.apache.fluss.server.config.ConfigRedactors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -50,6 +53,8 @@ import static
org.apache.fluss.config.ConfigOptions.REMOTE_DATA_DIRS;
import static org.apache.fluss.config.ConfigOptions.REMOTE_DATA_DIRS_STRATEGY;
import static org.apache.fluss.config.ConfigOptions.REMOTE_DATA_DIRS_WEIGHTS;
import static
org.apache.fluss.config.ConfigOptions.SERVER_DATA_DISK_WRITE_LIMIT_RATIO;
+import static org.apache.fluss.config.ConfigOptions.SERVER_SASL_CREDENTIALS;
+import static
org.apache.fluss.config.ConfigOptions.SERVER_SASL_PLAIN_JAAS_CONFIG;
import static org.apache.fluss.utils.concurrent.LockUtils.inReadLock;
import static org.apache.fluss.utils.concurrent.LockUtils.inWriteLock;
@@ -74,7 +79,8 @@ class DynamicServerConfig {
// Config options for remote.data.dirs
REMOTE_DATA_DIRS.key(),
REMOTE_DATA_DIRS_STRATEGY.key(),
- REMOTE_DATA_DIRS_WEIGHTS.key()));
+ REMOTE_DATA_DIRS_WEIGHTS.key(),
+ SERVER_SASL_CREDENTIALS.key()));
private static final Set<String> ALLOWED_CONFIG_PREFIXES =
Collections.singleton("datalake.");
private final ReadWriteLock lock = new ReentrantReadWriteLock();
@@ -85,6 +91,9 @@ class DynamicServerConfig {
private final Map<String, List<ConfigValidator<?>>> configValidatorsByKey =
new ConcurrentHashMap<>();
+ /** Registered config redactors for sensitive values exposed through
describe config APIs. */
+ private final List<ConfigRedactor> configRedactors = new ArrayList<>();
+
/** The initial configuration items when the server starts from
server.yaml. */
private final Map<String, String> initialConfigMap;
@@ -106,6 +115,7 @@ class DynamicServerConfig {
this.currentConfig = flussConfig;
this.initialConfigMap = flussConfig.toMap();
this.currentConfigMap = flussConfig.toMap();
+ registerDefaultRedactors();
}
void register(ServerReconfigurable serverReconfigurable) {
@@ -131,6 +141,26 @@ class DynamicServerConfig {
.add(validator);
}
+ String redactConfigValue(String configKey, String value) {
+ for (ConfigRedactor configRedactor : configRedactors) {
+ if (configRedactor.supports(configKey)) {
+ return configRedactor.redact(value);
+ }
+ }
+ return value;
+ }
+
+ private void registerDefaultRedactors() {
+
configRedactors.add(ConfigRedactors.map(SERVER_SASL_CREDENTIALS.key()));
+
configRedactors.add(ConfigRedactors.value(DynamicServerConfig::isPlainJaasConfig));
+ }
+
+ private static boolean isPlainJaasConfig(String configKey) {
+ return SERVER_SASL_PLAIN_JAAS_CONFIG.key().equals(configKey)
+ || (configKey.startsWith("security.sasl.listener.name.")
+ && configKey.endsWith(".plain.jaas.config"));
+ }
+
/**
* Update the dynamic configuration and apply to registered
ServerReconfigurable. If skipping
* error config, only the error one will be ignored.
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/authorizer/DefaultAuthorizer.java
b/fluss-server/src/main/java/org/apache/fluss/server/authorizer/DefaultAuthorizer.java
index fac4aa740..4c27c43b0 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/authorizer/DefaultAuthorizer.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/authorizer/DefaultAuthorizer.java
@@ -119,6 +119,7 @@ public class DefaultAuthorizer extends AbstractAuthorizer
implements FatalErrorH
}
private final Set<FlussPrincipal> superUsers;
+ private final boolean principalIgnoreCase;
private final ZooKeeperClient zooKeeperClient;
private final ZkNodeChangeNotificationWatcher aclChangeNotificationWatcher;
@@ -134,6 +135,8 @@ public class DefaultAuthorizer extends AbstractAuthorizer
implements FatalErrorH
public DefaultAuthorizer(AuthorizationPlugin.Context context) {
Configuration configuration = context.getConfiguration();
this.superUsers = parseSuperUsers(configuration);
+ this.principalIgnoreCase =
+
configuration.get(ConfigOptions.SECURITY_ACL_PRINCIPAL_IGNORE_CASE);
if (context.getZooKeeperClient().isPresent()) {
this.zooKeeperClient = context.getZooKeeperClient().get();
} else {
@@ -167,7 +170,7 @@ public class DefaultAuthorizer extends AbstractAuthorizer
implements FatalErrorH
@Override
public boolean authorizeAction(Session session, Action action) {
FlussPrincipal principal = session.getPrincipal();
- return superUsers.contains(principal)
+ return isSuperUser(principal)
|| aclsAllowAccess(
action.getResource(),
principal,
@@ -175,6 +178,15 @@ public class DefaultAuthorizer extends AbstractAuthorizer
implements FatalErrorH
session.getInetAddress().getHostAddress());
}
+ private boolean isSuperUser(FlussPrincipal principal) {
+ for (FlussPrincipal superUser : superUsers) {
+ if (superUser.matches(principal, principalIgnoreCase)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public List<AclCreateResult> addAcls(Session session, List<AclBinding>
aclBindings) {
if (aclBindings.isEmpty()) {
@@ -514,9 +526,12 @@ public class DefaultAuthorizer extends AbstractAuthorizer
implements FatalErrorH
.filter(
acl ->
acl.getPermissionType() == permissionType
- &&
(acl.getPrincipal().equals(principal)
+ && (acl.getPrincipal()
+ .matches(principal,
principalIgnoreCase)
|| acl.getPrincipal()
-
.equals(FlussPrincipal.WILD_CARD_PRINCIPAL))
+ .matches(
+
FlussPrincipal.WILD_CARD_PRINCIPAL,
+
principalIgnoreCase))
&& (operation == acl.getOperationType()
|| acl.getOperationType() ==
OperationType.ALL)
&&
(acl.getHost().equals(AccessControlEntry.WILD_CARD_HOST)
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/config/ConfigRedactor.java
b/fluss-server/src/main/java/org/apache/fluss/server/config/ConfigRedactor.java
new file mode 100644
index 000000000..bc45e259f
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/config/ConfigRedactor.java
@@ -0,0 +1,34 @@
+/*
+ * 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.fluss.server.config;
+
+import org.apache.fluss.annotation.Internal;
+
+import javax.annotation.Nullable;
+
+/** Redacts sensitive values before exposing server configurations. */
+@Internal
+public interface ConfigRedactor {
+
+ /** Returns whether this redactor handles the given config key. */
+ boolean supports(String configKey);
+
+ /** Returns the redacted display value. */
+ @Nullable
+ String redact(@Nullable String value);
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/config/ConfigRedactors.java
b/fluss-server/src/main/java/org/apache/fluss/server/config/ConfigRedactors.java
new file mode 100644
index 000000000..2d7e5b9c5
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/config/ConfigRedactors.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.fluss.server.config;
+
+import org.apache.fluss.annotation.Internal;
+
+import java.util.function.Predicate;
+
+/** Factory methods for server config redactors. */
+@Internal
+public final class ConfigRedactors {
+
+ private ConfigRedactors() {}
+
+ /** Creates a redactor that masks map values while preserving map keys. */
+ public static ConfigRedactor map(String configKey) {
+ return new MapConfigRedactor(configKey);
+ }
+
+ /** Creates a redactor that masks the whole config value when the matcher
accepts its key. */
+ public static ConfigRedactor value(Predicate<String> configKeyMatcher) {
+ return new ValueConfigRedactor(configKeyMatcher);
+ }
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/config/MapConfigRedactor.java
b/fluss-server/src/main/java/org/apache/fluss/server/config/MapConfigRedactor.java
new file mode 100644
index 000000000..22be56dec
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/config/MapConfigRedactor.java
@@ -0,0 +1,61 @@
+/*
+ * 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.fluss.server.config;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Redacts map config values by preserving keys and masking values. */
+class MapConfigRedactor implements ConfigRedactor {
+
+ static final String REDACTED_VALUE = "******";
+
+ private final String configKey;
+
+ MapConfigRedactor(String configKey) {
+ this.configKey = configKey;
+ }
+
+ @Override
+ public boolean supports(String configKey) {
+ return this.configKey.equals(configKey);
+ }
+
+ @Override
+ public @Nullable String redact(@Nullable String value) {
+ if (value == null) {
+ return null;
+ }
+
+ List<String> redactedEntries = new ArrayList<>();
+ for (String rawEntry : value.split(",")) {
+ String entry = rawEntry.trim();
+ if (entry.isEmpty()) {
+ continue;
+ }
+ int separatorIndex = entry.indexOf(':');
+ if (separatorIndex <= 0) {
+ return REDACTED_VALUE;
+ }
+ redactedEntries.add(entry.substring(0, separatorIndex) + ":" +
REDACTED_VALUE);
+ }
+ return String.join(",", redactedEntries);
+ }
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/config/ValueConfigRedactor.java
b/fluss-server/src/main/java/org/apache/fluss/server/config/ValueConfigRedactor.java
new file mode 100644
index 000000000..d96b7d78d
--- /dev/null
+++
b/fluss-server/src/main/java/org/apache/fluss/server/config/ValueConfigRedactor.java
@@ -0,0 +1,42 @@
+/*
+ * 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.fluss.server.config;
+
+import javax.annotation.Nullable;
+
+import java.util.function.Predicate;
+
+/** Redacts sensitive config values as a whole. */
+class ValueConfigRedactor implements ConfigRedactor {
+
+ private final Predicate<String> configKeyMatcher;
+
+ ValueConfigRedactor(Predicate<String> configKeyMatcher) {
+ this.configKeyMatcher = configKeyMatcher;
+ }
+
+ @Override
+ public boolean supports(String configKey) {
+ return configKeyMatcher.test(configKey);
+ }
+
+ @Override
+ public @Nullable String redact(@Nullable String value) {
+ return value == null ? null : MapConfigRedactor.REDACTED_VALUE;
+ }
+}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorServer.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorServer.java
index 549eaf657..d4fbcbb97 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorServer.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorServer.java
@@ -242,16 +242,6 @@ public class CoordinatorServer extends ServerBase {
this.remoteDirDynamicLoader = new RemoteDirDynamicLoader(conf);
this.dynamicConfigManager = new DynamicConfigManager(zkClient,
conf, true);
-
- // Register server reconfigurable components
- dynamicConfigManager.register(lakeCatalogDynamicLoader);
- dynamicConfigManager.register(remoteDirDynamicLoader);
-
- // Register stateless validators for coordinator-side upfront
validation
- dynamicConfigManager.registerValidator(new
DiskWriteLimitRatioValidator());
-
- dynamicConfigManager.startup();
-
this.metadataCache = new CoordinatorMetadataCache();
this.authorizer = AuthorizerLoader.createAuthorizer(conf,
zkClient, pluginManager);
@@ -305,6 +295,14 @@ public class CoordinatorServer extends ServerBase {
serverMetricGroup,
RequestsMetrics.createCoordinatorServerRequestMetrics(
serverMetricGroup));
+ // Register server reconfigurable components
+ dynamicConfigManager.register(lakeCatalogDynamicLoader);
+ dynamicConfigManager.register(remoteDirDynamicLoader);
+ // Register stateless validators for coordinator-side upfront
validation
+ dynamicConfigManager.registerValidator(new
DiskWriteLimitRatioValidator());
+
rpcServer.getServerReconfigurables().forEach(dynamicConfigManager::register);
+ dynamicConfigManager.startup();
+
rpcServer.start();
registerCoordinatorServer();
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
index c7c5fdd78..adf9a59bd 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
@@ -227,7 +227,6 @@ public class TabletServer extends ServerBase {
MetadataManager metadataManager =
new MetadataManager(zkClient, conf,
lakeCatalogDynamicLoader);
this.dynamicConfigManager = new DynamicConfigManager(zkClient,
conf, false);
- dynamicConfigManager.register(lakeCatalogDynamicLoader);
this.metadataCache = new
TabletServerMetadataCache(metadataManager);
@@ -247,9 +246,6 @@ public class TabletServer extends ServerBase {
conf, zkClient, logManager,
tabletServerMetricGroup, localDiskManager);
kvManager.startup();
- // Register kvManager to dynamicConfigManager for dynamic
reconfiguration
- dynamicConfigManager.register(kvManager);
-
this.authorizer = AuthorizerLoader.createAuthorizer(conf,
zkClient, pluginManager);
if (authorizer != null) {
authorizer.startup();
@@ -295,15 +291,6 @@ public class TabletServer extends ServerBase {
localDiskManager);
replicaManager.startup();
- // Register DefaultSnapshotContext for dynamic kv.snapshot.interval
-
dynamicConfigManager.register(replicaManager.getKvSnapshotContext());
- // Register replicaManager to dynamicConfigManager for dynamic
config
- dynamicConfigManager.register(replicaManager);
- // Register localDiskManager for dynamic
server.data-disk.write-limit-ratio
- dynamicConfigManager.register(localDiskManager);
- // Start dynamicConfigManager after all reconfigurable components
are registered
- dynamicConfigManager.startup();
-
this.tabletService =
new TabletService(
serverId,
@@ -328,6 +315,21 @@ public class TabletServer extends ServerBase {
tabletService,
tabletServerMetricGroup,
requestsMetrics);
+
+ dynamicConfigManager.register(lakeCatalogDynamicLoader);
+ // Register kvManager to dynamicConfigManager for dynamic
reconfiguration
+ dynamicConfigManager.register(kvManager);
+ // Register DefaultSnapshotContext for dynamic kv.snapshot.interval
+
dynamicConfigManager.register(replicaManager.getKvSnapshotContext());
+ // Register replicaManager to dynamicConfigManager for dynamic
config
+ dynamicConfigManager.register(replicaManager);
+ // Register localDiskManager for dynamic
server.data-disk.write-limit-ratio
+ dynamicConfigManager.register(localDiskManager);
+
rpcServer.getServerReconfigurables().forEach(dynamicConfigManager::register);
+
+ // Start dynamicConfigManager after all reconfigurable components
are registered
+ dynamicConfigManager.startup();
+
rpcServer.start();
registerTabletServer();
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/DynamicConfigChangeTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/DynamicConfigChangeTest.java
index c41afa0f6..839c8c69a 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/DynamicConfigChangeTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/DynamicConfigChangeTest.java
@@ -21,6 +21,7 @@ import org.apache.fluss.config.ConfigOptions;
import org.apache.fluss.config.Configuration;
import org.apache.fluss.config.cluster.AlterConfig;
import org.apache.fluss.config.cluster.AlterConfigOpType;
+import org.apache.fluss.config.cluster.ConfigEntry;
import org.apache.fluss.config.cluster.ServerReconfigurable;
import org.apache.fluss.exception.ConfigException;
import org.apache.fluss.server.coordinator.LakeCatalogDynamicLoader;
@@ -697,4 +698,246 @@ public class DynamicConfigChangeTest {
assertThat(localDiskManager.getDiskWriteLimitRatio()).isEqualTo(0.85);
}
}
+
+ @Test
+ void testAppendAndSubtractOnMapConfig() throws Exception {
+ Configuration configuration = new Configuration();
+ DynamicConfigManager dynamicConfigManager =
+ new DynamicConfigManager(zookeeperClient, configuration, true);
+
+ AtomicReference<Map<String, String>> reconfiguredUsers = new
AtomicReference<>();
+ dynamicConfigManager.register(
+ new ServerReconfigurable() {
+ @Override
+ public void validate(Configuration newConfig) throws
ConfigException {}
+
+ @Override
+ public void reconfigure(Configuration newConfig) {
+
reconfiguredUsers.set(newConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS));
+ }
+ });
+ dynamicConfigManager.startup();
+
+ // APPEND first user
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "admin:admin-secret",
+ AlterConfigOpType.APPEND)));
+
+ Map<String, String> zkConfig = zookeeperClient.fetchEntityConfig();
+ assertThat(zkConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .isEqualTo("admin:admin-secret");
+ assertThat(reconfiguredUsers.get()).containsEntry("admin",
"admin-secret");
+
+ // APPEND second user
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "bob:bob-secret",
+ AlterConfigOpType.APPEND)));
+
+ zkConfig = zookeeperClient.fetchEntityConfig();
+ assertThat(zkConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .isEqualTo("admin:admin-secret,bob:bob-secret");
+ assertThat(reconfiguredUsers.get())
+ .containsEntry("admin", "admin-secret")
+ .containsEntry("bob", "bob-secret");
+
+ // SUBTRACT with an existing key but a different value should remove
by map key.
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "admin:wrong-secret",
+ AlterConfigOpType.SUBTRACT)));
+
+ zkConfig = zookeeperClient.fetchEntityConfig();
+ assertThat(zkConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .isEqualTo("bob:bob-secret");
+ assertThat(reconfiguredUsers.get())
+ .containsEntry("bob", "bob-secret")
+ .doesNotContainKey("admin");
+
+ // SUBTRACT last user - should write null to override static config
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "bob:bob-secret",
+ AlterConfigOpType.SUBTRACT)));
+
+ zkConfig = zookeeperClient.fetchEntityConfig();
+
assertThat(zkConfig.containsKey(ConfigOptions.SERVER_SASL_CREDENTIALS.key())).isTrue();
+
assertThat(zkConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS.key())).isNull();
+ assertThat(reconfiguredUsers.get()).isNull();
+ }
+
+ @Test
+ void testAppendOnNonListOrMapConfigIsRejected() throws Exception {
+ Configuration configuration = new Configuration();
+ DynamicConfigManager dynamicConfigManager =
+ new DynamicConfigManager(zookeeperClient, configuration, true);
+ dynamicConfigManager.startup();
+
+ // APPEND on a non-list/non-map config (kv.snapshot.interval is
Duration type)
+ // should be rejected immediately by the collection-type check.
+ assertThatThrownBy(
+ () ->
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+
ConfigOptions.KV_SNAPSHOT_INTERVAL.key(),
+ "5min",
+
AlterConfigOpType.APPEND))))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining(
+ "APPEND/SUBTRACT operations are only supported for
list-typed or map-typed config keys");
+
+ // SUBTRACT on a non-list config should also be rejected.
+ assertThatThrownBy(
+ () ->
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+
ConfigOptions.KV_SNAPSHOT_INTERVAL.key(),
+ "5min",
+
AlterConfigOpType.SUBTRACT))))
+ .isInstanceOf(ConfigException.class)
+ .hasMessageContaining(
+ "APPEND/SUBTRACT operations are only supported for
list-typed or map-typed config keys");
+ }
+
+ @Test
+ void testDescribeRedactsSensitiveConfigs() throws Exception {
+ Configuration configuration = new Configuration();
+ configuration.setString(ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
"admin:admin-secret");
+ configuration.setString(
+ ConfigOptions.SERVER_SASL_PLAIN_JAAS_CONFIG.key(),
+ "org.apache.fluss.security.auth.sasl.plain.PlainLoginModule
required "
+ + "user_admin=\"admin-secret\";");
+
+ DynamicConfigManager dynamicConfigManager =
+ new DynamicConfigManager(zookeeperClient, configuration, true);
+ dynamicConfigManager.startup();
+
+ assertThat(dynamicConfigManager.describeConfigs())
+ .contains(
+ new ConfigEntry(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "admin:******",
+
ConfigEntry.ConfigSource.INITIAL_SERVER_CONFIG),
+ new ConfigEntry(
+
ConfigOptions.SERVER_SASL_PLAIN_JAAS_CONFIG.key(),
+ "******",
+
ConfigEntry.ConfigSource.INITIAL_SERVER_CONFIG));
+ }
+
+ @Test
+ void testAppendReadsStaticConfig() throws Exception {
+ Configuration configuration = new Configuration();
+ // Pre-configure a static user in server.yaml equivalent
+ configuration.setString(ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
"admin:admin-secret");
+
+ DynamicConfigManager dynamicConfigManager =
+ new DynamicConfigManager(zookeeperClient, configuration, true);
+
+ AtomicReference<Map<String, String>> reconfiguredUsers = new
AtomicReference<>();
+ dynamicConfigManager.register(
+ new ServerReconfigurable() {
+ @Override
+ public void validate(Configuration newConfig) throws
ConfigException {}
+
+ @Override
+ public void reconfigure(Configuration newConfig) {
+
reconfiguredUsers.set(newConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS));
+ }
+ });
+ dynamicConfigManager.startup();
+
+ // APPEND should build on top of static config
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "bob:bob-secret",
+ AlterConfigOpType.APPEND)));
+
+ Map<String, String> zkConfig = zookeeperClient.fetchEntityConfig();
+ assertThat(zkConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .isEqualTo("admin:admin-secret,bob:bob-secret");
+ assertThat(reconfiguredUsers.get())
+ .containsEntry("admin", "admin-secret")
+ .containsEntry("bob", "bob-secret");
+ }
+
+ @Test
+ void testSubtractFromStaticConfigWritesNull() throws Exception {
+ Configuration configuration = new Configuration();
+ // Pre-configure a static user in server.yaml equivalent
+ configuration.setString(ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
"admin:admin-secret");
+
+ DynamicConfigManager dynamicConfigManager =
+ new DynamicConfigManager(zookeeperClient, configuration, true);
+
+ AtomicReference<Map<String, String>> reconfiguredUsers = new
AtomicReference<>();
+ dynamicConfigManager.register(
+ new ServerReconfigurable() {
+ @Override
+ public void validate(Configuration newConfig) throws
ConfigException {}
+
+ @Override
+ public void reconfigure(Configuration newConfig) {
+
reconfiguredUsers.set(newConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS));
+ }
+ });
+ dynamicConfigManager.startup();
+
+ // SUBTRACT the static user - should write null to override static
config
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "admin:admin-secret",
+ AlterConfigOpType.SUBTRACT)));
+
+ Map<String, String> zkConfig = zookeeperClient.fetchEntityConfig();
+
assertThat(zkConfig.containsKey(ConfigOptions.SERVER_SASL_CREDENTIALS.key())).isTrue();
+
assertThat(zkConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS.key())).isNull();
+ assertThat(reconfiguredUsers.get()).isNull();
+ }
+
+ @Test
+ void testSubtractTrimsWhitespaceAndRemovesByKey() throws Exception {
+ Configuration configuration = new Configuration();
+ DynamicConfigManager dynamicConfigManager =
+ new DynamicConfigManager(zookeeperClient, configuration, true);
+ dynamicConfigManager.startup();
+
+ // Set up a config with whitespace around entries
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "admin:admin-secret, bob:bob-secret",
+ AlterConfigOpType.SET)));
+
+ Map<String, String> zkConfig = zookeeperClient.fetchEntityConfig();
+ assertThat(zkConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .isEqualTo("admin:admin-secret, bob:bob-secret");
+
+ // SUBTRACT should trim entries and remove the matching map key
+ dynamicConfigManager.alterConfigs(
+ Collections.singletonList(
+ new AlterConfig(
+ ConfigOptions.SERVER_SASL_CREDENTIALS.key(),
+ "admin:admin-secret",
+ AlterConfigOpType.SUBTRACT)));
+
+ zkConfig = zookeeperClient.fetchEntityConfig();
+ assertThat(zkConfig.get(ConfigOptions.SERVER_SASL_CREDENTIALS.key()))
+ .isEqualTo("bob:bob-secret");
+ }
}
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/authorizer/DefaultAuthorizerTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/authorizer/DefaultAuthorizerTest.java
index 68fd6eed2..7a597c2e3 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/authorizer/DefaultAuthorizerTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/authorizer/DefaultAuthorizerTest.java
@@ -223,6 +223,35 @@ public class DefaultAuthorizerTest {
.isTrue();
}
+ @Test
+ void testPrincipalIgnoreCaseMatchesConfiguredUser() throws Exception {
+ // The setup configures USER:root as a super user with full access.
USER:user1 has no
+ // matching ACLs and is not a super user, so READ on database1 should
always be denied.
+ // The root session intentionally uses user:ROOT to verify
case-sensitive matching first.
+ Session normalUserSession = createSession("USER", "user1",
"192.168.1.1");
+ Session superUserSession = createSession("user", "ROOT",
"192.168.1.1");
+ assertThat(authorizer.isAuthorized(normalUserSession, READ,
Resource.database("database1")))
+ .isFalse();
+ assertThat(authorizer.isAuthorized(superUserSession, READ,
Resource.database("database1")))
+ .isFalse();
+ Configuration ignoreCaseConfiguration = new
Configuration(configuration);
+
ignoreCaseConfiguration.setBoolean(ConfigOptions.SECURITY_ACL_PRINCIPAL_IGNORE_CASE,
true);
+ try (Authorizer ignoreCaseAuthorizer =
+ AuthorizerLoader.createAuthorizer(ignoreCaseConfiguration,
zooKeeperClient, null)) {
+ assertThat(ignoreCaseAuthorizer).isNotNull();
+ // Enabling ignore-case lets user:ROOT match the configured
USER:root super user, but it
+ // does not grant privileges to USER:user1.
+ assertThat(
+ ignoreCaseAuthorizer.isAuthorized(
+ normalUserSession, READ,
Resource.database("database1")))
+ .isFalse();
+ assertThat(
+ ignoreCaseAuthorizer.isAuthorized(
+ superUserSession, READ,
Resource.database("database1")))
+ .isTrue();
+ }
+ }
+
@Test
void testAclWithOperationAll() throws Exception {
Session session = createSession("user1", "192.168.1.1");
@@ -664,12 +693,16 @@ public class DefaultAuthorizerTest {
}
private Session createSession(String username, String host) throws
Exception {
+ return createSession("USER", username, host);
+ }
+
+ private Session createSession(String userType, String username, String
host) throws Exception {
return new Session(
(byte) 1,
"FLUSS",
false,
InetAddress.getByName(host),
- new FlussPrincipal(username, "USER"));
+ new FlussPrincipal(username, userType));
}
private AclBinding createAclBinding(
diff --git a/website/docs/engine-flink/procedures.md
b/website/docs/engine-flink/procedures.md
index b3035e836..24baa9c08 100644
--- a/website/docs/engine-flink/procedures.md
+++ b/website/docs/engine-flink/procedures.md
@@ -246,6 +246,78 @@ CALL sys.set_cluster_configs(
);
```
+### append_cluster_configs
+
+Append values to list-type or map-type cluster configurations dynamically.
+
+**Syntax:**
+
+```sql
+-- Append collection configuration values
+CALL [catalog_name.]sys.append_cluster_configs(
+ config_pairs => 'key1', 'value1' [, 'key2', 'value2' ...]
+)
+```
+
+**Parameters:**
+
+- `config_pairs`(required): For key-value pairs in configuration items, the
number of parameters must be even.
+
+**Important Notes:**
+
+- APPEND operations are only supported for list-type or map-type configuration
keys
+- For map-type configurations, values must use the `map-key:map-value` format
+- Appending a map entry whose key already exists is rejected
+
+**Example:**
+
+```sql title="Flink SQL"
+-- Use the Fluss catalog (replace 'fluss_catalog' with your catalog name if
different)
+USE fluss_catalog;
+
+-- Add a SASL Plain credential
+CALL sys.append_cluster_configs(
+ config_pairs => 'security.sasl.plain.credentials', 'bob:bob-secret'
+);
+```
+
+### subtract_cluster_configs
+
+Subtract values from list-type or map-type cluster configurations dynamically.
+
+**Syntax:**
+
+```sql
+-- Subtract collection configuration values
+CALL [catalog_name.]sys.subtract_cluster_configs(
+ config_pairs => 'key1', 'value1' [, 'key2', 'value2' ...]
+)
+```
+
+**Parameters:**
+
+- `config_pairs`(required): For key-value pairs in configuration items, the
number of parameters must be even.
+
+**Important Notes:**
+
+- SUBTRACT operations are only supported for list-type or map-type
configuration keys
+- For list-type configurations, SUBTRACT removes the exact list value
+- For map-type configurations, values must use the `map-key:map-value` format,
but removal is matched only by `map-key`; the supplied `map-value` is ignored
for matching
+- Subtracting a list value or map key that does not exist is a no-op
+
+**Example:**
+
+```sql title="Flink SQL"
+-- Use the Fluss catalog (replace 'fluss_catalog' with your catalog name if
different)
+USE fluss_catalog;
+
+-- Remove user "bob" from the SASL Plain credentials map.
+-- This removes the "bob" entry even if the stored secret is different.
+CALL sys.subtract_cluster_configs(
+ config_pairs => 'security.sasl.plain.credentials', 'bob:any-secret'
+);
+```
+
### reset_cluster_configs
reset cluster configurations dynamically.
@@ -521,4 +593,4 @@ USE fluss_catalog;
-- Drop KV snapshots leased under the given leaseId
CALL sys.drop_kv_snapshot_lease('test-lease-id');
-```
\ No newline at end of file
+```
diff --git a/website/docs/maintenance/operations/updating-configs.md
b/website/docs/maintenance/operations/updating-configs.md
index 6ab797239..f6d3b12e3 100644
--- a/website/docs/maintenance/operations/updating-configs.md
+++ b/website/docs/maintenance/operations/updating-configs.md
@@ -74,7 +74,7 @@ CALL sys.set_cluster_configs(
);
```
-See [Procedures](engine-flink/procedures.md#cluster-configuration-procedures)
for detailed documentation on `set_cluster_configs`, `reset_cluster_configs`,
and `get_cluster_configs` procedures.
+See [Procedures](engine-flink/procedures.md#cluster-configuration-procedures)
for detailed documentation on `get_cluster_configs`, `set_cluster_configs`,
`append_cluster_configs`, `subtract_cluster_configs`, and
`reset_cluster_configs` procedures.
</TabItem>
</Tabs>