From 998fcd75f2a432a502fdca81a0681fb7068b9a0d Mon Sep 17 00:00:00 2001 From: Aparna Naik Date: Fri, 31 Jul 2026 16:19:11 -0700 Subject: [PATCH 01/21] Add MutualTLS strategy for Cluster Startup --- conf/cassandra.yaml | 24 +++ .../org/apache/cassandra/auth/AuthConfig.java | 4 + .../cassandra/auth/CassandraRoleManager.java | 32 ++-- .../auth/IDefaultRoleInitializer.java | 71 +++++++ .../auth/MutualTlsDefaultRoleInitializer.java | 113 +++++++++++ .../auth/PasswordDefaultRoleInitializer.java | 107 +++++++++++ .../org/apache/cassandra/config/Config.java | 1 + .../cassandra/config/DatabaseDescriptor.java | 12 ++ .../MutualTlsDefaultRoleInitializerTest.java | 176 ++++++++++++++++++ .../PasswordDefaultRoleInitializerTest.java | 121 ++++++++++++ .../apache/cassandra/auth/AuthConfigTest.java | 32 ++++ .../auth/DefaultRoleInitializerTest.java | 116 ++++++++++++ .../org/apache/cassandra/auth/RolesTest.java | 28 +++ .../config/DatabaseDescriptorRefTest.java | 1 + 14 files changed, 827 insertions(+), 11 deletions(-) create mode 100644 src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java create mode 100644 src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java create mode 100644 src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java create mode 100644 test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsDefaultRoleInitializerTest.java create mode 100644 test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java create mode 100644 test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index 2f64b7e54ee2..a12520c67349 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -241,6 +241,30 @@ role_manager: # invalid_role_disconnect_task_period: 4h # invalid_role_disconnect_task_max_jitter: 1h +# Creates the initial role on a cluster which has no roles yet, implementing IDefaultRoleInitializer. +# Most functions of the IRoleManager require an authenticated login, so a cluster with no roles has no way +# to create the first one; this option controls how that role is bootstrapped. +# +# Defaults to PasswordDefaultRoleInitializer, which creates a 'cassandra' superuser whose password is also +# 'cassandra'. That password is a published constant, so deployments using it must rotate or drop the role +# before the native transport is reachable. +# +# MutualTlsDefaultRoleInitializer instead creates the role with no password at all and maps a client +# certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting +# mutual TLS, such as MutualTlsAuthenticator. +# +# default_role_initializer: +# class_name: PasswordDefaultRoleInitializer +# parameters: +# role: cassandra +# password: cassandra +# +# default_role_initializer: +# class_name: MutualTlsDefaultRoleInitializer +# parameters: +# role: cassandra_mtls +# identity: "spiffe1" + # Network authorization backend, implementing INetworkAuthorizer; used to restrict user # access to certain DCs # Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllNetworkAuthorizer, diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index 52182afe3186..707582e3e7ba 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -101,6 +101,9 @@ public static void applyAuth() DatabaseDescriptor.setRoleManager(roleManager); + IDefaultRoleInitializer defaultRoleInitializer = authInstantiate(conf.default_role_initializer, IDefaultRoleInitializer.class, PasswordDefaultRoleInitializer.class); + DatabaseDescriptor.setDefaultRoleInitializer(defaultRoleInitializer); + // authenticator IInternodeAuthenticator internodeAuthenticator = authInstantiate(conf.internode_authenticator, @@ -140,6 +143,7 @@ public static void applyAuth() authenticator.validateConfiguration(); authorizer.validateConfiguration(); roleManager.validateConfiguration(); + defaultRoleInitializer.validateConfiguration(); networkAuthorizer.validateConfiguration(); cidrAuthorizer.validateConfiguration(); DatabaseDescriptor.getInternodeAuthenticator().validateConfiguration(); diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index ec4f873b5c57..9d7ec4337e40 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -139,6 +139,12 @@ public class CassandraRoleManager implements IRoleManager, CassandraRoleManagerM */ static final ConsistencyLevel DEFAULT_SUPERUSER_CONSISTENCY_LEVEL = ConsistencyLevel.QUORUM; + /** + * Used when no default_role_initializer is configured, or when auth setup has not run, e.g. in tests which + * do not call {@link AuthConfig#applyAuth()}. Preserves the historical bootstrap behaviour. + */ + private static final IDefaultRoleInitializer DEFAULT_ROLE_INITIALIZER = new PasswordDefaultRoleInitializer(); + // Transform a row in the AuthKeyspace.ROLES to a Role instance private static final Function ROW_TO_ROLE = row -> { @@ -533,9 +539,7 @@ private static void setupDefaultRole() { if (!hasExistingRoles()) { - QueryProcessor.process(createDefaultRoleQuery(), - consistencyForRoleWrite(DEFAULT_SUPERUSER_NAME)); - logger.info("Created default superuser role '{}'", DEFAULT_SUPERUSER_NAME); + defaultRoleInitializer().initialize(); } } catch (RequestExecutionException e) @@ -558,11 +562,11 @@ public static String createDefaultRoleQuery() @VisibleForTesting public static boolean hasExistingRoles() throws RequestExecutionException { - // Try looking up the 'cassandra' default role first, to avoid the range query if possible. - String defaultSUQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, DEFAULT_SUPERUSER_NAME); + // Try looking up the configured default role first, to avoid the range query if possible. + String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleInitializer().defaultRoleName())); String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); - return !QueryProcessor.process(defaultSUQuery, ConsistencyLevel.ONE).isEmpty() - || !QueryProcessor.process(defaultSUQuery, ConsistencyLevel.QUORUM).isEmpty() + return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() + || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); } @@ -771,16 +775,22 @@ private void enforcePasswordUpdateRateLimit(AuthenticatedUser performer, String throw new OverloadedException(failure); } - private static String hashpw(String password) + static String hashpw(String password) { return BCrypt.hashpw(password, PasswordSaltSupplier.get()); } - private static String escape(String name) + static String escape(String name) { return StringUtils.replace(name, "'", "''"); } + private static IDefaultRoleInitializer defaultRoleInitializer() + { + IDefaultRoleInitializer initializer = DatabaseDescriptor.getDefaultRoleInitializer(); + return initializer == null ? DEFAULT_ROLE_INITIALIZER : initializer; + } + private static ByteBuffer byteBuf(String str) { return UTF8Type.instance.decompose(str); @@ -789,14 +799,14 @@ private static ByteBuffer byteBuf(String str) /** Allows selective overriding of the consistency level for specific roles. */ protected static ConsistencyLevel consistencyForRoleWrite(String role) { - return role.equals(DEFAULT_SUPERUSER_NAME) ? + return role.equals(defaultRoleInitializer().defaultRoleName()) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authWriteConsistencyLevel(); } protected static ConsistencyLevel consistencyForRoleRead(String role) { - return role.equals(DEFAULT_SUPERUSER_NAME) ? + return role.equals(defaultRoleInitializer().defaultRoleName()) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authReadConsistencyLevel(); } diff --git a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java new file mode 100644 index 000000000000..2ea2f63e94d5 --- /dev/null +++ b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.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.cassandra.auth; + +import org.apache.cassandra.exceptions.ConfigurationException; + +/** + * Creates the initial role on a cluster which has no roles yet, so that there is some + * identity available to authenticate as and grant permissions from. Selected via + * {@code default_role_initializer} option in cassandra.yaml and instantiated by + * {@link AuthConfig#applyAuth()} + * + * Implementations decide both what the role is called and how it is authenticated. + * See {@link PasswordDefaultRoleInitializer} which gives the role a password, and + * {@link MutualTlsDefaultRoleInitializer} which gives no password and instead + * maps a client certificate identity to itself. + */ +public interface IDefaultRoleInitializer +{ + /** + * Creates the default role. Called from {@link CassandraRoleManager#setup(boolean)} only after + * {@link CassandraRoleManager#hasExistingRoles()} has established that the cluster has no rules. + * + * Every node runs this independently during initial startup so implementations must write at + * {@link CassandraRoleManager#consistencyForRoleWrite(String)} to avoid concurrent duplicate creation + * and must use {@code USING TIMESTAMP 0} so that any operator changes to the role later supersede it. + * + * The caller retries on failure so this may be invoked more than once on a node: it must not fail + * @throws org.apache.cassandra.exceptions.RequestExecutionException if not enough nodes are available + * yet which the caller treats as a signal to reschedule + */ + void initialize(); + + /** + * The name of the role {@link #initialize()} creates. + * + * The default role is a special case during startup: reads and writes of it are performed at + * {@link CassandraRoleManager#DEFAULT_SUPERUSER_CONSISTENCY_LEVEL} rather than at the configured auth + * consistency levels, and {@link CassandraRoleManager#hasExistingRoles()} looks it up by name before + * falling back to a range query. Both need to know the configured name, not assume + * {@link CassandraRoleManager#DEFAULT_SUPERUSER_NAME}. + */ + String defaultRoleName(); + + /** + * Validates configuration of the IDefaultRoleInitializer implementation (if configurable). + * + * Called by {@link AuthConfig#applyAuth()} after the authenticator, authorizer and role manager have been + * set, so implementations may inspect those to reject combinations which would leave the cluster with no + * usable login. + * + * @throws ConfigurationException when there is a configuration error. + */ + default void validateConfiguration() throws ConfigurationException {} +} diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java new file mode 100644 index 000000000000..55f091d66ce4 --- /dev/null +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -0,0 +1,113 @@ +/* + * 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.cassandra.auth; + +import java.util.Map; +import java.util.Set; + +import com.google.common.base.Strings; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.exceptions.ConfigurationException; +import org.apache.cassandra.schema.SchemaConstants; + +import static org.apache.cassandra.auth.CassandraRoleManager.consistencyForRoleWrite; +import static org.apache.cassandra.auth.CassandraRoleManager.escape; + +public class MutualTlsDefaultRoleInitializer implements IDefaultRoleInitializer +{ + private static final Logger logger = LoggerFactory.getLogger(MutualTlsDefaultRoleInitializer.class); + static final String ROLE = "role"; + static final String IDENTITY = "identity"; + + private static final Set SUPPORTED_PARAMS = Set.of(ROLE, IDENTITY); + private final String role; + private final String identity; + + public MutualTlsDefaultRoleInitializer(Map parameters) + { + for (String params: parameters.keySet()) + { + if (!SUPPORTED_PARAMS.contains(params)) + { + throw new ConfigurationException(String.format("Unsupported parameter %s for %s, supported parameters are %s", params, getClass().getSimpleName(), SUPPORTED_PARAMS)); + } + } + role = parameters.get(ROLE); + identity = parameters.get(IDENTITY); + } + + @Override + public void initialize() + { + QueryProcessor.process(String.format("INSERT INTO %s.%s (role, is_superuser, can_login) " + + "VALUES ('%s', true, true) USING TIMESTAMP 0", + SchemaConstants.AUTH_KEYSPACE_NAME, + AuthKeyspace.ROLES, + escape(role)), + consistencyForRoleWrite(role)); + + QueryProcessor.process(String.format("INSERT INTO %s.%s (identity, role) " + + "VALUES ('%s', '%s') USING TIMESTAMP 0", + SchemaConstants.AUTH_KEYSPACE_NAME, + AuthKeyspace.IDENTITY_TO_ROLES, + escape(identity), + escape(role)), + consistencyForRoleWrite(role)); + + logger.info("Created passwordless default superuser role '{}' for identity '{}'", role, identity); + } + + @Override + public String defaultRoleName() + { + return role; + } + + @Override + public void validateConfiguration() throws ConfigurationException + { + if (Strings.isNullOrEmpty(role)) + throw new ConfigurationException(String.format("%s requires a non-empty '%s' parameter", + getClass().getSimpleName(), ROLE)); + + if (Strings.isNullOrEmpty(identity)) + throw new ConfigurationException(String.format("%s requires a non-empty '%s' parameter", + getClass().getSimpleName(), IDENTITY)); + + // The role this creates has no password, so an authenticator which cannot authenticate by certificate + // would leave a freshly bootstrapped cluster with no way to log in at all. + IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); + Set modes = authenticator.getSupportedAuthenticationModes(); + if (authenticator.requireAuthentication() && !modes.isEmpty() && !modes.contains(IAuthenticator.AuthenticationMode.MTLS)) + { + throw new ConfigurationException(String.format("%s creates a role with no password, which %s cannot " + + "authenticate (supported modes: %s). Configure an " + + "authenticator supporting mutual TLS, such as %s.", + getClass().getSimpleName(), + authenticator.getClass().getSimpleName(), + modes, + MutualTlsAuthenticator.class.getSimpleName())); + } + } +} diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java new file mode 100644 index 000000000000..62996481fad0 --- /dev/null +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -0,0 +1,107 @@ +/* + * 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.cassandra.auth; + +import java.util.Map; +import java.util.Set; + +import com.google.common.base.Strings; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.exceptions.ConfigurationException; +import org.apache.cassandra.schema.SchemaConstants; + +import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_NAME; +import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; +import static org.apache.cassandra.auth.CassandraRoleManager.consistencyForRoleWrite; +import static org.apache.cassandra.auth.CassandraRoleManager.escape; +import static org.apache.cassandra.auth.CassandraRoleManager.hashpw; + +/** + * Creates the default role with a password, so that it can be authenticated with + * {@link PasswordAuthenticator}. This is the default {@link IDefaultRoleInitializer} and reproduces the + * historical bootstrap behaviour of creating a {@code cassandra} superuser whose password is also + * {@code cassandra}. + * + * Because that password is a well known constant, deployments which can authenticate by other means should + * prefer an initializer which does not create a password at all, such as + * {@link MutualTlsDefaultRoleInitializer}. Deployments which do use this initializer should rotate or drop the + * created role before the native transport is reachable. + */ +public class PasswordDefaultRoleInitializer implements IDefaultRoleInitializer +{ + private static final Logger logger = LoggerFactory.getLogger(PasswordDefaultRoleInitializer.class); + static final String ROLE = "role"; + static final String PASSWORD = "password"; + + private static final Set SUPPORTED_PARAMS = Set.of(ROLE, PASSWORD); + + private final String role; + private final String password; + + public PasswordDefaultRoleInitializer() + { + this(Map.of()); + } + + public PasswordDefaultRoleInitializer(Map parameters) + { + for (String param: parameters.keySet()) + { + if (!SUPPORTED_PARAMS.contains(param)) + throw new ConfigurationException(String.format("Unsupported parameter '%s' for %s, supported parameters are %s", param, getClass().getSimpleName(), SUPPORTED_PARAMS)); + } + + role = parameters.getOrDefault(ROLE, DEFAULT_SUPERUSER_NAME); + password = parameters.getOrDefault(PASSWORD, DEFAULT_SUPERUSER_PASSWORD); + } + + @Override + public void initialize() + { + QueryProcessor.process(String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) " + + + "VALUES ('%s', true, true, '%s') USING TIMESTAMP 0", + SchemaConstants.AUTH_KEYSPACE_NAME, + AuthKeyspace.ROLES, + escape(role), + escape(hashpw(password))), + consistencyForRoleWrite(role)); + logger.info("Created default superuser role '{}'", role); + } + + @Override + public String defaultRoleName() + { + return role; + } + + @Override + public void validateConfiguration() throws ConfigurationException + { + if (Strings.isNullOrEmpty(role)) + throw new ConfigurationException(String.format("%s requires a non-empty %s parameter", getClass().getSimpleName(), ROLE)); + + if (Strings.isNullOrEmpty(password)) + throw new ConfigurationException(String.format("%s requires a non-empty %s parameter", getClass().getSimpleName(), PASSWORD)); + } +} diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index 8df1a05cf18a..b891dd14bad2 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -86,6 +86,7 @@ public static Set splitCommaDelimited(String src) public ParameterizedClass authenticator; public ParameterizedClass authorizer; public ParameterizedClass role_manager; + public ParameterizedClass default_role_initializer; public ParameterizedClass crypto_provider; public ParameterizedClass network_authorizer; public ParameterizedClass cidr_authorizer; diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java index 5bc6de7ac1e4..97d0e43c6a01 100644 --- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java +++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java @@ -77,6 +77,7 @@ import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.auth.IAuthorizer; import org.apache.cassandra.auth.ICIDRAuthorizer; +import org.apache.cassandra.auth.IDefaultRoleInitializer; import org.apache.cassandra.auth.IInternodeAuthenticator; import org.apache.cassandra.auth.INetworkAuthorizer; import org.apache.cassandra.auth.IRoleManager; @@ -240,6 +241,7 @@ public class DatabaseDescriptor // Don't initialize the role manager until applying config. The options supported by CassandraRoleManager // depend on the configured IAuthenticator, so defer creating it until that's been set. private static IRoleManager roleManager; + private static IDefaultRoleInitializer defaultRoleInitializer; private static long preparedStatementsCacheSizeInMiB; @@ -2226,6 +2228,16 @@ public static void setRoleManager(IRoleManager roleManager) DatabaseDescriptor.roleManager = roleManager; } + public static IDefaultRoleInitializer getDefaultRoleInitializer() + { + return defaultRoleInitializer; + } + + public static void setDefaultRoleInitializer(IDefaultRoleInitializer defaultRoleInitializer) + { + DatabaseDescriptor.defaultRoleInitializer = defaultRoleInitializer; + } + public static int getPermissionsValidity() { return conf.permissions_validity.toMilliseconds(); diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsDefaultRoleInitializerTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsDefaultRoleInitializerTest.java new file mode 100644 index 000000000000..9a77d4e24868 --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsDefaultRoleInitializerTest.java @@ -0,0 +1,176 @@ +/* + * 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.cassandra.distributed.test.auth; + +import java.net.InetAddress; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; + +import com.datastax.driver.core.Session; + +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import org.apache.cassandra.auth.AuthKeyspace; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.cql3.UntypedResultSet; +import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.distributed.api.Feature; +import org.apache.cassandra.distributed.api.ICluster; +import org.apache.cassandra.distributed.api.IInvokableInstance; +import org.apache.cassandra.distributed.api.IIsolatedExecutor.SerializableCallable; +import org.apache.cassandra.distributed.test.JavaDriverUtils; +import org.apache.cassandra.distributed.test.TestBaseImpl; +import org.apache.cassandra.schema.SchemaConstants; +import org.apache.cassandra.utils.tls.CertificateBuilder; +import org.apache.cassandra.utils.tls.CertificateBundle; + +import static org.apache.cassandra.transport.TlsTestUtils.CLIENT_SPIFFE_IDENTITY; +import static org.apache.cassandra.transport.TlsTestUtils.SERVER_KEYSTORE_PASSWORD; +import static org.apache.cassandra.transport.TlsTestUtils.SERVER_TRUSTSTORE_PASSWORD; +import static org.apache.cassandra.transport.TlsTestUtils.generateClientCertificate; +import static org.apache.cassandra.transport.TlsTestUtils.getSSLOptions; +import static org.apache.cassandra.transport.TlsTestUtils.withAuthenticatedSession; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies that {@code default_role_initializer: MutualTlsDefaultRoleInitializer} bootstraps a passwordless + * superuser role and its identity mapping without any post-startup CQL step, that a client presenting a + * matching certificate can authenticate as that role with superuser access, and that password authentication + * against the same role fails cleanly since it has no {@code salted_hash}. + */ +public class MutualTlsDefaultRoleInitializerTest extends TestBaseImpl +{ + private static final String TEST_ROLE = "cassandra_mtls_bootstrap_test_role"; + + @ClassRule + public static TemporaryFolder tempFolder = new TemporaryFolder(); + + private static ICluster CLUSTER; + private static CertificateBundle CA; + private static Path truststorePath; + private static Path clientKeystorePath; + + @BeforeClass + public static void setupClass() throws Exception + { + Cluster.Builder builder = Cluster.build(1).withDynamicPortAllocation(true); + + CA = new CertificateBuilder().subject("CN=Apache Cassandra Root CA, OU=Certification Authority, O=Unknown, C=Unknown") + .alias("fakerootca") + .isCertificateAuthority(true) + .buildSelfSigned(); + + truststorePath = CA.toTempKeyStorePath(tempFolder.getRoot().toPath(), + SERVER_TRUSTSTORE_PASSWORD.toCharArray(), + SERVER_TRUSTSTORE_PASSWORD.toCharArray()); + + CertificateBundle serverKeystore = new CertificateBuilder().subject("CN=Apache Cassandra, OU=ssl_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .addSanDnsName(InetAddress.getLocalHost().getCanonicalHostName()) + .addSanDnsName(InetAddress.getLocalHost().getHostName()) + .buildIssuedBy(CA); + + Path serverKeystorePath = serverKeystore.toTempKeyStorePath(tempFolder.getRoot().toPath(), + SERVER_KEYSTORE_PASSWORD.toCharArray(), + SERVER_KEYSTORE_PASSWORD.toCharArray()); + + builder.withConfig(c -> c.set("authenticator.class_name", "org.apache.cassandra.auth.MutualTlsWithPasswordFallbackAuthenticator") + .set("authenticator.parameters", Collections.singletonMap("validator_class_name", "org.apache.cassandra.auth.SpiffeCertificateValidator")) + .set("role_manager", "CassandraRoleManager") + .set("authorizer", "CassandraAuthorizer") + .set("default_role_initializer.class_name", "org.apache.cassandra.auth.MutualTlsDefaultRoleInitializer") + .set("default_role_initializer.parameters", Map.of("role", TEST_ROLE, "identity", CLIENT_SPIFFE_IDENTITY)) + .set("client_encryption_options.enabled", "true") + .set("client_encryption_options.require_client_auth", "optional") + .set("client_encryption_options.keystore", serverKeystorePath.toString()) + .set("client_encryption_options.keystore_password", SERVER_KEYSTORE_PASSWORD) + .set("client_encryption_options.truststore", truststorePath.toString()) + .set("client_encryption_options.truststore_password", SERVER_TRUSTSTORE_PASSWORD) + .set("client_encryption_options.require_endpoint_verification", "false") + .with(Feature.NATIVE_PROTOCOL, Feature.GOSSIP, Feature.NETWORK)); + CLUSTER = builder.start(); + + clientKeystorePath = generateClientCertificate(null, tempFolder.getRoot(), CA); + } + + @AfterClass + public static void teardown() throws Exception + { + if (CLUSTER != null) + CLUSTER.close(); + } + + @Test + public void testDefaultRoleAndIdentityCreatedAtBootstrap() + { + String identity = CLIENT_SPIFFE_IDENTITY; + + Object[] roleRow = CLUSTER.get(1).callOnInstance((SerializableCallable) () -> { + UntypedResultSet result = QueryProcessor.executeInternal( + String.format("SELECT is_superuser, can_login, salted_hash FROM %s.%s WHERE role = '%s'", + SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, TEST_ROLE)); + if (result.isEmpty()) + return null; + UntypedResultSet.Row row = result.one(); + return new Object[]{ row.getBoolean("is_superuser"), row.getBoolean("can_login"), row.has("salted_hash") }; + }); + + assertThat(roleRow).isNotNull(); + assertThat((Boolean) roleRow[0]).as("is_superuser").isTrue(); + assertThat((Boolean) roleRow[1]).as("can_login").isTrue(); + assertThat((Boolean) roleRow[2]).as("has salted_hash").isFalse(); + + String mappedRole = CLUSTER.get(1).callOnInstance((SerializableCallable) () -> { + UntypedResultSet result = QueryProcessor.executeInternal( + String.format("SELECT role FROM %s.%s WHERE identity = '%s'", + SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.IDENTITY_TO_ROLES, identity)); + Assert.assertNotNull(result); + return result.isEmpty() ? null : result.one().getString("role"); + }); + + assertThat(mappedRole).isEqualTo(TEST_ROLE); + } + + @Test + public void testCertificateAuthenticationGrantsSuperuser() throws Exception + { + try (com.datastax.driver.core.Cluster c = JavaDriverUtils.create(CLUSTER, null, b -> b.withSSL(getSSLOptions(clientKeystorePath, truststorePath))); + Session session = c.connect()) + { + // system_auth.roles is a protected resource (CassandraRoleManager#protectedResources); reading it + // without ever being granted SELECT proves this connection authenticated with superuser status. + com.datastax.driver.core.ResultSet rows = session.execute("SELECT role FROM system_auth.roles WHERE role = ?", TEST_ROLE); + assertThat(rows.one().getString("role")).isEqualTo(TEST_ROLE); + } + } + + @Test + public void testPasswordAuthenticationFailsCleanly() throws Exception + { + assertThatThrownBy(() -> withAuthenticatedSession(CLUSTER.get(1), TEST_ROLE, "irrelevant-password", session -> { + }, getSSLOptions(null, truststorePath))) + .isInstanceOf(com.datastax.driver.core.exceptions.AuthenticationException.class); + } +} diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java new file mode 100644 index 000000000000..104374c11d63 --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java @@ -0,0 +1,121 @@ +/* + * 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.cassandra.distributed.test.auth; + +import java.net.InetAddress; +import java.util.function.Consumer; + +import com.datastax.driver.core.Cluster.Builder; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.policies.LoadBalancingPolicy; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.auth.AuthKeyspace; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.cql3.UntypedResultSet; +import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.distributed.api.Feature; +import org.apache.cassandra.distributed.api.ICluster; +import org.apache.cassandra.distributed.api.IInvokableInstance; +import org.apache.cassandra.distributed.api.IIsolatedExecutor.SerializableCallable; +import org.apache.cassandra.distributed.test.TestBaseImpl; +import org.apache.cassandra.distributed.util.Auth; +import org.apache.cassandra.distributed.util.SingleHostLoadBalancingPolicy; +import org.apache.cassandra.schema.SchemaConstants; + +import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_NAME; +import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression test for the {@code default_role_initializer} refactor (see {@link org.apache.cassandra.auth.IDefaultRoleInitializer}): + * when the option is left unconfigured, bootstrap must still fall back to {@link org.apache.cassandra.auth.PasswordDefaultRoleInitializer} + * and create the classic {@code cassandra}/{@code cassandra} superuser exactly as it always has, so deployments and + * tests that rely on the historical default (e.g. {@code CQLTester}) keep working unchanged. + */ +public class PasswordDefaultRoleInitializerTest extends TestBaseImpl +{ + private static ICluster CLUSTER; + + @BeforeClass + public static void setupClass() throws Exception + { + CLUSTER = Cluster.build(1) + .withConfig(conf -> conf.with(Feature.GOSSIP, Feature.NATIVE_PROTOCOL) + .set("authenticator", "PasswordAuthenticator") + .set("authorizer", "CassandraAuthorizer") + .set("role_manager", "CassandraRoleManager")) + .start(); + } + + @AfterClass + public static void teardown() throws Exception + { + if (CLUSTER != null) + CLUSTER.close(); + } + + @Test + public void testClassicSuperuserBootstrappedByDefault() + { + Object[] roleRow = CLUSTER.get(1).callOnInstance((SerializableCallable) () -> { + UntypedResultSet result = QueryProcessor.executeInternal( + String.format("SELECT is_superuser, can_login, salted_hash FROM %s.%s WHERE role = '%s'", + SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, DEFAULT_SUPERUSER_NAME)); + if (result.isEmpty()) + return null; + UntypedResultSet.Row row = result.one(); + return new Object[]{ row.getBoolean("is_superuser"), row.getBoolean("can_login"), row.has("salted_hash") }; + }); + + assertThat(roleRow).isNotNull(); + assertThat((Boolean) roleRow[0]).as("is_superuser").isTrue(); + assertThat((Boolean) roleRow[1]).as("can_login").isTrue(); + assertThat((Boolean) roleRow[2]).as("has salted_hash").isTrue(); + + // and the credential actually works end-to-end, not just the raw row contents + withAuthenticatedSession(CLUSTER.get(1), DEFAULT_SUPERUSER_NAME, DEFAULT_SUPERUSER_PASSWORD, session -> { + com.datastax.driver.core.ResultSet rows = session.execute("SELECT role FROM system_auth.roles WHERE role = ?", DEFAULT_SUPERUSER_NAME); + assertThat(rows.one().getString("role")).isEqualTo(DEFAULT_SUPERUSER_NAME); + }); + } + + // No client_encryption_options are configured for this cluster, so unlike TlsTestUtils#withAuthenticatedSession + // (which always requires SSLOptions) this connects in plaintext, matching ColumnMaskTest's local helper. + private static void withAuthenticatedSession(IInvokableInstance instance, String username, String password, Consumer consumer) + { + Auth.waitForExistingRoles(instance); + + InetAddress address = instance.broadcastAddress().getAddress(); + LoadBalancingPolicy lbc = new SingleHostLoadBalancingPolicy(address); + + Builder builder = com.datastax.driver.core.Cluster.builder() + .addContactPoints(address) + .withLoadBalancingPolicy(lbc) + .withCredentials(username, password); + + try (com.datastax.driver.core.Cluster cluster = builder.build(); Session session = cluster.connect()) + { + consumer.accept(session); + } + } +} diff --git a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java index 90c10d16cf5b..a90d1a1db203 100644 --- a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java +++ b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java @@ -23,6 +23,7 @@ import java.security.cert.CertificateException; import java.util.Arrays; import java.util.Collections; +import java.util.Map; import org.junit.After; import org.junit.Before; @@ -164,6 +165,29 @@ public void testNewInstanceForMutualTlsAuthenticator() assertTrue(DatabaseDescriptor.getRoleManager().alterableOptions().containsAll(authenticator.getAlterableRoleOptions())); } + @Test + public void testNewInstanceForMutualTlsDefaultRoleInitializer() + { + Config config = load("cassandra-mtls.yaml"); + config.default_role_initializer = new ParameterizedClass("org.apache.cassandra.auth.MutualTlsDefaultRoleInitializer", + Map.of("role", "cassandra", "identity", "spiffe1")); + DatabaseDescriptor.unsafeDaemonInitialization(()->config); + + assertThat(DatabaseDescriptor.getDefaultRoleInitializer()).isInstanceOf(MutualTlsDefaultRoleInitializer.class); + assertThat(DatabaseDescriptor.getDefaultRoleInitializer().defaultRoleName()).isEqualTo("cassandra"); + } + + @Test + public void testMutualTlsDefaultRoleInitializerRejectedWithIncompatibleAuthenticator() + { + Config config = load("cassandra-passwordauth.yaml"); + config.default_role_initializer = new ParameterizedClass("org.apache.cassandra.auth.MutualTlsDefaultRoleInitializer", + Map.of("role", "cassandra", "identity", "spiffe1")); + assertThatThrownBy(() -> DatabaseDescriptor.unsafeDaemonInitialization(()->config)) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("creates a role with no password"); + } + private static final String PROBE = ClassLoadingTestNonAssignable.class.getName(); private static Config baseConfig() @@ -209,6 +233,14 @@ public void testRoleManagerWrongTypeRejectedWithoutInitializing() assertApplyAuthRejectsProbe(); } + @Test + public void testDefaultRoleInitializerWrongTypeRejectedWithoutInitializing() + { + Config config = baseConfig(); + config.default_role_initializer = new ParameterizedClass(PROBE, Collections.emptyMap()); + assertApplyAuthRejectsProbe(); + } + @Test public void testInternodeAuthenticatorWrongTypeRejectedWithoutInitializing() { diff --git a/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java b/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java new file mode 100644 index 000000000000..c93fdfc5df55 --- /dev/null +++ b/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.auth; + +import java.util.Collections; +import java.util.Map; + +import org.junit.Test; + +import org.apache.cassandra.exceptions.ConfigurationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link PasswordDefaultRoleInitializer} and {@link MutualTlsDefaultRoleInitializer} that don't + * require a running node: unsupported-parameter rejection at construction time, and the parameter-presence checks + * in {@link IDefaultRoleInitializer#validateConfiguration()}. The authenticator-compatibility half of + * {@link MutualTlsDefaultRoleInitializer#validateConfiguration()} needs a configured {@code DatabaseDescriptor} + * authenticator and is covered by {@link AuthConfigTest} instead. + */ +public class DefaultRoleInitializerTest +{ + @Test + public void passwordInitializerRejectsUnsupportedParameter() + { + assertThatThrownBy(() -> new PasswordDefaultRoleInitializer(Map.of("bogus", "x"))) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("Unsupported parameter"); + } + + @Test + public void passwordInitializerDefaultsPassValidation() + { + new PasswordDefaultRoleInitializer().validateConfiguration(); + new PasswordDefaultRoleInitializer(Collections.emptyMap()).validateConfiguration(); + } + + @Test + public void passwordInitializerRejectsEmptyRole() + { + assertThatThrownBy(() -> new PasswordDefaultRoleInitializer(Map.of("role", "", "password", "x")).validateConfiguration()) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("role"); + } + + @Test + public void passwordInitializerRejectsEmptyPassword() + { + assertThatThrownBy(() -> new PasswordDefaultRoleInitializer(Map.of("role", "cassandra", "password", "")).validateConfiguration()) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("password"); + } + + @Test + public void passwordInitializerDefaultRoleNameMatchesConfiguredRole() + { + PasswordDefaultRoleInitializer initializer = new PasswordDefaultRoleInitializer(Map.of("role", "myrole", "password", "x")); + assertThat(initializer.defaultRoleName()).isEqualTo("myrole"); + } + + @Test + public void mutualTlsInitializerRejectsUnsupportedParameter() + { + assertThatThrownBy(() -> new MutualTlsDefaultRoleInitializer(Map.of("bogus", "x"))) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("Unsupported parameter"); + } + + @Test + public void mutualTlsInitializerRejectsMissingRole() + { + assertThatThrownBy(() -> new MutualTlsDefaultRoleInitializer(Map.of("identity", "spiffe1")).validateConfiguration()) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("role"); + } + + @Test + public void mutualTlsInitializerRejectsMissingIdentity() + { + assertThatThrownBy(() -> new MutualTlsDefaultRoleInitializer(Map.of("role", "cassandra")).validateConfiguration()) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("identity"); + } + + @Test + public void mutualTlsInitializerRejectsEmptyRoleAndIdentity() + { + assertThatThrownBy(() -> new MutualTlsDefaultRoleInitializer(Map.of("role", "", "identity", "")).validateConfiguration()) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("role"); + } + + @Test + public void mutualTlsInitializerDefaultRoleNameMatchesConfiguredRole() + { + MutualTlsDefaultRoleInitializer initializer = new MutualTlsDefaultRoleInitializer(Map.of("role", "cassandra", "identity", "spiffe1")); + assertThat(initializer.defaultRoleName()).isEqualTo("cassandra"); + } +} diff --git a/test/unit/org/apache/cassandra/auth/RolesTest.java b/test/unit/org/apache/cassandra/auth/RolesTest.java index 77520e3631b6..d439811be768 100644 --- a/test/unit/org/apache/cassandra/auth/RolesTest.java +++ b/test/unit/org/apache/cassandra/auth/RolesTest.java @@ -20,6 +20,7 @@ import java.util.Arrays; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -132,6 +133,33 @@ public void confirmSuperUserConsistency() Assert.assertEquals(nonPrivWriteLevel, DatabaseDescriptor.getAuthWriteConsistencyLevel()); } + @Test + public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() + { + IDefaultRoleInitializer previous = DatabaseDescriptor.getDefaultRoleInitializer(); + String customRole = "cassandra_mtls_custom_test_role"; + try + { + DatabaseDescriptor.setDefaultRoleInitializer(new MutualTlsDefaultRoleInitializer(Map.of("role", customRole, "identity", "spiffe1"))); + + ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(customRole); + Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); + + ConsistencyLevel writeLevel = CassandraRoleManager.consistencyForRoleWrite(customRole); + Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); + + ConsistencyLevel legacyReadLevel = CassandraRoleManager.consistencyForRoleRead(CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + Assert.assertEquals(legacyReadLevel, DatabaseDescriptor.getAuthReadConsistencyLevel()); + + ConsistencyLevel legacyWriteLevel = CassandraRoleManager.consistencyForRoleWrite(CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + Assert.assertEquals(legacyWriteLevel, DatabaseDescriptor.getAuthWriteConsistencyLevel()); + } + finally + { + DatabaseDescriptor.setDefaultRoleInitializer(previous); + } + } + @Test public void testSuperUsers() { diff --git a/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java b/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java index 6477fc25f283..29eabd57f14d 100644 --- a/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java +++ b/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java @@ -74,6 +74,7 @@ public class DatabaseDescriptorRefTest "org.apache.cassandra.auth.IAuthorizer", "org.apache.cassandra.auth.ICIDRAuthorizer", "org.apache.cassandra.auth.ICIDRAuthorizer$CIDRAuthorizerMode", + "org.apache.cassandra.auth.IDefaultRoleInitializer", "org.apache.cassandra.auth.IInternodeAuthenticator", "org.apache.cassandra.auth.INetworkAuthorizer", "org.apache.cassandra.auth.IRoleManager", From 1e7e2102cbe22e2c20ce15e6b22b7789940b27e9 Mon Sep 17 00:00:00 2001 From: Stefan Miklosovic Date: Mon, 3 Aug 2026 15:22:19 +0200 Subject: [PATCH 02/21] rewrite --- conf/cassandra.yaml | 47 +++++----- .../org/apache/cassandra/auth/AuthConfig.java | 6 +- .../cassandra/auth/CassandraRoleManager.java | 73 ++++++---------- .../auth/IDefaultRoleInitializer.java | 87 ++++++++++++++----- .../apache/cassandra/auth/IRoleManager.java | 4 + .../auth/MutualTlsDefaultRoleInitializer.java | 14 ++- .../auth/PasswordDefaultRoleInitializer.java | 19 +++- .../org/apache/cassandra/config/Config.java | 1 - .../cassandra/config/DatabaseDescriptor.java | 12 --- .../apache/cassandra/auth/AuthConfigTest.java | 26 +++--- .../org/apache/cassandra/auth/RolesTest.java | 11 ++- 11 files changed, 169 insertions(+), 131 deletions(-) diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index a12520c67349..5dd1e53b2db2 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -231,9 +231,32 @@ authorizer: # # - CassandraRoleManager stores role data in the system_auth keyspace. Please # increase system_auth keyspace replication factor if you use this role manager. +# +# The creation of the initial role on a cluster which has no roles yet, implementing IDefaultRoleInitializer, +# is driven by default_role_initializer_class_name property. +# Most functions of the IRoleManager require an authenticated login, so a cluster with no roles has no way +# to create the first one; this option controls how that role is bootstrapped. +# +# default_role_initializer_class_name defaults to PasswordDefaultRoleInitializer, +# which creates a 'cassandra' superuser whose password is also 'cassandra'. +# That password is a published constant, so deployments using it must rotate or drop the role +# before the native transport is reachable. +# +# MutualTlsDefaultRoleInitializer instead creates the role with no password at all and maps a client +# certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting +# mutual TLS, such as MutualTlsAuthenticator. role_manager: class_name: CassandraRoleManager parameters: + # Controls default password for initial role + # default_role_initializer_class_name: PasswordDefaultRoleInitializer + # default_role_initializer_role: cassandra + # default_role_initializer_password: cassandra + # For mutual TLS, use this: + # default_role_initializer_class_name: MutualTlsDefaultRoleInitializer + # default_role_initializer_role: cassandra_mtls + # default_role_initializer_identity: spiffe1 + # # Controls how often invalid roles are disconnected, such as when a role is altered with LOGIN=false # Task is scheduled with period + random(0, max_jitter) delay between executions # It's recommended to set these longer than the roles cache refresh period, since the invalidation check depends on @@ -241,30 +264,6 @@ role_manager: # invalid_role_disconnect_task_period: 4h # invalid_role_disconnect_task_max_jitter: 1h -# Creates the initial role on a cluster which has no roles yet, implementing IDefaultRoleInitializer. -# Most functions of the IRoleManager require an authenticated login, so a cluster with no roles has no way -# to create the first one; this option controls how that role is bootstrapped. -# -# Defaults to PasswordDefaultRoleInitializer, which creates a 'cassandra' superuser whose password is also -# 'cassandra'. That password is a published constant, so deployments using it must rotate or drop the role -# before the native transport is reachable. -# -# MutualTlsDefaultRoleInitializer instead creates the role with no password at all and maps a client -# certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting -# mutual TLS, such as MutualTlsAuthenticator. -# -# default_role_initializer: -# class_name: PasswordDefaultRoleInitializer -# parameters: -# role: cassandra -# password: cassandra -# -# default_role_initializer: -# class_name: MutualTlsDefaultRoleInitializer -# parameters: -# role: cassandra_mtls -# identity: "spiffe1" - # Network authorization backend, implementing INetworkAuthorizer; used to restrict user # access to certain DCs # Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllNetworkAuthorizer, diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index 707582e3e7ba..c5204ee62fd7 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -101,9 +101,6 @@ public static void applyAuth() DatabaseDescriptor.setRoleManager(roleManager); - IDefaultRoleInitializer defaultRoleInitializer = authInstantiate(conf.default_role_initializer, IDefaultRoleInitializer.class, PasswordDefaultRoleInitializer.class); - DatabaseDescriptor.setDefaultRoleInitializer(defaultRoleInitializer); - // authenticator IInternodeAuthenticator internodeAuthenticator = authInstantiate(conf.internode_authenticator, @@ -143,13 +140,12 @@ public static void applyAuth() authenticator.validateConfiguration(); authorizer.validateConfiguration(); roleManager.validateConfiguration(); - defaultRoleInitializer.validateConfiguration(); networkAuthorizer.validateConfiguration(); cidrAuthorizer.validateConfiguration(); DatabaseDescriptor.getInternodeAuthenticator().validateConfiguration(); } - private static T authInstantiate(ParameterizedClass authCls, Class expectedType, Class defaultCls) { + public static T authInstantiate(ParameterizedClass authCls, Class expectedType, Class defaultCls) { if (authCls != null && authCls.class_name != null) { String authPackage = AuthConfig.class.getPackage().getName(); diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index 9d7ec4337e40..229be9f8baec 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -55,6 +55,7 @@ import org.apache.cassandra.config.CassandraRelevantProperties; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.config.DurationSpec; +import org.apache.cassandra.config.ParameterizedClass; import org.apache.cassandra.cql3.CQLStatement; import org.apache.cassandra.cql3.ColumnIdentifier; import org.apache.cassandra.cql3.ColumnSpecification; @@ -77,7 +78,6 @@ import org.apache.cassandra.service.ClientState; import org.apache.cassandra.service.StorageProxy; import org.apache.cassandra.service.StorageService; -import org.apache.cassandra.tcm.ClusterMetadata; import org.apache.cassandra.transport.Dispatcher; import org.apache.cassandra.transport.messages.ResultMessage; import org.apache.cassandra.utils.ByteBufferUtil; @@ -139,11 +139,7 @@ public class CassandraRoleManager implements IRoleManager, CassandraRoleManagerM */ static final ConsistencyLevel DEFAULT_SUPERUSER_CONSISTENCY_LEVEL = ConsistencyLevel.QUORUM; - /** - * Used when no default_role_initializer is configured, or when auth setup has not run, e.g. in tests which - * do not call {@link AuthConfig#applyAuth()}. Preserves the historical bootstrap behaviour. - */ - private static final IDefaultRoleInitializer DEFAULT_ROLE_INITIALIZER = new PasswordDefaultRoleInitializer(); + private final IDefaultRoleInitializer defaultRoleInitializer; // Transform a row in the AuthKeyspace.ROLES to a Role instance private static final Function ROW_TO_ROLE = row -> @@ -219,10 +215,29 @@ public CassandraRoleManager(Map parameters) invalidClientDisconnectPeriodMillis = new DurationSpec.LongMillisecondsBound(parameters.getOrDefault(PARAM_INVALID_ROLE_DISCONNECT_TASK_PERIOD, "0h")).toMilliseconds(); invalidClientDisconnectMaxJitterMillis = new DurationSpec.LongMillisecondsBound(parameters.getOrDefault(PARAM_INVALID_ROLE_DISCONNECT_TASK_MAX_JITTER, "0h")).toMilliseconds(); + String roleInitializerClassName = parameters.getOrDefault(IDefaultRoleInitializer.DEFAULT_ROLE_INITIALIZER_CLASS_NAME, PasswordDefaultRoleInitializer.class.getName()); + + Map defaultRoleInitializerParameters = parameters.entrySet() + .stream() + .filter(e -> e.getKey().startsWith("default_role_initializer_") + && !e.getKey().equals("default_role_initializer_class_name")) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + defaultRoleInitializer = AuthConfig.authInstantiate(new ParameterizedClass(roleInitializerClassName, + defaultRoleInitializerParameters), + IDefaultRoleInitializer.class, + PasswordDefaultRoleInitializer.class); + if (!MBeanWrapper.instance.isRegistered(MBEAN_NAME)) MBeanWrapper.instance.registerMBean(this, MBEAN_NAME); } + @Override + public IDefaultRoleInitializer defaultRoleInitializer() + { + return defaultRoleInitializer; + } + @Override public void setup(boolean asyncRoleSetup) { @@ -234,7 +249,7 @@ public void setup(boolean asyncRoleSetup) try { // Try to set up synchronously - setupDefaultRole(); + defaultRoleInitializer().setupDefaultRole(); return; } catch (Throwable t) @@ -243,7 +258,7 @@ public void setup(boolean asyncRoleSetup) } } scheduleSetupTask(() -> { - setupDefaultRole(); + defaultRoleInitializer().setupDefaultRole(); return null; }); } @@ -523,30 +538,7 @@ public Set protectedResources() public void validateConfiguration() throws ConfigurationException { - } - - /* - * Create the default superuser role to bootstrap role creation on a clean system. Preemptively - * gives the role the default password so PasswordAuthenticator can be used to log in (if - * configured) - */ - private static void setupDefaultRole() - { - if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) - throw new IllegalStateException("CassandraRoleManager skipped default role setup: no known tokens in ring"); - - try - { - if (!hasExistingRoles()) - { - defaultRoleInitializer().initialize(); - } - } - catch (RequestExecutionException e) - { - logger.warn("CassandraRoleManager skipped default role setup: some nodes were not ready"); - throw e; - } + defaultRoleInitializer.validateConfiguration(); } @VisibleForTesting @@ -562,12 +554,7 @@ public static String createDefaultRoleQuery() @VisibleForTesting public static boolean hasExistingRoles() throws RequestExecutionException { - // Try looking up the configured default role first, to avoid the range query if possible. - String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleInitializer().defaultRoleName())); - String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); - return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() - || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() - || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); + return DatabaseDescriptor.getRoleManager().defaultRoleInitializer().hasExistingRoles(); } protected void scheduleSetupTask(final Callable setupTask) @@ -785,12 +772,6 @@ static String escape(String name) return StringUtils.replace(name, "'", "''"); } - private static IDefaultRoleInitializer defaultRoleInitializer() - { - IDefaultRoleInitializer initializer = DatabaseDescriptor.getDefaultRoleInitializer(); - return initializer == null ? DEFAULT_ROLE_INITIALIZER : initializer; - } - private static ByteBuffer byteBuf(String str) { return UTF8Type.instance.decompose(str); @@ -799,14 +780,14 @@ private static ByteBuffer byteBuf(String str) /** Allows selective overriding of the consistency level for specific roles. */ protected static ConsistencyLevel consistencyForRoleWrite(String role) { - return role.equals(defaultRoleInitializer().defaultRoleName()) ? + return role.equals(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authWriteConsistencyLevel(); } protected static ConsistencyLevel consistencyForRoleRead(String role) { - return role.equals(defaultRoleInitializer().defaultRoleName()) ? + return role.equals(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authReadConsistencyLevel(); } diff --git a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java index 2ea2f63e94d5..d2c0909ece50 100644 --- a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java @@ -18,14 +18,25 @@ package org.apache.cassandra.auth; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.db.ConsistencyLevel; import org.apache.cassandra.exceptions.ConfigurationException; +import org.apache.cassandra.exceptions.RequestExecutionException; +import org.apache.cassandra.schema.SchemaConstants; +import org.apache.cassandra.tcm.ClusterMetadata; /** * Creates the initial role on a cluster which has no roles yet, so that there is some * identity available to authenticate as and grant permissions from. Selected via * {@code default_role_initializer} option in cassandra.yaml and instantiated by * {@link AuthConfig#applyAuth()} - * + *

* Implementations decide both what the role is called and how it is authenticated. * See {@link PasswordDefaultRoleInitializer} which gives the role a password, and * {@link MutualTlsDefaultRoleInitializer} which gives no password and instead @@ -33,39 +44,75 @@ */ public interface IDefaultRoleInitializer { + Logger logger = LoggerFactory.getLogger(IDefaultRoleInitializer.class); + + String DEFAULT_ROLE_INITIALIZER_CLASS_NAME = "default_role_initializer_class_name"; + /** - * Creates the default role. Called from {@link CassandraRoleManager#setup(boolean)} only after - * {@link CassandraRoleManager#hasExistingRoles()} has established that the cluster has no rules. - * - * Every node runs this independently during initial startup so implementations must write at - * {@link CassandraRoleManager#consistencyForRoleWrite(String)} to avoid concurrent duplicate creation - * and must use {@code USING TIMESTAMP 0} so that any operator changes to the role later supersede it. - * + * Creates the default role. + * When using this in connection with CassandraRoleManager, every node runs this independently during initial + * startup so implementations must write at {@link CassandraRoleManager#consistencyForRoleWrite(String)} to + * avoid concurrent duplicate creation and must use {@code USING TIMESTAMP 0} so that any operator changes + * to the role later supersede it. + *

* The caller retries on failure so this may be invoked more than once on a node: it must not fail - * @throws org.apache.cassandra.exceptions.RequestExecutionException if not enough nodes are available - * yet which the caller treats as a signal to reschedule + * + * @throws RequestExecutionException if not enough nodes are available + * yet which the caller treats as a signal to reschedule */ - void initialize(); + void createDefaultRole(); /** - * The name of the role {@link #initialize()} creates. - * - * The default role is a special case during startup: reads and writes of it are performed at - * {@link CassandraRoleManager#DEFAULT_SUPERUSER_CONSISTENCY_LEVEL} rather than at the configured auth - * consistency levels, and {@link CassandraRoleManager#hasExistingRoles()} looks it up by name before - * falling back to a range query. Both need to know the configured name, not assume - * {@link CassandraRoleManager#DEFAULT_SUPERUSER_NAME}. + * The name of the role {@link #createDefaultRole()} creates. */ String defaultRoleName(); /** * Validates configuration of the IDefaultRoleInitializer implementation (if configurable). - * + *

* Called by {@link AuthConfig#applyAuth()} after the authenticator, authorizer and role manager have been * set, so implementations may inspect those to reject combinations which would leave the cluster with no * usable login. * * @throws ConfigurationException when there is a configuration error. */ - default void validateConfiguration() throws ConfigurationException {} + default void validateConfiguration() throws ConfigurationException + { + } + + Map parameters(); + + /* + * Create the default superuser role to bootstrap role creation on a clean system. Preemptively + * gives the role the default password so PasswordAuthenticator can be used to log in (if + * configured) + */ + default void setupDefaultRole() + { + if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) + throw new IllegalStateException("CassandraRoleManager skipped default role setup: no known tokens in ring"); + + try + { + if (!hasExistingRoles()) + { + createDefaultRole(); + } + } + catch (RequestExecutionException e) + { + logger.warn("CassandraRoleManager skipped default role setup: some nodes were not ready"); + throw e; + } + } + + default boolean hasExistingRoles() + { + // Try looking up the configured default role first, to avoid the range query if possible. + String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, StringUtils.replace(defaultRoleName(), "'", "''")); + String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); + return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() + || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() + || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); + } } diff --git a/src/java/org/apache/cassandra/auth/IRoleManager.java b/src/java/org/apache/cassandra/auth/IRoleManager.java index ef4f816775dd..af3070ceeee3 100644 --- a/src/java/org/apache/cassandra/auth/IRoleManager.java +++ b/src/java/org/apache/cassandra/auth/IRoleManager.java @@ -330,4 +330,8 @@ default void dropIdentity(String identity) } + default IDefaultRoleInitializer defaultRoleInitializer() + { + return PasswordDefaultRoleInitializer.instance; + } } diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index 55f091d66ce4..069bb83e56ca 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -37,12 +37,13 @@ public class MutualTlsDefaultRoleInitializer implements IDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(MutualTlsDefaultRoleInitializer.class); - static final String ROLE = "role"; - static final String IDENTITY = "identity"; + public static final String ROLE = "default_role_initializer_role"; + public static final String IDENTITY = "default_role_initializer_identity"; private static final Set SUPPORTED_PARAMS = Set.of(ROLE, IDENTITY); private final String role; private final String identity; + private final Map parameters; public MutualTlsDefaultRoleInitializer(Map parameters) { @@ -55,10 +56,11 @@ public MutualTlsDefaultRoleInitializer(Map parameters) } role = parameters.get(ROLE); identity = parameters.get(IDENTITY); + this.parameters = Map.of(ROLE, role, IDENTITY, identity); } @Override - public void initialize() + public void createDefaultRole() { QueryProcessor.process(String.format("INSERT INTO %s.%s (role, is_superuser, can_login) " + "VALUES ('%s', true, true) USING TIMESTAMP 0", @@ -110,4 +112,10 @@ public void validateConfiguration() throws ConfigurationException MutualTlsAuthenticator.class.getSimpleName())); } } + + @Override + public Map parameters() + { + return parameters; + } } diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 62996481fad0..3d25b6052021 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.Set; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import org.slf4j.Logger; @@ -50,13 +51,18 @@ public class PasswordDefaultRoleInitializer implements IDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(PasswordDefaultRoleInitializer.class); - static final String ROLE = "role"; - static final String PASSWORD = "password"; + @VisibleForTesting + public static final String ROLE = "default_role_initializer_role"; + @VisibleForTesting + public static final String PASSWORD = "default_role_initializer_password"; private static final Set SUPPORTED_PARAMS = Set.of(ROLE, PASSWORD); private final String role; private final String password; + private final Map parameters; + + public static final PasswordDefaultRoleInitializer instance = new PasswordDefaultRoleInitializer(); public PasswordDefaultRoleInitializer() { @@ -73,10 +79,11 @@ public PasswordDefaultRoleInitializer(Map parameters) role = parameters.getOrDefault(ROLE, DEFAULT_SUPERUSER_NAME); password = parameters.getOrDefault(PASSWORD, DEFAULT_SUPERUSER_PASSWORD); + this.parameters = Map.of(ROLE, role, PASSWORD, password); } @Override - public void initialize() + public void createDefaultRole() { QueryProcessor.process(String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) " + @@ -104,4 +111,10 @@ public void validateConfiguration() throws ConfigurationException if (Strings.isNullOrEmpty(password)) throw new ConfigurationException(String.format("%s requires a non-empty %s parameter", getClass().getSimpleName(), PASSWORD)); } + + @Override + public Map parameters() + { + return parameters; + } } diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index b891dd14bad2..8df1a05cf18a 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -86,7 +86,6 @@ public static Set splitCommaDelimited(String src) public ParameterizedClass authenticator; public ParameterizedClass authorizer; public ParameterizedClass role_manager; - public ParameterizedClass default_role_initializer; public ParameterizedClass crypto_provider; public ParameterizedClass network_authorizer; public ParameterizedClass cidr_authorizer; diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java index 97d0e43c6a01..5bc6de7ac1e4 100644 --- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java +++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java @@ -77,7 +77,6 @@ import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.auth.IAuthorizer; import org.apache.cassandra.auth.ICIDRAuthorizer; -import org.apache.cassandra.auth.IDefaultRoleInitializer; import org.apache.cassandra.auth.IInternodeAuthenticator; import org.apache.cassandra.auth.INetworkAuthorizer; import org.apache.cassandra.auth.IRoleManager; @@ -241,7 +240,6 @@ public class DatabaseDescriptor // Don't initialize the role manager until applying config. The options supported by CassandraRoleManager // depend on the configured IAuthenticator, so defer creating it until that's been set. private static IRoleManager roleManager; - private static IDefaultRoleInitializer defaultRoleInitializer; private static long preparedStatementsCacheSizeInMiB; @@ -2228,16 +2226,6 @@ public static void setRoleManager(IRoleManager roleManager) DatabaseDescriptor.roleManager = roleManager; } - public static IDefaultRoleInitializer getDefaultRoleInitializer() - { - return defaultRoleInitializer; - } - - public static void setDefaultRoleInitializer(IDefaultRoleInitializer defaultRoleInitializer) - { - DatabaseDescriptor.defaultRoleInitializer = defaultRoleInitializer; - } - public static int getPermissionsValidity() { return conf.permissions_validity.toMilliseconds(); diff --git a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java index a90d1a1db203..d8f4a3503d4f 100644 --- a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java +++ b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java @@ -165,24 +165,30 @@ public void testNewInstanceForMutualTlsAuthenticator() assertTrue(DatabaseDescriptor.getRoleManager().alterableOptions().containsAll(authenticator.getAlterableRoleOptions())); } + private static ParameterizedClass mutualTlsDefaultRoleInitializer() + { + return new ParameterizedClass(CassandraRoleManager.class.getName(), + Map.of(IDefaultRoleInitializer.DEFAULT_ROLE_INITIALIZER_CLASS_NAME, MutualTlsDefaultRoleInitializer.class.getName(), + MutualTlsDefaultRoleInitializer.ROLE, "cassandra", + MutualTlsDefaultRoleInitializer.IDENTITY, "spiffe1")); + } + @Test public void testNewInstanceForMutualTlsDefaultRoleInitializer() { Config config = load("cassandra-mtls.yaml"); - config.default_role_initializer = new ParameterizedClass("org.apache.cassandra.auth.MutualTlsDefaultRoleInitializer", - Map.of("role", "cassandra", "identity", "spiffe1")); + config.role_manager = mutualTlsDefaultRoleInitializer(); DatabaseDescriptor.unsafeDaemonInitialization(()->config); - assertThat(DatabaseDescriptor.getDefaultRoleInitializer()).isInstanceOf(MutualTlsDefaultRoleInitializer.class); - assertThat(DatabaseDescriptor.getDefaultRoleInitializer().defaultRoleName()).isEqualTo("cassandra"); + assertThat(DatabaseDescriptor.getRoleManager().defaultRoleInitializer()).isInstanceOf(MutualTlsDefaultRoleInitializer.class); + assertThat(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()).isEqualTo("cassandra"); } @Test public void testMutualTlsDefaultRoleInitializerRejectedWithIncompatibleAuthenticator() { Config config = load("cassandra-passwordauth.yaml"); - config.default_role_initializer = new ParameterizedClass("org.apache.cassandra.auth.MutualTlsDefaultRoleInitializer", - Map.of("role", "cassandra", "identity", "spiffe1")); + config.role_manager = mutualTlsDefaultRoleInitializer(); assertThatThrownBy(() -> DatabaseDescriptor.unsafeDaemonInitialization(()->config)) .isInstanceOf(ConfigurationException.class) .hasMessageContaining("creates a role with no password"); @@ -233,14 +239,6 @@ public void testRoleManagerWrongTypeRejectedWithoutInitializing() assertApplyAuthRejectsProbe(); } - @Test - public void testDefaultRoleInitializerWrongTypeRejectedWithoutInitializing() - { - Config config = baseConfig(); - config.default_role_initializer = new ParameterizedClass(PROBE, Collections.emptyMap()); - assertApplyAuthRejectsProbe(); - } - @Test public void testInternodeAuthenticatorWrongTypeRejectedWithoutInitializing() { diff --git a/test/unit/org/apache/cassandra/auth/RolesTest.java b/test/unit/org/apache/cassandra/auth/RolesTest.java index d439811be768..079afe4e9f7e 100644 --- a/test/unit/org/apache/cassandra/auth/RolesTest.java +++ b/test/unit/org/apache/cassandra/auth/RolesTest.java @@ -43,6 +43,7 @@ import static org.apache.cassandra.auth.AuthTestUtils.ROLE_C; import static org.apache.cassandra.auth.AuthTestUtils.getRolesReadCount; import static org.apache.cassandra.auth.AuthTestUtils.grantRolesTo; +import static org.apache.cassandra.auth.IDefaultRoleInitializer.DEFAULT_ROLE_INITIALIZER_CLASS_NAME; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -136,11 +137,15 @@ public void confirmSuperUserConsistency() @Test public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() { - IDefaultRoleInitializer previous = DatabaseDescriptor.getDefaultRoleInitializer(); + IRoleManager previous = DatabaseDescriptor.getRoleManager(); String customRole = "cassandra_mtls_custom_test_role"; try { - DatabaseDescriptor.setDefaultRoleInitializer(new MutualTlsDefaultRoleInitializer(Map.of("role", customRole, "identity", "spiffe1"))); + DatabaseDescriptor.setRoleManager(new CassandraRoleManager(Map.of( + DEFAULT_ROLE_INITIALIZER_CLASS_NAME, MutualTlsDefaultRoleInitializer.class.getSimpleName(), + MutualTlsDefaultRoleInitializer.ROLE, customRole, + MutualTlsDefaultRoleInitializer.IDENTITY, "spiffe1")) + ); ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(customRole); Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); @@ -156,7 +161,7 @@ public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() } finally { - DatabaseDescriptor.setDefaultRoleInitializer(previous); + DatabaseDescriptor.setRoleManager(previous); } } From 9d7b7ee672d450b970a11e740fb5df0c7b1d4e4d Mon Sep 17 00:00:00 2001 From: Stefan Miklosovic Date: Mon, 3 Aug 2026 17:25:24 +0200 Subject: [PATCH 03/21] redact values of ParameterizedClass parameters map which contain a password-like entry --- .../cassandra/config/ParameterizedClass.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/java/org/apache/cassandra/config/ParameterizedClass.java b/src/java/org/apache/cassandra/config/ParameterizedClass.java index 803fcb1fac18..9485d95227bf 100644 --- a/src/java/org/apache/cassandra/config/ParameterizedClass.java +++ b/src/java/org/apache/cassandra/config/ParameterizedClass.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -168,6 +169,20 @@ public int hashCode() @Override public String toString() { - return class_name + parameters; + if (parameters == null) + { + return class_name + "{}"; + } + else + { + Map sanitizedMap = parameters.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, + e -> e.getKey().contains("password") ? "" : e.getValue(), + (a, b) -> a, + TreeMap::new)); + + return class_name + sanitizedMap; + } } } From 06a3726f851a6cbdd7d50e51cc0a492fd96e5a29 Mon Sep 17 00:00:00 2001 From: Stefan Miklosovic Date: Mon, 3 Aug 2026 17:50:54 +0200 Subject: [PATCH 04/21] support password hash --- conf/cassandra.yaml | 3 +- .../auth/PasswordDefaultRoleInitializer.java | 30 +++++++++++++++---- .../cassandra/config/ParameterizedClass.java | 14 +++++---- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index 5dd1e53b2db2..dc46b04dce09 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -248,9 +248,10 @@ authorizer: role_manager: class_name: CassandraRoleManager parameters: - # Controls default password for initial role + # Controls default password for initial role, if password hash is set, password is ignored # default_role_initializer_class_name: PasswordDefaultRoleInitializer # default_role_initializer_role: cassandra + # default_role_initializer_password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # default_role_initializer_password: cassandra # For mutual TLS, use this: # default_role_initializer_class_name: MutualTlsDefaultRoleInitializer diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 3d25b6052021..07f756214568 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -18,6 +18,7 @@ package org.apache.cassandra.auth; +import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -55,11 +56,14 @@ public class PasswordDefaultRoleInitializer implements IDefaultRoleInitializer public static final String ROLE = "default_role_initializer_role"; @VisibleForTesting public static final String PASSWORD = "default_role_initializer_password"; + @VisibleForTesting + public static final String PASSWORD_HASH = "default_role_initializer_password_hash"; - private static final Set SUPPORTED_PARAMS = Set.of(ROLE, PASSWORD); + private static final Set SUPPORTED_PARAMS = Set.of(ROLE, PASSWORD, PASSWORD_HASH); private final String role; private final String password; + private final String passwordHash; private final Map parameters; public static final PasswordDefaultRoleInitializer instance = new PasswordDefaultRoleInitializer(); @@ -78,8 +82,17 @@ public PasswordDefaultRoleInitializer(Map parameters) } role = parameters.getOrDefault(ROLE, DEFAULT_SUPERUSER_NAME); - password = parameters.getOrDefault(PASSWORD, DEFAULT_SUPERUSER_PASSWORD); - this.parameters = Map.of(ROLE, role, PASSWORD, password); + passwordHash = parameters.get(PASSWORD_HASH); + Map santizedParams = new HashMap<>(); + santizedParams.put(ROLE, role); + if (passwordHash != null) + santizedParams.put(PASSWORD_HASH, passwordHash); + if (passwordHash == null) + password = parameters.getOrDefault(PASSWORD, DEFAULT_SUPERUSER_PASSWORD); + else + password = null; + + this.parameters = santizedParams; } @Override @@ -91,7 +104,7 @@ public void createDefaultRole() SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(role), - escape(hashpw(password))), + escape(password == null ? passwordHash : hashpw(password))), consistencyForRoleWrite(role)); logger.info("Created default superuser role '{}'", role); } @@ -108,8 +121,13 @@ public void validateConfiguration() throws ConfigurationException if (Strings.isNullOrEmpty(role)) throw new ConfigurationException(String.format("%s requires a non-empty %s parameter", getClass().getSimpleName(), ROLE)); - if (Strings.isNullOrEmpty(password)) - throw new ConfigurationException(String.format("%s requires a non-empty %s parameter", getClass().getSimpleName(), PASSWORD)); + boolean specifiedPassword = !Strings.isNullOrEmpty(password); + boolean specifiedPasswordHash = !Strings.isNullOrEmpty(passwordHash); + + if (!specifiedPassword && !specifiedPasswordHash) + throw new ConfigurationException(String.format("There has to be one of %s, %s specified.", PASSWORD, PASSWORD_HASH)); + else if (specifiedPassword && specifiedPasswordHash) + throw new ConfigurationException(String.format("Only one of %s, %s can be specified.", PASSWORD, PASSWORD_HASH)); } @Override diff --git a/src/java/org/apache/cassandra/config/ParameterizedClass.java b/src/java/org/apache/cassandra/config/ParameterizedClass.java index 9485d95227bf..33fa752cea9e 100644 --- a/src/java/org/apache/cassandra/config/ParameterizedClass.java +++ b/src/java/org/apache/cassandra/config/ParameterizedClass.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.TreeMap; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -31,6 +32,7 @@ import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.utils.Shared; +import static java.util.stream.Collectors.toMap; import static org.apache.cassandra.utils.Shared.Scope.SIMULATION; @Shared(scope = SIMULATION) @@ -177,11 +179,13 @@ public String toString() { Map sanitizedMap = parameters.entrySet() .stream() - .collect(Collectors.toMap(Map.Entry::getKey, - e -> e.getKey().contains("password") ? "" : e.getValue(), - (a, b) -> a, - TreeMap::new)); - + .collect(toMap(Entry::getKey, + e -> e.getKey().contains("password") + || e.getKey().contains("hash") + ? "" + : e.getValue(), + (a, b) -> a, + TreeMap::new)); return class_name + sanitizedMap; } } From cd041772b59310f6e1b40d4d62f406fd489f66b9 Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Tue, 4 Aug 2026 09:15:42 -0700 Subject: [PATCH 05/21] my review --- .../cassandra/auth/CassandraRoleManager.java | 13 ----- .../auth/IDefaultRoleInitializer.java | 9 ++-- .../auth/MutualTlsDefaultRoleInitializer.java | 16 ++---- .../auth/PasswordDefaultRoleInitializer.java | 49 +++++++------------ .../cassandra/config/ParameterizedClass.java | 11 +++-- .../distributed/test/ColumnMaskTest.java | 4 +- .../test/auth/AuthAuditLoggingTest.java | 4 +- .../PasswordDefaultRoleInitializerTest.java | 4 +- .../auth/AllowAllCIDRAuthorizerTest.java | 2 +- .../apache/cassandra/auth/AuthTestUtils.java | 4 +- ...assandraCIDRAuthorizerEnforceModeTest.java | 12 ++--- ...assandraCIDRAuthorizerMonitorModeTest.java | 10 ++-- .../auth/PasswordAuthenticatorTest.java | 2 +- .../org/apache/cassandra/auth/RolesTest.java | 8 +-- .../org/apache/cassandra/cql3/CQLTester.java | 3 +- .../statements/AddIdentityStatementTest.java | 6 +-- .../db/guardrails/GuardrailTester.java | 4 +- .../cassandra/transport/TlsTestUtils.java | 4 +- 18 files changed, 67 insertions(+), 98 deletions(-) diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index 229be9f8baec..475dc5975ebb 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -106,9 +106,6 @@ public class CassandraRoleManager implements IRoleManager, CassandraRoleManagerM private static final Logger logger = LoggerFactory.getLogger(CassandraRoleManager.class); private static final NoSpamLogger nospamLogger = NoSpamLogger.getLogger(logger, 1L, TimeUnit.MINUTES); - public static final String DEFAULT_SUPERUSER_NAME = "cassandra"; - public static final String DEFAULT_SUPERUSER_PASSWORD = "cassandra"; - /** * Role options which are supported for all authentication mechanisms. IAuthenticator implementations can declare * additional supported role options via {@link IAuthenticator#getSupportedRoleOptions()}. @@ -541,16 +538,6 @@ public void validateConfiguration() throws ConfigurationException defaultRoleInitializer.validateConfiguration(); } - @VisibleForTesting - public static String createDefaultRoleQuery() - { - return String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) VALUES ('%s', true, true, '%s') USING TIMESTAMP 0", - SchemaConstants.AUTH_KEYSPACE_NAME, - AuthKeyspace.ROLES, - DEFAULT_SUPERUSER_NAME, - escape(hashpw(DEFAULT_SUPERUSER_PASSWORD))); - } - @VisibleForTesting public static boolean hasExistingRoles() throws RequestExecutionException { diff --git a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java index d2c0909ece50..e64ea80775c8 100644 --- a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java @@ -18,9 +18,6 @@ package org.apache.cassandra.auth; -import java.util.Map; - -import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,6 +28,8 @@ import org.apache.cassandra.schema.SchemaConstants; import org.apache.cassandra.tcm.ClusterMetadata; +import static org.apache.cassandra.auth.CassandraRoleManager.escape; + /** * Creates the initial role on a cluster which has no roles yet, so that there is some * identity available to authenticate as and grant permissions from. Selected via @@ -80,8 +79,6 @@ default void validateConfiguration() throws ConfigurationException { } - Map parameters(); - /* * Create the default superuser role to bootstrap role creation on a clean system. Preemptively * gives the role the default password so PasswordAuthenticator can be used to log in (if @@ -109,7 +106,7 @@ default void setupDefaultRole() default boolean hasExistingRoles() { // Try looking up the configured default role first, to avoid the range query if possible. - String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, StringUtils.replace(defaultRoleName(), "'", "''")); + String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleName())); String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index 069bb83e56ca..5af1dc11e552 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -37,17 +37,16 @@ public class MutualTlsDefaultRoleInitializer implements IDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(MutualTlsDefaultRoleInitializer.class); - public static final String ROLE = "default_role_initializer_role"; - public static final String IDENTITY = "default_role_initializer_identity"; + public static final String ROLE = "role"; + public static final String IDENTITY = "identity"; private static final Set SUPPORTED_PARAMS = Set.of(ROLE, IDENTITY); private final String role; private final String identity; - private final Map parameters; public MutualTlsDefaultRoleInitializer(Map parameters) { - for (String params: parameters.keySet()) + for (String params : parameters.keySet()) { if (!SUPPORTED_PARAMS.contains(params)) { @@ -56,7 +55,6 @@ public MutualTlsDefaultRoleInitializer(Map parameters) } role = parameters.get(ROLE); identity = parameters.get(IDENTITY); - this.parameters = Map.of(ROLE, role, IDENTITY, identity); } @Override @@ -77,7 +75,7 @@ public void createDefaultRole() escape(role)), consistencyForRoleWrite(role)); - logger.info("Created passwordless default superuser role '{}' for identity '{}'", role, identity); + logger.info("Created passwordless default superuser role '{}' with identity '{}'", role, identity); } @Override @@ -112,10 +110,4 @@ public void validateConfiguration() throws ConfigurationException MutualTlsAuthenticator.class.getSimpleName())); } } - - @Override - public Map parameters() - { - return parameters; - } } diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 07f756214568..33cd1f389b6a 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -18,7 +18,6 @@ package org.apache.cassandra.auth; -import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -32,8 +31,6 @@ import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.schema.SchemaConstants; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_NAME; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; import static org.apache.cassandra.auth.CassandraRoleManager.consistencyForRoleWrite; import static org.apache.cassandra.auth.CassandraRoleManager.escape; import static org.apache.cassandra.auth.CassandraRoleManager.hashpw; @@ -43,7 +40,7 @@ * {@link PasswordAuthenticator}. This is the default {@link IDefaultRoleInitializer} and reproduces the * historical bootstrap behaviour of creating a {@code cassandra} superuser whose password is also * {@code cassandra}. - * + *

* Because that password is a well known constant, deployments which can authenticate by other means should * prefer an initializer which does not create a password at all, such as * {@link MutualTlsDefaultRoleInitializer}. Deployments which do use this initializer should rotate or drop the @@ -52,19 +49,21 @@ public class PasswordDefaultRoleInitializer implements IDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(PasswordDefaultRoleInitializer.class); + + public static final String DEFAULT_SUPERUSER_NAME = "cassandra"; + public static final String DEFAULT_SUPERUSER_PASSWORD = "cassandra"; @VisibleForTesting - public static final String ROLE = "default_role_initializer_role"; + public static final String ROLE = "role"; @VisibleForTesting - public static final String PASSWORD = "default_role_initializer_password"; + public static final String PASSWORD = "password"; @VisibleForTesting - public static final String PASSWORD_HASH = "default_role_initializer_password_hash"; + public static final String PASSWORD_HASH = "password_hash"; private static final Set SUPPORTED_PARAMS = Set.of(ROLE, PASSWORD, PASSWORD_HASH); private final String role; private final String password; private final String passwordHash; - private final Map parameters; public static final PasswordDefaultRoleInitializer instance = new PasswordDefaultRoleInitializer(); @@ -75,7 +74,7 @@ public PasswordDefaultRoleInitializer() public PasswordDefaultRoleInitializer(Map parameters) { - for (String param: parameters.keySet()) + for (String param : parameters.keySet()) { if (!SUPPORTED_PARAMS.contains(param)) throw new ConfigurationException(String.format("Unsupported parameter '%s' for %s, supported parameters are %s", param, getClass().getSimpleName(), SUPPORTED_PARAMS)); @@ -83,29 +82,15 @@ public PasswordDefaultRoleInitializer(Map parameters) role = parameters.getOrDefault(ROLE, DEFAULT_SUPERUSER_NAME); passwordHash = parameters.get(PASSWORD_HASH); - Map santizedParams = new HashMap<>(); - santizedParams.put(ROLE, role); - if (passwordHash != null) - santizedParams.put(PASSWORD_HASH, passwordHash); - if (passwordHash == null) - password = parameters.getOrDefault(PASSWORD, DEFAULT_SUPERUSER_PASSWORD); - else - password = null; - - this.parameters = santizedParams; + password = passwordHash == null + ? parameters.getOrDefault(PASSWORD, DEFAULT_SUPERUSER_PASSWORD) + : null; } @Override public void createDefaultRole() { - QueryProcessor.process(String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) " - + - "VALUES ('%s', true, true, '%s') USING TIMESTAMP 0", - SchemaConstants.AUTH_KEYSPACE_NAME, - AuthKeyspace.ROLES, - escape(role), - escape(password == null ? passwordHash : hashpw(password))), - consistencyForRoleWrite(role)); + QueryProcessor.process(createDefaultRoleQuery(), consistencyForRoleWrite(role)); logger.info("Created default superuser role '{}'", role); } @@ -130,9 +115,13 @@ else if (specifiedPassword && specifiedPasswordHash) throw new ConfigurationException(String.format("Only one of %s, %s can be specified.", PASSWORD, PASSWORD_HASH)); } - @Override - public Map parameters() + @VisibleForTesting + public String createDefaultRoleQuery() { - return parameters; + return String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) VALUES ('%s', true, true, '%s') USING TIMESTAMP 0", + SchemaConstants.AUTH_KEYSPACE_NAME, + AuthKeyspace.ROLES, + escape(role), + escape(password == null ? passwordHash : hashpw(password))); } } diff --git a/src/java/org/apache/cassandra/config/ParameterizedClass.java b/src/java/org/apache/cassandra/config/ParameterizedClass.java index 33fa752cea9e..8516ceb8e37c 100644 --- a/src/java/org/apache/cassandra/config/ParameterizedClass.java +++ b/src/java/org/apache/cassandra/config/ParameterizedClass.java @@ -180,13 +180,16 @@ public String toString() Map sanitizedMap = parameters.entrySet() .stream() .collect(toMap(Entry::getKey, - e -> e.getKey().contains("password") - || e.getKey().contains("hash") - ? "" - : e.getValue(), + e -> isSensitive(e.getKey()) ? "" : e.getValue(), (a, b) -> a, TreeMap::new)); return class_name + sanitizedMap; } } + + static boolean isSensitive(String key) + { + return key.toLowerCase().contains("password") + || key.toLowerCase().contains("hash"); + } } diff --git a/test/distributed/org/apache/cassandra/distributed/test/ColumnMaskTest.java b/test/distributed/org/apache/cassandra/distributed/test/ColumnMaskTest.java index c07e08a46cdf..fb99ccbacf46 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/ColumnMaskTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/ColumnMaskTest.java @@ -39,8 +39,8 @@ import static com.datastax.driver.core.Cluster.Builder; import static java.lang.String.format; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_NAME; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_PASSWORD; import static org.apache.cassandra.distributed.api.Feature.GOSSIP; import static org.apache.cassandra.distributed.api.Feature.NATIVE_PROTOCOL; import static org.apache.cassandra.distributed.shared.AssertUtils.assertRows; diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/AuthAuditLoggingTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/AuthAuditLoggingTest.java index 8b7d82197408..578b562c8647 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/auth/AuthAuditLoggingTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/AuthAuditLoggingTest.java @@ -52,8 +52,8 @@ import static org.apache.cassandra.audit.AuditLogEntryType.LOGIN_ERROR; import static org.apache.cassandra.audit.AuditLogEntryType.LOGIN_SUCCESS; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_NAME; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_PASSWORD; import static org.apache.cassandra.transport.TlsTestUtils.SERVER_KEYSTORE_PASSWORD; import static org.apache.cassandra.transport.TlsTestUtils.SERVER_TRUSTSTORE_PASSWORD; import static org.apache.cassandra.transport.TlsTestUtils.configureIdentity; diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java index 104374c11d63..89c59881a79e 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java @@ -42,8 +42,8 @@ import org.apache.cassandra.distributed.util.SingleHostLoadBalancingPolicy; import org.apache.cassandra.schema.SchemaConstants; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_NAME; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_PASSWORD; import static org.assertj.core.api.Assertions.assertThat; /** diff --git a/test/unit/org/apache/cassandra/auth/AllowAllCIDRAuthorizerTest.java b/test/unit/org/apache/cassandra/auth/AllowAllCIDRAuthorizerTest.java index 467b70e3a035..6bbf6eaa49ea 100644 --- a/test/unit/org/apache/cassandra/auth/AllowAllCIDRAuthorizerTest.java +++ b/test/unit/org/apache/cassandra/auth/AllowAllCIDRAuthorizerTest.java @@ -81,7 +81,7 @@ private void testAccessSucceeds(String userName, String ip) @Test public void testSuperUser() { - testAccessSucceeds(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); + testAccessSucceeds(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); } @Test diff --git a/test/unit/org/apache/cassandra/auth/AuthTestUtils.java b/test/unit/org/apache/cassandra/auth/AuthTestUtils.java index d17d39ec4e09..03972963d36d 100644 --- a/test/unit/org/apache/cassandra/auth/AuthTestUtils.java +++ b/test/unit/org/apache/cassandra/auth/AuthTestUtils.java @@ -321,7 +321,7 @@ public static ClientState getClientState(String role) public static ClientState getClientState() { - return getClientState(CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + return getClientState(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); } public static AuthenticationStatement authWithoutInvalidate(String query, ClientState clientState, Object... args) @@ -467,6 +467,6 @@ static String createName() public static void setupSuperUser() { QueryProcessor.executeInternal(String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) VALUES ('%s', true, true, '%s')", - AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "xxx")); + AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "xxx")); } } diff --git a/test/unit/org/apache/cassandra/auth/CassandraCIDRAuthorizerEnforceModeTest.java b/test/unit/org/apache/cassandra/auth/CassandraCIDRAuthorizerEnforceModeTest.java index 0cbb15670a6f..c25ac157a6b7 100644 --- a/test/unit/org/apache/cassandra/auth/CassandraCIDRAuthorizerEnforceModeTest.java +++ b/test/unit/org/apache/cassandra/auth/CassandraCIDRAuthorizerEnforceModeTest.java @@ -175,7 +175,7 @@ public void testNonexistingCidrLogin() @Test public void testSuperUserAccess() { - testValidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "255.255.255.255"); + testValidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "255.255.255.255"); } @Test @@ -293,8 +293,8 @@ public void testInvalidCidrs() @Test public void testCidrChecksForSuperUsers() { - testValidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); - testValidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "200.30.40.60"); + testValidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); + testValidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "200.30.40.60"); Config conf = DatabaseDescriptor.getRawConfig(); conf.cidr_authorizer = new ParameterizedClass(CassandraCIDRAuthorizer.class.getName(), new HashMap<>()); @@ -304,10 +304,10 @@ public void testCidrChecksForSuperUsers() Collections.singletonList( CIDR.getInstance("200.30.40.60/24")))); AuthTestUtils.auth("alter role %s with access from cidrs {'cidrGroup1'}", - CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); cidrAuthorizer.getCidrPermissionsCache().invalidate(); - testInvalidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); - testValidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "200.30.40.60"); + testInvalidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); + testValidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "200.30.40.60"); } } diff --git a/test/unit/org/apache/cassandra/auth/CassandraCIDRAuthorizerMonitorModeTest.java b/test/unit/org/apache/cassandra/auth/CassandraCIDRAuthorizerMonitorModeTest.java index b9225b519861..44ae6b48b419 100644 --- a/test/unit/org/apache/cassandra/auth/CassandraCIDRAuthorizerMonitorModeTest.java +++ b/test/unit/org/apache/cassandra/auth/CassandraCIDRAuthorizerMonitorModeTest.java @@ -106,8 +106,8 @@ public void testNonexistingCidrLogin() @Test public void testCidrChecksForSuperUsers() { - testValidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); - testValidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "200.30.40.60"); + testValidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); + testValidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "200.30.40.60"); Config conf = DatabaseDescriptor.getRawConfig(); conf.cidr_authorizer = new ParameterizedClass(CassandraCIDRAuthorizer.class.getName(), new HashMap<>()); @@ -117,10 +117,10 @@ public void testCidrChecksForSuperUsers() Collections.singletonList( CIDR.getInstance("200.30.40.60/24")))); AuthTestUtils.auth("alter role %s with access from cidrs {'cidrGroup1'}", - CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); cidrAuthorizer.getCidrPermissionsCache().invalidate(); - testValidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); - testValidCidrAccess(CassandraRoleManager.DEFAULT_SUPERUSER_NAME, "200.30.40.60"); + testValidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "10.20.30.5"); + testValidCidrAccess(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "200.30.40.60"); } } diff --git a/test/unit/org/apache/cassandra/auth/PasswordAuthenticatorTest.java b/test/unit/org/apache/cassandra/auth/PasswordAuthenticatorTest.java index d40bdc634337..a81aba3b8114 100644 --- a/test/unit/org/apache/cassandra/auth/PasswordAuthenticatorTest.java +++ b/test/unit/org/apache/cassandra/auth/PasswordAuthenticatorTest.java @@ -43,7 +43,7 @@ import org.apache.cassandra.transport.messages.AuthenticateMessage; import static org.apache.cassandra.auth.AuthTestUtils.ALL_ROLES; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_PASSWORD; import static org.apache.cassandra.auth.PasswordAuthenticator.SaslNegotiator; import static org.apache.cassandra.auth.PasswordAuthenticator.checkpw; import static org.apache.cassandra.auth.PasswordSaltSupplier.getGensaltLogRounds; diff --git a/test/unit/org/apache/cassandra/auth/RolesTest.java b/test/unit/org/apache/cassandra/auth/RolesTest.java index 079afe4e9f7e..74844517ec33 100644 --- a/test/unit/org/apache/cassandra/auth/RolesTest.java +++ b/test/unit/org/apache/cassandra/auth/RolesTest.java @@ -120,10 +120,10 @@ public void grantedRoleResourcesAreCached() public void confirmSuperUserConsistency() { // Confirm special treatment of superuser - ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); - ConsistencyLevel writeLevel = CassandraRoleManager.consistencyForRoleWrite(CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + ConsistencyLevel writeLevel = CassandraRoleManager.consistencyForRoleWrite(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); // Confirm standard config-based treatment of non @@ -153,10 +153,10 @@ public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() ConsistencyLevel writeLevel = CassandraRoleManager.consistencyForRoleWrite(customRole); Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); - ConsistencyLevel legacyReadLevel = CassandraRoleManager.consistencyForRoleRead(CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + ConsistencyLevel legacyReadLevel = CassandraRoleManager.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(legacyReadLevel, DatabaseDescriptor.getAuthReadConsistencyLevel()); - ConsistencyLevel legacyWriteLevel = CassandraRoleManager.consistencyForRoleWrite(CassandraRoleManager.DEFAULT_SUPERUSER_NAME); + ConsistencyLevel legacyWriteLevel = CassandraRoleManager.consistencyForRoleWrite(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(legacyWriteLevel, DatabaseDescriptor.getAuthWriteConsistencyLevel()); } finally diff --git a/test/unit/org/apache/cassandra/cql3/CQLTester.java b/test/unit/org/apache/cassandra/cql3/CQLTester.java index 6f836c0e8671..6c0d4935ec55 100644 --- a/test/unit/org/apache/cassandra/cql3/CQLTester.java +++ b/test/unit/org/apache/cassandra/cql3/CQLTester.java @@ -115,6 +115,7 @@ import org.apache.cassandra.auth.AuthTestUtils; import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.auth.IRoleManager; +import org.apache.cassandra.auth.PasswordDefaultRoleInitializer; import org.apache.cassandra.concurrent.Stage; import org.apache.cassandra.config.CassandraRelevantProperties; import org.apache.cassandra.config.Config; @@ -645,7 +646,7 @@ public void setup() { loadRoleStatement(); loadIdentityStatement(); - QueryProcessor.executeInternal(createDefaultRoleQuery()); + QueryProcessor.executeInternal(PasswordDefaultRoleInitializer.instance.createDefaultRoleQuery()); } }; diff --git a/test/unit/org/apache/cassandra/cql3/statements/AddIdentityStatementTest.java b/test/unit/org/apache/cassandra/cql3/statements/AddIdentityStatementTest.java index 424ea46ed076..0cfe9036884b 100644 --- a/test/unit/org/apache/cassandra/cql3/statements/AddIdentityStatementTest.java +++ b/test/unit/org/apache/cassandra/cql3/statements/AddIdentityStatementTest.java @@ -29,7 +29,7 @@ import org.apache.cassandra.auth.AuthKeyspace; import org.apache.cassandra.auth.AuthTestUtils; import org.apache.cassandra.auth.AuthenticatedUser; -import org.apache.cassandra.auth.CassandraRoleManager; +import org.apache.cassandra.auth.PasswordDefaultRoleInitializer; import org.apache.cassandra.auth.RoleResource; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.cql3.CQLStatement; @@ -62,7 +62,7 @@ private static void setupPrivilegedUser() + "VALUES ('%s', true, true, '%s')", AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, - CassandraRoleManager.DEFAULT_SUPERUSER_NAME, + PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME, "xxx")); } @@ -194,7 +194,7 @@ public void ifNotExistsTest() static QueryState getClientState() { ClientState state = ClientState.forInternalCalls(); - state.login(new AuthenticatedUser(CassandraRoleManager.DEFAULT_SUPERUSER_NAME)); + state.login(new AuthenticatedUser(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME)); return new QueryState(state); } } diff --git a/test/unit/org/apache/cassandra/db/guardrails/GuardrailTester.java b/test/unit/org/apache/cassandra/db/guardrails/GuardrailTester.java index b9fe9d6ea01d..ebcf760b4354 100644 --- a/test/unit/org/apache/cassandra/db/guardrails/GuardrailTester.java +++ b/test/unit/org/apache/cassandra/db/guardrails/GuardrailTester.java @@ -45,7 +45,7 @@ import org.junit.BeforeClass; import org.apache.cassandra.auth.AuthenticatedUser; -import org.apache.cassandra.auth.CassandraRoleManager; +import org.apache.cassandra.auth.PasswordDefaultRoleInitializer; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.cql3.CQLStatement; import org.apache.cassandra.cql3.CQLTester; @@ -130,7 +130,7 @@ public static void setUpState() userClientState.login(user); superClientState = ClientState.forExternalCalls(InetSocketAddress.createUnresolved("127.0.0.1", 321)); - superClientState.login(new AuthenticatedUser(CassandraRoleManager.DEFAULT_SUPERUSER_NAME)); + superClientState.login(new AuthenticatedUser(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME)); } /** diff --git a/test/unit/org/apache/cassandra/transport/TlsTestUtils.java b/test/unit/org/apache/cassandra/transport/TlsTestUtils.java index 1ff868ecf4df..a696a51cd79a 100644 --- a/test/unit/org/apache/cassandra/transport/TlsTestUtils.java +++ b/test/unit/org/apache/cassandra/transport/TlsTestUtils.java @@ -50,8 +50,8 @@ import org.apache.cassandra.utils.tls.CertificateBuilder; import org.apache.cassandra.utils.tls.CertificateBundle; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_NAME; -import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_PASSWORD; public class TlsTestUtils { From e05c56c8646f4d389d97f407a37e372d4c6ff8bf Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Tue, 4 Aug 2026 11:30:13 -0700 Subject: [PATCH 06/21] my review with default_role_initializer config changes --- conf/cassandra.yaml | 50 ++++++----- .../org/apache/cassandra/auth/AuthConfig.java | 26 +++++- .../cassandra/auth/CassandraRoleManager.java | 28 +++--- .../auth/IDefaultRoleInitializer.java | 2 - .../auth/MutualTlsDefaultRoleInitializer.java | 2 +- .../org/apache/cassandra/config/Config.java | 1 + .../cassandra/config/DatabaseDescriptor.java | 12 +++ .../cassandra/config/ParameterizedClass.java | 2 +- ...utualTlsCertificateValidityPeriodTest.java | 5 +- .../apache/cassandra/auth/AuthConfigTest.java | 9 +- .../org/apache/cassandra/auth/RolesTest.java | 14 +-- .../config/ParameterizedClassTest.java | 90 +++++++++++++++++++ 12 files changed, 179 insertions(+), 62 deletions(-) diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index dc46b04dce09..69f199adb3c7 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -231,33 +231,9 @@ authorizer: # # - CassandraRoleManager stores role data in the system_auth keyspace. Please # increase system_auth keyspace replication factor if you use this role manager. -# -# The creation of the initial role on a cluster which has no roles yet, implementing IDefaultRoleInitializer, -# is driven by default_role_initializer_class_name property. -# Most functions of the IRoleManager require an authenticated login, so a cluster with no roles has no way -# to create the first one; this option controls how that role is bootstrapped. -# -# default_role_initializer_class_name defaults to PasswordDefaultRoleInitializer, -# which creates a 'cassandra' superuser whose password is also 'cassandra'. -# That password is a published constant, so deployments using it must rotate or drop the role -# before the native transport is reachable. -# -# MutualTlsDefaultRoleInitializer instead creates the role with no password at all and maps a client -# certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting -# mutual TLS, such as MutualTlsAuthenticator. role_manager: class_name: CassandraRoleManager parameters: - # Controls default password for initial role, if password hash is set, password is ignored - # default_role_initializer_class_name: PasswordDefaultRoleInitializer - # default_role_initializer_role: cassandra - # default_role_initializer_password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" - # default_role_initializer_password: cassandra - # For mutual TLS, use this: - # default_role_initializer_class_name: MutualTlsDefaultRoleInitializer - # default_role_initializer_role: cassandra_mtls - # default_role_initializer_identity: spiffe1 - # # Controls how often invalid roles are disconnected, such as when a role is altered with LOGIN=false # Task is scheduled with period + random(0, max_jitter) delay between executions # It's recommended to set these longer than the roles cache refresh period, since the invalidation check depends on @@ -265,6 +241,32 @@ role_manager: # invalid_role_disconnect_task_period: 4h # invalid_role_disconnect_task_max_jitter: 1h +# Creates the initial role on a cluster which has no roles yet, implementing IDefaultRoleInitializer. +# Most functions of the IRoleManager require an authenticated login, so a cluster with no roles has no way +# to create the first one; this option controls how that role is bootstrapped. +# +# Defaults to PasswordDefaultRoleInitializer, which creates a 'cassandra' superuser whose password is also +# 'cassandra'. That password is a published constant, so deployments using it must rotate or drop the role +# before the native transport is reachable. +# +# MutualTlsDefaultRoleInitializer instead creates the role with no password at all and maps a client +# certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting +# mutual TLS, such as MutualTlsAuthenticator or MutualTlsWithPasswordFallbackAuthenticator. +# +# default_role_initializer: +# class_name: PasswordDefaultRoleInitializer +# parameters: +# role: cassandra +# # Either a plaintext password or a password_hash may be given; if password_hash is set, password is ignored. +# password: cassandra +# # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" +# +# default_role_initializer: +# class_name: MutualTlsDefaultRoleInitializer +# parameters: +# role: cassandra_mtls +# identity: "spiffe1" + # Network authorization backend, implementing INetworkAuthorizer; used to restrict user # access to certain DCs # Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllNetworkAuthorizer, diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index c5204ee62fd7..374e515cbc5f 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -19,6 +19,7 @@ package org.apache.cassandra.auth; import java.util.List; +import java.util.function.Supplier; import com.google.common.annotations.VisibleForTesting; @@ -92,6 +93,14 @@ public static void applyAuth() DatabaseDescriptor.setAuthorizer(authorizer); + // default role initializer: bootstraps the first role on a cluster which has none yet. Instantiated + // before the role manager because the role manager depends on it (see IRoleManager#defaultRoleInitializer). + + IDefaultRoleInitializer defaultRoleInitializer = authInstantiate(conf.default_role_initializer, + IDefaultRoleInitializer.class, + PasswordDefaultRoleInitializer.instance); + DatabaseDescriptor.setDefaultRoleInitializer(defaultRoleInitializer); + // role manager IRoleManager roleManager = authInstantiate(conf.role_manager, IRoleManager.class, CassandraRoleManager.class); @@ -140,18 +149,23 @@ public static void applyAuth() authenticator.validateConfiguration(); authorizer.validateConfiguration(); roleManager.validateConfiguration(); + defaultRoleInitializer.validateConfiguration(); networkAuthorizer.validateConfiguration(); cidrAuthorizer.validateConfiguration(); DatabaseDescriptor.getInternodeAuthenticator().validateConfiguration(); } - public static T authInstantiate(ParameterizedClass authCls, Class expectedType, Class defaultCls) { + public static T authInstantiate(ParameterizedClass authCls, Class expectedType, Class defaultCls) + { if (authCls != null && authCls.class_name != null) { String authPackage = AuthConfig.class.getPackage().getName(); return ParameterizedClass.newInstance(authCls, List.of("", authPackage), expectedType); } + if (defaultCls == null) + return null; + // for now, this has to stay and can not be replaced by ParameterizedClass.newInstance as above // due to that failing for simulator dtests. See CASSANDRA-20450 for more information. try @@ -163,4 +177,14 @@ public static T authInstantiate(ParameterizedClass authCls, Class expecte throw new ConfigurationException("Failed to instantiate " + defaultCls.getName(), e); } } + + public static T authInstantiate(ParameterizedClass authCls, Class expectedType, T defaultInstance) + { + if (authCls != null && authCls.class_name != null) + { + String authPackage = AuthConfig.class.getPackage().getName(); + return ParameterizedClass.newInstance(authCls, List.of("", authPackage), expectedType); + } + return defaultInstance; + } } diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index 475dc5975ebb..594592f78084 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -55,7 +55,6 @@ import org.apache.cassandra.config.CassandraRelevantProperties; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.config.DurationSpec; -import org.apache.cassandra.config.ParameterizedClass; import org.apache.cassandra.cql3.CQLStatement; import org.apache.cassandra.cql3.ColumnIdentifier; import org.apache.cassandra.cql3.ColumnSpecification; @@ -136,8 +135,6 @@ public class CassandraRoleManager implements IRoleManager, CassandraRoleManagerM */ static final ConsistencyLevel DEFAULT_SUPERUSER_CONSISTENCY_LEVEL = ConsistencyLevel.QUORUM; - private final IDefaultRoleInitializer defaultRoleInitializer; - // Transform a row in the AuthKeyspace.ROLES to a Role instance private static final Function ROW_TO_ROLE = row -> { @@ -212,27 +209,23 @@ public CassandraRoleManager(Map parameters) invalidClientDisconnectPeriodMillis = new DurationSpec.LongMillisecondsBound(parameters.getOrDefault(PARAM_INVALID_ROLE_DISCONNECT_TASK_PERIOD, "0h")).toMilliseconds(); invalidClientDisconnectMaxJitterMillis = new DurationSpec.LongMillisecondsBound(parameters.getOrDefault(PARAM_INVALID_ROLE_DISCONNECT_TASK_MAX_JITTER, "0h")).toMilliseconds(); - String roleInitializerClassName = parameters.getOrDefault(IDefaultRoleInitializer.DEFAULT_ROLE_INITIALIZER_CLASS_NAME, PasswordDefaultRoleInitializer.class.getName()); - - Map defaultRoleInitializerParameters = parameters.entrySet() - .stream() - .filter(e -> e.getKey().startsWith("default_role_initializer_") - && !e.getKey().equals("default_role_initializer_class_name")) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - defaultRoleInitializer = AuthConfig.authInstantiate(new ParameterizedClass(roleInitializerClassName, - defaultRoleInitializerParameters), - IDefaultRoleInitializer.class, - PasswordDefaultRoleInitializer.class); - if (!MBeanWrapper.instance.isRegistered(MBEAN_NAME)) MBeanWrapper.instance.registerMBean(this, MBEAN_NAME); } + /** + * The default role initializer is configured as a top-level {@code default_role_initializer} option and + * applied by {@link AuthConfig#applyAuth()}. Returning it here lets the startup logic (see + * {@link #setup(boolean)}, {@link #hasExistingRoles()} and {@link #consistencyForRoleWrite(String)}) reach the + * configured initializer through the role manager, and lets custom {@link IRoleManager} implementations + * override how they integrate it. Falls back to the historical password initializer when auth setup has not + * run, e.g. in tests which do not call {@link AuthConfig#applyAuth()}. + */ @Override public IDefaultRoleInitializer defaultRoleInitializer() { - return defaultRoleInitializer; + IDefaultRoleInitializer initializer = DatabaseDescriptor.getDefaultRoleInitializer(); + return initializer == null ? PasswordDefaultRoleInitializer.instance : initializer; } @Override @@ -535,7 +528,6 @@ public Set protectedResources() public void validateConfiguration() throws ConfigurationException { - defaultRoleInitializer.validateConfiguration(); } @VisibleForTesting diff --git a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java index e64ea80775c8..4b1e677214c0 100644 --- a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java @@ -45,8 +45,6 @@ public interface IDefaultRoleInitializer { Logger logger = LoggerFactory.getLogger(IDefaultRoleInitializer.class); - String DEFAULT_ROLE_INITIALIZER_CLASS_NAME = "default_role_initializer_class_name"; - /** * Creates the default role. * When using this in connection with CassandraRoleManager, every node runs this independently during initial diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index 5af1dc11e552..742514c4e2d1 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -75,7 +75,7 @@ public void createDefaultRole() escape(role)), consistencyForRoleWrite(role)); - logger.info("Created passwordless default superuser role '{}' with identity '{}'", role, identity); + logger.info("Created passwordless default superuser role '{}' with mapped identity '{}'", role, identity); } @Override diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index 8df1a05cf18a..b891dd14bad2 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -86,6 +86,7 @@ public static Set splitCommaDelimited(String src) public ParameterizedClass authenticator; public ParameterizedClass authorizer; public ParameterizedClass role_manager; + public ParameterizedClass default_role_initializer; public ParameterizedClass crypto_provider; public ParameterizedClass network_authorizer; public ParameterizedClass cidr_authorizer; diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java index 5bc6de7ac1e4..97d0e43c6a01 100644 --- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java +++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java @@ -77,6 +77,7 @@ import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.auth.IAuthorizer; import org.apache.cassandra.auth.ICIDRAuthorizer; +import org.apache.cassandra.auth.IDefaultRoleInitializer; import org.apache.cassandra.auth.IInternodeAuthenticator; import org.apache.cassandra.auth.INetworkAuthorizer; import org.apache.cassandra.auth.IRoleManager; @@ -240,6 +241,7 @@ public class DatabaseDescriptor // Don't initialize the role manager until applying config. The options supported by CassandraRoleManager // depend on the configured IAuthenticator, so defer creating it until that's been set. private static IRoleManager roleManager; + private static IDefaultRoleInitializer defaultRoleInitializer; private static long preparedStatementsCacheSizeInMiB; @@ -2226,6 +2228,16 @@ public static void setRoleManager(IRoleManager roleManager) DatabaseDescriptor.roleManager = roleManager; } + public static IDefaultRoleInitializer getDefaultRoleInitializer() + { + return defaultRoleInitializer; + } + + public static void setDefaultRoleInitializer(IDefaultRoleInitializer defaultRoleInitializer) + { + DatabaseDescriptor.defaultRoleInitializer = defaultRoleInitializer; + } + public static int getPermissionsValidity() { return conf.permissions_validity.toMilliseconds(); diff --git a/src/java/org/apache/cassandra/config/ParameterizedClass.java b/src/java/org/apache/cassandra/config/ParameterizedClass.java index 8516ceb8e37c..8dde10185c32 100644 --- a/src/java/org/apache/cassandra/config/ParameterizedClass.java +++ b/src/java/org/apache/cassandra/config/ParameterizedClass.java @@ -187,7 +187,7 @@ public String toString() } } - static boolean isSensitive(String key) + protected boolean isSensitive(String key) { return key.toLowerCase().contains("password") || key.toLowerCase().contains("hash"); diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsCertificateValidityPeriodTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsCertificateValidityPeriodTest.java index 3cccccf135b6..277a755932ee 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsCertificateValidityPeriodTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsCertificateValidityPeriodTest.java @@ -57,7 +57,6 @@ import static org.apache.cassandra.transport.TlsTestUtils.CLIENT_SPIFFE_IDENTITY; import static org.apache.cassandra.transport.TlsTestUtils.SERVER_KEYSTORE_PASSWORD; import static org.apache.cassandra.transport.TlsTestUtils.SERVER_TRUSTSTORE_PASSWORD; -import static org.apache.cassandra.transport.TlsTestUtils.configureIdentity; import static org.apache.cassandra.transport.TlsTestUtils.generateClientCertificate; import static org.apache.cassandra.transport.TlsTestUtils.getSSLOptions; import static org.assertj.core.api.Assertions.as; @@ -104,6 +103,8 @@ public static void setupClass() throws Exception .set("authenticator.parameters", Collections.singletonMap("validator_class_name", "org.apache.cassandra.auth.SpiffeCertificateValidator")) .set("role_manager", "CassandraRoleManager") .set("authorizer", "CassandraAuthorizer") + .set("default_role_initializer.class_name", "org.apache.cassandra.auth.MutualTlsDefaultRoleInitializer") + .set("default_role_initializer.parameters", Map.of("role", "cassandra_ssl_test", "identity", CLIENT_SPIFFE_IDENTITY)) .set("client_encryption_options.enabled", "true") .set("client_encryption_options.require_client_auth", "optional") .set("client_encryption_options.keystore", serverKeystorePath.toString()) @@ -115,8 +116,6 @@ public static void setupClass() throws Exception .set("client_encryption_options.certificate_validity_warn_threshold", "5d") .with(Feature.NATIVE_PROTOCOL, Feature.GOSSIP)); CLUSTER = builder.start(); - - configureIdentity(CLUSTER, getSSLOptions(null, truststorePath)); } @AfterClass diff --git a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java index d8f4a3503d4f..8f21d9bf6049 100644 --- a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java +++ b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java @@ -167,9 +167,8 @@ public void testNewInstanceForMutualTlsAuthenticator() private static ParameterizedClass mutualTlsDefaultRoleInitializer() { - return new ParameterizedClass(CassandraRoleManager.class.getName(), - Map.of(IDefaultRoleInitializer.DEFAULT_ROLE_INITIALIZER_CLASS_NAME, MutualTlsDefaultRoleInitializer.class.getName(), - MutualTlsDefaultRoleInitializer.ROLE, "cassandra", + return new ParameterizedClass(MutualTlsDefaultRoleInitializer.class.getName(), + Map.of(MutualTlsDefaultRoleInitializer.ROLE, "cassandra", MutualTlsDefaultRoleInitializer.IDENTITY, "spiffe1")); } @@ -177,7 +176,7 @@ private static ParameterizedClass mutualTlsDefaultRoleInitializer() public void testNewInstanceForMutualTlsDefaultRoleInitializer() { Config config = load("cassandra-mtls.yaml"); - config.role_manager = mutualTlsDefaultRoleInitializer(); + config.default_role_initializer = mutualTlsDefaultRoleInitializer(); DatabaseDescriptor.unsafeDaemonInitialization(()->config); assertThat(DatabaseDescriptor.getRoleManager().defaultRoleInitializer()).isInstanceOf(MutualTlsDefaultRoleInitializer.class); @@ -188,7 +187,7 @@ public void testNewInstanceForMutualTlsDefaultRoleInitializer() public void testMutualTlsDefaultRoleInitializerRejectedWithIncompatibleAuthenticator() { Config config = load("cassandra-passwordauth.yaml"); - config.role_manager = mutualTlsDefaultRoleInitializer(); + config.default_role_initializer = mutualTlsDefaultRoleInitializer(); assertThatThrownBy(() -> DatabaseDescriptor.unsafeDaemonInitialization(()->config)) .isInstanceOf(ConfigurationException.class) .hasMessageContaining("creates a role with no password"); diff --git a/test/unit/org/apache/cassandra/auth/RolesTest.java b/test/unit/org/apache/cassandra/auth/RolesTest.java index 74844517ec33..4ace141f8bfb 100644 --- a/test/unit/org/apache/cassandra/auth/RolesTest.java +++ b/test/unit/org/apache/cassandra/auth/RolesTest.java @@ -43,7 +43,6 @@ import static org.apache.cassandra.auth.AuthTestUtils.ROLE_C; import static org.apache.cassandra.auth.AuthTestUtils.getRolesReadCount; import static org.apache.cassandra.auth.AuthTestUtils.grantRolesTo; -import static org.apache.cassandra.auth.IDefaultRoleInitializer.DEFAULT_ROLE_INITIALIZER_CLASS_NAME; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -137,15 +136,15 @@ public void confirmSuperUserConsistency() @Test public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() { - IRoleManager previous = DatabaseDescriptor.getRoleManager(); + IRoleManager previousRoleManager = DatabaseDescriptor.getRoleManager(); + IDefaultRoleInitializer previousInitializer = DatabaseDescriptor.getDefaultRoleInitializer(); String customRole = "cassandra_mtls_custom_test_role"; try { - DatabaseDescriptor.setRoleManager(new CassandraRoleManager(Map.of( - DEFAULT_ROLE_INITIALIZER_CLASS_NAME, MutualTlsDefaultRoleInitializer.class.getSimpleName(), + DatabaseDescriptor.setRoleManager(new CassandraRoleManager()); + DatabaseDescriptor.setDefaultRoleInitializer(new MutualTlsDefaultRoleInitializer(Map.of( MutualTlsDefaultRoleInitializer.ROLE, customRole, - MutualTlsDefaultRoleInitializer.IDENTITY, "spiffe1")) - ); + MutualTlsDefaultRoleInitializer.IDENTITY, "spiffe1"))); ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(customRole); Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); @@ -161,7 +160,8 @@ public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() } finally { - DatabaseDescriptor.setRoleManager(previous); + DatabaseDescriptor.setRoleManager(previousRoleManager); + DatabaseDescriptor.setDefaultRoleInitializer(previousInitializer); } } diff --git a/test/unit/org/apache/cassandra/config/ParameterizedClassTest.java b/test/unit/org/apache/cassandra/config/ParameterizedClassTest.java index 4794d3de8097..14725f431621 100644 --- a/test/unit/org/apache/cassandra/config/ParameterizedClassTest.java +++ b/test/unit/org/apache/cassandra/config/ParameterizedClassTest.java @@ -18,6 +18,7 @@ package org.apache.cassandra.config; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -146,4 +147,93 @@ public void testNewInstanceWithConstructorExceptionPreservesOriginalFailure() .hasMessageContaining("Simulated failure") .isInstanceOf(ConfigurationException.class); } + + @Test + public void testToStringWithNullParametersOmitsParameters() + { + assertThat(new ParameterizedClass("Foo", null).toString()).isEqualTo("Foo{}"); + } + + @Test + public void testToStringWithEmptyParametersOmitsParameters() + { + assertThat(new ParameterizedClass("Foo").toString()).isEqualTo("Foo{}"); + } + + @Test + public void testToStringDoesNotRedactNonSensitiveParameters() + { + ParameterizedClass pc = new ParameterizedClass("Foo", Map.of("role", "cassandra", "identity", "spiffe1")); + // Keys are sorted (TreeMap) and non-sensitive values are shown verbatim. + assertThat(pc.toString()).isEqualTo("Foo{identity=spiffe1, role=cassandra}"); + } + + @Test + public void testToStringRedactsPasswordValue() + { + ParameterizedClass pc = new ParameterizedClass("Foo", Map.of("password", "secret")); + assertThat(pc.toString()).isEqualTo("Foo{password=}"); + } + + @Test + public void testToStringRedactsHashValue() + { + ParameterizedClass pc = new ParameterizedClass("Foo", Map.of("password_hash", "$2a$04$abc")); + assertThat(pc.toString()).isEqualTo("Foo{password_hash=}"); + } + + @Test + public void testToStringRedactsOnlySensitiveKeysAndSortsByKey() + { + Map params = new HashMap<>(); + params.put("role", "cassandra"); + params.put("password", "secret"); + params.put("keystore_password", "kspw"); + params.put("salted_hash", "abcd"); + ParameterizedClass pc = new ParameterizedClass("Foo", params); + assertThat(pc.toString()) + .isEqualTo("Foo{keystore_password=, password=, role=cassandra, salted_hash=}"); + } + + @Test + public void testToStringRedactionIsCaseInsensitive() + { + Map params = new HashMap<>(); + params.put("PASSWORD", "secret"); + params.put("Password_Hash", "h"); + params.put("myHASHkey", "v"); + ParameterizedClass pc = new ParameterizedClass("Foo", params); + assertThat(pc.toString()) + .isEqualTo("Foo{PASSWORD=, Password_Hash=, myHASHkey=}"); + } + + @Test + public void testToStringRedactsSensitiveKeyWithNullValueWithoutThrowing() + { + Map params = new HashMap<>(); + params.put("password", null); + ParameterizedClass pc = new ParameterizedClass("Foo", params); + // A sensitive key is redacted before its value is read, so a null value does not cause a failure. + assertThat(pc.toString()).isEqualTo("Foo{password=}"); + } + + @Test + public void testIsSensitiveMatchesPasswordAndHashCaseInsensitively() + { + ParameterizedClass pc = new ParameterizedClass("Foo"); + + assertThat(pc.isSensitive("password")).isTrue(); + assertThat(pc.isSensitive("PASSWORD")).isTrue(); + assertThat(pc.isSensitive("Password")).isTrue(); + assertThat(pc.isSensitive("keystore_password")).isTrue(); + assertThat(pc.isSensitive("password_hash")).isTrue(); + assertThat(pc.isSensitive("hash")).isTrue(); + assertThat(pc.isSensitive("HASH")).isTrue(); + assertThat(pc.isSensitive("salted_hash")).isTrue(); + + assertThat(pc.isSensitive("role")).isFalse(); + assertThat(pc.isSensitive("identity")).isFalse(); + assertThat(pc.isSensitive("class_name")).isFalse(); + assertThat(pc.isSensitive("keystore")).isFalse(); + } } From aa2b83d94bfc0a81c4bfdfe2fa6bf483f373313f Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Tue, 4 Aug 2026 11:46:15 -0700 Subject: [PATCH 07/21] fix checkstyle --- src/java/org/apache/cassandra/auth/AuthConfig.java | 1 - .../org/apache/cassandra/config/ParameterizedClass.java | 6 ++++-- .../apache/cassandra/auth/PasswordAuthenticatorTest.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index 374e515cbc5f..feb26372f62b 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -19,7 +19,6 @@ package org.apache.cassandra.auth; import java.util.List; -import java.util.function.Supplier; import com.google.common.annotations.VisibleForTesting; diff --git a/src/java/org/apache/cassandra/config/ParameterizedClass.java b/src/java/org/apache/cassandra/config/ParameterizedClass.java index 8dde10185c32..3bfe2175dbca 100644 --- a/src/java/org/apache/cassandra/config/ParameterizedClass.java +++ b/src/java/org/apache/cassandra/config/ParameterizedClass.java @@ -33,6 +33,7 @@ import org.apache.cassandra.utils.Shared; import static java.util.stream.Collectors.toMap; +import static org.apache.cassandra.utils.LocalizeString.toLowerCaseLocalized; import static org.apache.cassandra.utils.Shared.Scope.SIMULATION; @Shared(scope = SIMULATION) @@ -189,7 +190,8 @@ public String toString() protected boolean isSensitive(String key) { - return key.toLowerCase().contains("password") - || key.toLowerCase().contains("hash"); + String lowerCaseKey = toLowerCaseLocalized(key); + return lowerCaseKey.contains("password") + || lowerCaseKey.contains("hash"); } } diff --git a/test/unit/org/apache/cassandra/auth/PasswordAuthenticatorTest.java b/test/unit/org/apache/cassandra/auth/PasswordAuthenticatorTest.java index a81aba3b8114..d6ef781b98b8 100644 --- a/test/unit/org/apache/cassandra/auth/PasswordAuthenticatorTest.java +++ b/test/unit/org/apache/cassandra/auth/PasswordAuthenticatorTest.java @@ -43,9 +43,9 @@ import org.apache.cassandra.transport.messages.AuthenticateMessage; import static org.apache.cassandra.auth.AuthTestUtils.ALL_ROLES; -import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_PASSWORD; import static org.apache.cassandra.auth.PasswordAuthenticator.SaslNegotiator; import static org.apache.cassandra.auth.PasswordAuthenticator.checkpw; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_PASSWORD; import static org.apache.cassandra.auth.PasswordSaltSupplier.getGensaltLogRounds; import static org.apache.cassandra.config.CassandraRelevantProperties.AUTH_BCRYPT_GENSALT_LOG2_ROUNDS; import static org.assertj.core.api.Assertions.assertThat; From 00e92434731cefde6aba401882b7bdf2c6bd3acf Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Tue, 4 Aug 2026 13:30:11 -0700 Subject: [PATCH 08/21] defaultRoleInitializer should not be part of the IRoleManager interface --- src/java/org/apache/cassandra/auth/CassandraRoleManager.java | 1 - src/java/org/apache/cassandra/auth/IRoleManager.java | 5 ----- 2 files changed, 6 deletions(-) diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index 594592f78084..b9a957087f63 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -221,7 +221,6 @@ public CassandraRoleManager(Map parameters) * override how they integrate it. Falls back to the historical password initializer when auth setup has not * run, e.g. in tests which do not call {@link AuthConfig#applyAuth()}. */ - @Override public IDefaultRoleInitializer defaultRoleInitializer() { IDefaultRoleInitializer initializer = DatabaseDescriptor.getDefaultRoleInitializer(); diff --git a/src/java/org/apache/cassandra/auth/IRoleManager.java b/src/java/org/apache/cassandra/auth/IRoleManager.java index af3070ceeee3..db742406cf56 100644 --- a/src/java/org/apache/cassandra/auth/IRoleManager.java +++ b/src/java/org/apache/cassandra/auth/IRoleManager.java @@ -329,9 +329,4 @@ default void dropIdentity(String identity) { } - - default IDefaultRoleInitializer defaultRoleInitializer() - { - return PasswordDefaultRoleInitializer.instance; - } } From 16cc98ffe16d1e3329aaf40357e1dcbcdd9fabed Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Tue, 4 Aug 2026 14:20:10 -0700 Subject: [PATCH 09/21] revert 00e92434731cefde6aba401882b7bdf2c6bd3acf --- src/java/org/apache/cassandra/auth/CassandraRoleManager.java | 1 + src/java/org/apache/cassandra/auth/IRoleManager.java | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index b9a957087f63..594592f78084 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -221,6 +221,7 @@ public CassandraRoleManager(Map parameters) * override how they integrate it. Falls back to the historical password initializer when auth setup has not * run, e.g. in tests which do not call {@link AuthConfig#applyAuth()}. */ + @Override public IDefaultRoleInitializer defaultRoleInitializer() { IDefaultRoleInitializer initializer = DatabaseDescriptor.getDefaultRoleInitializer(); diff --git a/src/java/org/apache/cassandra/auth/IRoleManager.java b/src/java/org/apache/cassandra/auth/IRoleManager.java index db742406cf56..af3070ceeee3 100644 --- a/src/java/org/apache/cassandra/auth/IRoleManager.java +++ b/src/java/org/apache/cassandra/auth/IRoleManager.java @@ -329,4 +329,9 @@ default void dropIdentity(String identity) { } + + default IDefaultRoleInitializer defaultRoleInitializer() + { + return PasswordDefaultRoleInitializer.instance; + } } From a6ed8bb151e3d4a3037a69dfca27f9fe8c7c1919 Mon Sep 17 00:00:00 2001 From: Aparna Naik Date: Thu, 13 Aug 2026 12:35:41 -0700 Subject: [PATCH 10/21] Review fixes --- conf/cassandra.yaml | 20 +++---- .../org/apache/cassandra/auth/AuthConfig.java | 4 +- .../org/apache/cassandra/auth/AuthUtils.java | 53 +++++++++++++++++++ .../cassandra/auth/CassandraRoleManager.java | 25 +++------ .../auth/IDefaultRoleInitializer.java | 23 +++++--- .../auth/MutualTlsDefaultRoleInitializer.java | 12 ++--- .../auth/PasswordDefaultRoleInitializer.java | 12 ++--- .../org/apache/cassandra/auth/RolesTest.java | 8 +-- 8 files changed, 96 insertions(+), 61 deletions(-) create mode 100644 src/java/org/apache/cassandra/auth/AuthUtils.java diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index 69f199adb3c7..b3701e572f6c 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -253,19 +253,13 @@ role_manager: # certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting # mutual TLS, such as MutualTlsAuthenticator or MutualTlsWithPasswordFallbackAuthenticator. # -# default_role_initializer: -# class_name: PasswordDefaultRoleInitializer -# parameters: -# role: cassandra -# # Either a plaintext password or a password_hash may be given; if password_hash is set, password is ignored. -# password: cassandra -# # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" -# -# default_role_initializer: -# class_name: MutualTlsDefaultRoleInitializer -# parameters: -# role: cassandra_mtls -# identity: "spiffe1" + # default_role_initializer: + # class_name: PasswordDefaultRoleInitializer # or: MutualTlsDefaultRoleInitializer + # parameters: + # role: cassandra + # password: cassandra # PasswordDefaultRoleInitializer: plaintext password + # # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # ...or a bcrypt hash instead + # # identity: "spiffe1" # MutualTlsDefaultRoleInitializer: cert identity to map # Network authorization backend, implementing INetworkAuthorizer; used to restrict user # access to certain DCs diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index feb26372f62b..6ba353b1540a 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -154,7 +154,7 @@ public static void applyAuth() DatabaseDescriptor.getInternodeAuthenticator().validateConfiguration(); } - public static T authInstantiate(ParameterizedClass authCls, Class expectedType, Class defaultCls) + private static T authInstantiate(ParameterizedClass authCls, Class expectedType, Class defaultCls) { if (authCls != null && authCls.class_name != null) { @@ -177,7 +177,7 @@ public static T authInstantiate(ParameterizedClass authCls, Class expecte } } - public static T authInstantiate(ParameterizedClass authCls, Class expectedType, T defaultInstance) + private static T authInstantiate(ParameterizedClass authCls, Class expectedType, T defaultInstance) { if (authCls != null && authCls.class_name != null) { diff --git a/src/java/org/apache/cassandra/auth/AuthUtils.java b/src/java/org/apache/cassandra/auth/AuthUtils.java new file mode 100644 index 000000000000..5a6f69d51750 --- /dev/null +++ b/src/java/org/apache/cassandra/auth/AuthUtils.java @@ -0,0 +1,53 @@ +/* + * 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.cassandra.auth; + +import org.apache.commons.lang3.StringUtils; +import org.mindrot.jbcrypt.BCrypt; + +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.db.ConsistencyLevel; + +public class AuthUtils +{ + static final ConsistencyLevel DEFAULT_SUPERUSER_CONSISTENCY_LEVEL = ConsistencyLevel.QUORUM; + + private AuthUtils() {} + + public static String hashpw(String password) + { + return BCrypt.hashpw(password, PasswordSaltSupplier.get()); + } + + public static String escape(String name) + { + return StringUtils.replace(name, "", "''"); + } + + /** Allows selective overriding of the consistency level for specific roles. */ + public static ConsistencyLevel consistencyForRoleWrite(String role) + { + return role.equals(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authWriteConsistencyLevel(); + } + + public static ConsistencyLevel consistencyForRoleRead(String role) + { + return role.equals(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authReadConsistencyLevel(); + } +} diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index 594592f78084..4fd82cbc5ad1 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -45,8 +45,6 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; -import org.apache.commons.lang3.StringUtils; -import org.mindrot.jbcrypt.BCrypt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -128,13 +126,6 @@ public class CassandraRoleManager implements IRoleManager, CassandraRoleManagerM public static final String MBEAN_NAME = "org.apache.cassandra.auth:type=CassandraRoleManager"; - /** - * We need to treat the default superuser as a special case since during initial node startup, we may end up with - * duplicate creation or deletion + re-creation of this user on different nodes unless we check at quorum to see if - * it's already been done. - */ - static final ConsistencyLevel DEFAULT_SUPERUSER_CONSISTENCY_LEVEL = ConsistencyLevel.QUORUM; - // Transform a row in the AuthKeyspace.ROLES to a Role instance private static final Function ROW_TO_ROLE = row -> { @@ -239,7 +230,7 @@ public void setup(boolean asyncRoleSetup) try { // Try to set up synchronously - defaultRoleInitializer().setupDefaultRole(); + defaultRoleInitializer().initializeDefaultRoleIfNeeded(); return; } catch (Throwable t) @@ -248,7 +239,7 @@ public void setup(boolean asyncRoleSetup) } } scheduleSetupTask(() -> { - defaultRoleInitializer().setupDefaultRole(); + defaultRoleInitializer().initializeDefaultRoleIfNeeded(); return null; }); } @@ -743,12 +734,12 @@ private void enforcePasswordUpdateRateLimit(AuthenticatedUser performer, String static String hashpw(String password) { - return BCrypt.hashpw(password, PasswordSaltSupplier.get()); + return AuthUtils.hashpw(password); } static String escape(String name) { - return StringUtils.replace(name, "'", "''"); + return AuthUtils.escape(name); } private static ByteBuffer byteBuf(String str) @@ -759,16 +750,12 @@ private static ByteBuffer byteBuf(String str) /** Allows selective overriding of the consistency level for specific roles. */ protected static ConsistencyLevel consistencyForRoleWrite(String role) { - return role.equals(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()) ? - DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : - CassandraAuthorizer.authWriteConsistencyLevel(); + return AuthUtils.consistencyForRoleWrite(role); } protected static ConsistencyLevel consistencyForRoleRead(String role) { - return role.equals(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()) ? - DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : - CassandraAuthorizer.authReadConsistencyLevel(); + return AuthUtils.consistencyForRoleRead(role); } /** diff --git a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java index 4b1e677214c0..b80532349dd2 100644 --- a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java @@ -18,6 +18,9 @@ package org.apache.cassandra.auth; +import java.util.Map; +import java.util.Set; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,19 +76,17 @@ public interface IDefaultRoleInitializer * * @throws ConfigurationException when there is a configuration error. */ - default void validateConfiguration() throws ConfigurationException - { - } + void validateConfiguration() throws ConfigurationException; /* * Create the default superuser role to bootstrap role creation on a clean system. Preemptively * gives the role the default password so PasswordAuthenticator can be used to log in (if * configured) */ - default void setupDefaultRole() + default void initializeDefaultRoleIfNeeded() { if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) - throw new IllegalStateException("CassandraRoleManager skipped default role setup: no known tokens in ring"); + throw new IllegalStateException(getClass().getSimpleName() + " skipped default role setup: no known tokens in ring"); try { @@ -96,7 +97,7 @@ default void setupDefaultRole() } catch (RequestExecutionException e) { - logger.warn("CassandraRoleManager skipped default role setup: some nodes were not ready"); + logger.warn(getClass().getSimpleName() + " skipped default role setup: some nodes were not ready"); throw e; } } @@ -110,4 +111,14 @@ default boolean hasExistingRoles() || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); } + + static void validateSupportedParams(Map parameters, Set supportedParams, Class implClass) + { + for (String param : parameters.keySet()) + { + if (!supportedParams.contains(param)) + throw new ConfigurationException(String.format("Unsupported parameter '%s' for %s, supported parameters are %s", + param, implClass.getSimpleName(), supportedParams)); + } + } } diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index 742514c4e2d1..5e60ad42a2ba 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -31,8 +31,8 @@ import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.schema.SchemaConstants; -import static org.apache.cassandra.auth.CassandraRoleManager.consistencyForRoleWrite; -import static org.apache.cassandra.auth.CassandraRoleManager.escape; +import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; +import static org.apache.cassandra.auth.AuthUtils.escape; public class MutualTlsDefaultRoleInitializer implements IDefaultRoleInitializer { @@ -46,13 +46,7 @@ public class MutualTlsDefaultRoleInitializer implements IDefaultRoleInitializer public MutualTlsDefaultRoleInitializer(Map parameters) { - for (String params : parameters.keySet()) - { - if (!SUPPORTED_PARAMS.contains(params)) - { - throw new ConfigurationException(String.format("Unsupported parameter %s for %s, supported parameters are %s", params, getClass().getSimpleName(), SUPPORTED_PARAMS)); - } - } + IDefaultRoleInitializer.validateSupportedParams(parameters, SUPPORTED_PARAMS, getClass()); role = parameters.get(ROLE); identity = parameters.get(IDENTITY); } diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 33cd1f389b6a..9cea1e0c6fef 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -31,9 +31,9 @@ import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.schema.SchemaConstants; -import static org.apache.cassandra.auth.CassandraRoleManager.consistencyForRoleWrite; -import static org.apache.cassandra.auth.CassandraRoleManager.escape; -import static org.apache.cassandra.auth.CassandraRoleManager.hashpw; +import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; +import static org.apache.cassandra.auth.AuthUtils.escape; +import static org.apache.cassandra.auth.AuthUtils.hashpw; /** * Creates the default role with a password, so that it can be authenticated with @@ -74,11 +74,7 @@ public PasswordDefaultRoleInitializer() public PasswordDefaultRoleInitializer(Map parameters) { - for (String param : parameters.keySet()) - { - if (!SUPPORTED_PARAMS.contains(param)) - throw new ConfigurationException(String.format("Unsupported parameter '%s' for %s, supported parameters are %s", param, getClass().getSimpleName(), SUPPORTED_PARAMS)); - } + IDefaultRoleInitializer.validateSupportedParams(parameters, SUPPORTED_PARAMS, getClass()); role = parameters.getOrDefault(ROLE, DEFAULT_SUPERUSER_NAME); passwordHash = parameters.get(PASSWORD_HASH); diff --git a/test/unit/org/apache/cassandra/auth/RolesTest.java b/test/unit/org/apache/cassandra/auth/RolesTest.java index 4ace141f8bfb..ff1718e16dd6 100644 --- a/test/unit/org/apache/cassandra/auth/RolesTest.java +++ b/test/unit/org/apache/cassandra/auth/RolesTest.java @@ -120,10 +120,10 @@ public void confirmSuperUserConsistency() { // Confirm special treatment of superuser ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); - Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); + Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); ConsistencyLevel writeLevel = CassandraRoleManager.consistencyForRoleWrite(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); - Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); + Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); // Confirm standard config-based treatment of non ConsistencyLevel nonPrivReadLevel = CassandraRoleManager.consistencyForRoleRead("non-privilaged"); @@ -147,10 +147,10 @@ public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() MutualTlsDefaultRoleInitializer.IDENTITY, "spiffe1"))); ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(customRole); - Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); + Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); ConsistencyLevel writeLevel = CassandraRoleManager.consistencyForRoleWrite(customRole); - Assert.assertEquals(CassandraRoleManager.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); + Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); ConsistencyLevel legacyReadLevel = CassandraRoleManager.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(legacyReadLevel, DatabaseDescriptor.getAuthReadConsistencyLevel()); From cdf83321f5615ffb5277a239a1016e90e70b539a Mon Sep 17 00:00:00 2001 From: Stefan Miklosovic Date: Thu, 6 Aug 2026 14:30:49 +0200 Subject: [PATCH 11/21] added supportsRoleManager method --- .java-version | 1 + conf/cassandra.yaml | 14 ++--- .../auth/AbstractDefaultRoleInitializer.java | 63 +++++++++++++++++++ .../org/apache/cassandra/auth/AuthConfig.java | 4 ++ .../auth/IDefaultRoleInitializer.java | 55 +++++----------- .../auth/MutualTlsDefaultRoleInitializer.java | 8 ++- .../auth/PasswordDefaultRoleInitializer.java | 8 ++- 7 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 .java-version create mode 100644 src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java diff --git a/.java-version b/.java-version new file mode 100644 index 000000000000..b4de39476753 --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +11 diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index b3701e572f6c..bc3a50f007ba 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -253,13 +253,13 @@ role_manager: # certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting # mutual TLS, such as MutualTlsAuthenticator or MutualTlsWithPasswordFallbackAuthenticator. # - # default_role_initializer: - # class_name: PasswordDefaultRoleInitializer # or: MutualTlsDefaultRoleInitializer - # parameters: - # role: cassandra - # password: cassandra # PasswordDefaultRoleInitializer: plaintext password - # # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # ...or a bcrypt hash instead - # # identity: "spiffe1" # MutualTlsDefaultRoleInitializer: cert identity to map +# default_role_initializer: +# class_name: PasswordDefaultRoleInitializer # or: MutualTlsDefaultRoleInitializer +# parameters: +# role: cassandra +# password: cassandra # PasswordDefaultRoleInitializer: plaintext password +# # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # ...or a bcrypt hash instead +# # identity: "spiffe1" # MutualTlsDefaultRoleInitializer: cert identity to map # Network authorization backend, implementing INetworkAuthorizer; used to restrict user # access to certain DCs diff --git a/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java new file mode 100644 index 000000000000..2b595f07b307 --- /dev/null +++ b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java @@ -0,0 +1,63 @@ +/* + * 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.cassandra.auth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.db.ConsistencyLevel; +import org.apache.cassandra.exceptions.RequestExecutionException; +import org.apache.cassandra.schema.SchemaConstants; +import org.apache.cassandra.tcm.ClusterMetadata; + +import static org.apache.cassandra.auth.AuthUtils.escape; + +public abstract class AbstractDefaultRoleInitializer implements IDefaultRoleInitializer +{ + private static final Logger logger = LoggerFactory.getLogger(AbstractDefaultRoleInitializer.class); + + @Override + public final void initializeDefaultRoleIfNeeded() + { + if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) + throw new IllegalStateException(getClass().getSimpleName() + " skipped role setup: no known tokens in the ring"); + + try { + if (!hasExistingRoles()) + createDefaultRole(); + + } catch (RequestExecutionException e) + { + logger.warn(getClass().getSimpleName() + " skipped default role setup: some nodes were not ready"); + throw e; + } + } + + @Override + public final boolean hasExistingRoles() + { + // Try looking up the configured default role first, to avoid the range query if possible. + String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleName())); + String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); + return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() + || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() + || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); + } +} diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index 6ba353b1540a..a538360d79a7 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -107,6 +107,10 @@ public static void applyAuth() if (authenticator instanceof PasswordAuthenticator && !(roleManager instanceof CassandraRoleManager)) throw new ConfigurationException(authenticator.getClass().getName() + " requires " + CassandraRoleManager.class.getName(), false); + if (!defaultRoleInitializer.supportsRoleManager(roleManager)) + throw new ConfigurationException(defaultRoleInitializer.getClass().getName() + + " does not support " + roleManager.getClass().getName()); + DatabaseDescriptor.setRoleManager(roleManager); // authenticator diff --git a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java index b80532349dd2..1ed020a7f9da 100644 --- a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java @@ -21,17 +21,8 @@ import java.util.Map; import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.apache.cassandra.cql3.QueryProcessor; -import org.apache.cassandra.db.ConsistencyLevel; import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.exceptions.RequestExecutionException; -import org.apache.cassandra.schema.SchemaConstants; -import org.apache.cassandra.tcm.ClusterMetadata; - -import static org.apache.cassandra.auth.CassandraRoleManager.escape; /** * Creates the initial role on a cluster which has no roles yet, so that there is some @@ -46,8 +37,6 @@ */ public interface IDefaultRoleInitializer { - Logger logger = LoggerFactory.getLogger(IDefaultRoleInitializer.class); - /** * Creates the default role. * When using this in connection with CassandraRoleManager, every node runs this independently during initial @@ -78,39 +67,23 @@ public interface IDefaultRoleInitializer */ void validateConfiguration() throws ConfigurationException; - /* - * Create the default superuser role to bootstrap role creation on a clean system. Preemptively - * gives the role the default password so PasswordAuthenticator can be used to log in (if - * configured) + /** + * Checks {@link #hasExistingRoles()} and, if the cluster has none yet, calls {@link #createDefaultRole()}. + * Implemented once, sealed, by {@link AbstractDefaultRoleInitializer} — see that class for the logic. */ - default void initializeDefaultRoleIfNeeded() - { - if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) - throw new IllegalStateException(getClass().getSimpleName() + " skipped default role setup: no known tokens in ring"); + void initializeDefaultRoleIfNeeded(); - try - { - if (!hasExistingRoles()) - { - createDefaultRole(); - } - } - catch (RequestExecutionException e) - { - logger.warn(getClass().getSimpleName() + " skipped default role setup: some nodes were not ready"); - throw e; - } - } + /** + * @return true if the cluster already has at least one role (or the configured default role specifically). + * Implemented once, sealed, by {@link AbstractDefaultRoleInitializer} — see that class for the logic. + */ + boolean hasExistingRoles(); - default boolean hasExistingRoles() - { - // Try looking up the configured default role first, to avoid the range query if possible. - String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleName())); - String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); - return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() - || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() - || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); - } + /** + * @param manager manager to check the support of + * @return true if this role initializer conceptually works together with specified role manager, false otherwise + */ + boolean supportsRoleManager(IRoleManager manager); static void validateSupportedParams(Map parameters, Set supportedParams, Class implClass) { diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index 5e60ad42a2ba..d406335a250c 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -34,7 +34,7 @@ import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; import static org.apache.cassandra.auth.AuthUtils.escape; -public class MutualTlsDefaultRoleInitializer implements IDefaultRoleInitializer +public class MutualTlsDefaultRoleInitializer extends AbstractDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(MutualTlsDefaultRoleInitializer.class); public static final String ROLE = "role"; @@ -51,6 +51,12 @@ public MutualTlsDefaultRoleInitializer(Map parameters) identity = parameters.get(IDENTITY); } + @Override + public boolean supportsRoleManager(IRoleManager manager) + { + return manager instanceof CassandraRoleManager; + } + @Override public void createDefaultRole() { diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 9cea1e0c6fef..4d0ad023308c 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -46,7 +46,7 @@ * {@link MutualTlsDefaultRoleInitializer}. Deployments which do use this initializer should rotate or drop the * created role before the native transport is reachable. */ -public class PasswordDefaultRoleInitializer implements IDefaultRoleInitializer +public class PasswordDefaultRoleInitializer extends AbstractDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(PasswordDefaultRoleInitializer.class); @@ -83,6 +83,12 @@ public PasswordDefaultRoleInitializer(Map parameters) : null; } + @Override + public boolean supportsRoleManager(IRoleManager manager) + { + return manager instanceof CassandraRoleManager; + } + @Override public void createDefaultRole() { From d5e790f383df7eeb53e58a9f08f346700e765e71 Mon Sep 17 00:00:00 2001 From: Aparna Naik Date: Thu, 13 Aug 2026 14:25:49 -0700 Subject: [PATCH 12/21] Revert "added supportsRoleManager method" This reverts commit cdf83321f5615ffb5277a239a1016e90e70b539a. --- .java-version | 1 - conf/cassandra.yaml | 14 ++--- .../auth/AbstractDefaultRoleInitializer.java | 63 ------------------- .../org/apache/cassandra/auth/AuthConfig.java | 4 -- .../auth/IDefaultRoleInitializer.java | 55 +++++++++++----- .../auth/MutualTlsDefaultRoleInitializer.java | 8 +-- .../auth/PasswordDefaultRoleInitializer.java | 8 +-- 7 files changed, 50 insertions(+), 103 deletions(-) delete mode 100644 .java-version delete mode 100644 src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java diff --git a/.java-version b/.java-version deleted file mode 100644 index b4de39476753..000000000000 --- a/.java-version +++ /dev/null @@ -1 +0,0 @@ -11 diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index bc3a50f007ba..b3701e572f6c 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -253,13 +253,13 @@ role_manager: # certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting # mutual TLS, such as MutualTlsAuthenticator or MutualTlsWithPasswordFallbackAuthenticator. # -# default_role_initializer: -# class_name: PasswordDefaultRoleInitializer # or: MutualTlsDefaultRoleInitializer -# parameters: -# role: cassandra -# password: cassandra # PasswordDefaultRoleInitializer: plaintext password -# # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # ...or a bcrypt hash instead -# # identity: "spiffe1" # MutualTlsDefaultRoleInitializer: cert identity to map + # default_role_initializer: + # class_name: PasswordDefaultRoleInitializer # or: MutualTlsDefaultRoleInitializer + # parameters: + # role: cassandra + # password: cassandra # PasswordDefaultRoleInitializer: plaintext password + # # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # ...or a bcrypt hash instead + # # identity: "spiffe1" # MutualTlsDefaultRoleInitializer: cert identity to map # Network authorization backend, implementing INetworkAuthorizer; used to restrict user # access to certain DCs diff --git a/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java deleted file mode 100644 index 2b595f07b307..000000000000 --- a/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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.cassandra.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.apache.cassandra.cql3.QueryProcessor; -import org.apache.cassandra.db.ConsistencyLevel; -import org.apache.cassandra.exceptions.RequestExecutionException; -import org.apache.cassandra.schema.SchemaConstants; -import org.apache.cassandra.tcm.ClusterMetadata; - -import static org.apache.cassandra.auth.AuthUtils.escape; - -public abstract class AbstractDefaultRoleInitializer implements IDefaultRoleInitializer -{ - private static final Logger logger = LoggerFactory.getLogger(AbstractDefaultRoleInitializer.class); - - @Override - public final void initializeDefaultRoleIfNeeded() - { - if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) - throw new IllegalStateException(getClass().getSimpleName() + " skipped role setup: no known tokens in the ring"); - - try { - if (!hasExistingRoles()) - createDefaultRole(); - - } catch (RequestExecutionException e) - { - logger.warn(getClass().getSimpleName() + " skipped default role setup: some nodes were not ready"); - throw e; - } - } - - @Override - public final boolean hasExistingRoles() - { - // Try looking up the configured default role first, to avoid the range query if possible. - String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleName())); - String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); - return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() - || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() - || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); - } -} diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index a538360d79a7..6ba353b1540a 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -107,10 +107,6 @@ public static void applyAuth() if (authenticator instanceof PasswordAuthenticator && !(roleManager instanceof CassandraRoleManager)) throw new ConfigurationException(authenticator.getClass().getName() + " requires " + CassandraRoleManager.class.getName(), false); - if (!defaultRoleInitializer.supportsRoleManager(roleManager)) - throw new ConfigurationException(defaultRoleInitializer.getClass().getName() - + " does not support " + roleManager.getClass().getName()); - DatabaseDescriptor.setRoleManager(roleManager); // authenticator diff --git a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java index 1ed020a7f9da..b80532349dd2 100644 --- a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java @@ -21,8 +21,17 @@ import java.util.Map; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.db.ConsistencyLevel; import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.exceptions.RequestExecutionException; +import org.apache.cassandra.schema.SchemaConstants; +import org.apache.cassandra.tcm.ClusterMetadata; + +import static org.apache.cassandra.auth.CassandraRoleManager.escape; /** * Creates the initial role on a cluster which has no roles yet, so that there is some @@ -37,6 +46,8 @@ */ public interface IDefaultRoleInitializer { + Logger logger = LoggerFactory.getLogger(IDefaultRoleInitializer.class); + /** * Creates the default role. * When using this in connection with CassandraRoleManager, every node runs this independently during initial @@ -67,23 +78,39 @@ public interface IDefaultRoleInitializer */ void validateConfiguration() throws ConfigurationException; - /** - * Checks {@link #hasExistingRoles()} and, if the cluster has none yet, calls {@link #createDefaultRole()}. - * Implemented once, sealed, by {@link AbstractDefaultRoleInitializer} — see that class for the logic. + /* + * Create the default superuser role to bootstrap role creation on a clean system. Preemptively + * gives the role the default password so PasswordAuthenticator can be used to log in (if + * configured) */ - void initializeDefaultRoleIfNeeded(); + default void initializeDefaultRoleIfNeeded() + { + if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) + throw new IllegalStateException(getClass().getSimpleName() + " skipped default role setup: no known tokens in ring"); - /** - * @return true if the cluster already has at least one role (or the configured default role specifically). - * Implemented once, sealed, by {@link AbstractDefaultRoleInitializer} — see that class for the logic. - */ - boolean hasExistingRoles(); + try + { + if (!hasExistingRoles()) + { + createDefaultRole(); + } + } + catch (RequestExecutionException e) + { + logger.warn(getClass().getSimpleName() + " skipped default role setup: some nodes were not ready"); + throw e; + } + } - /** - * @param manager manager to check the support of - * @return true if this role initializer conceptually works together with specified role manager, false otherwise - */ - boolean supportsRoleManager(IRoleManager manager); + default boolean hasExistingRoles() + { + // Try looking up the configured default role first, to avoid the range query if possible. + String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleName())); + String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); + return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() + || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() + || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); + } static void validateSupportedParams(Map parameters, Set supportedParams, Class implClass) { diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index d406335a250c..5e60ad42a2ba 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -34,7 +34,7 @@ import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; import static org.apache.cassandra.auth.AuthUtils.escape; -public class MutualTlsDefaultRoleInitializer extends AbstractDefaultRoleInitializer +public class MutualTlsDefaultRoleInitializer implements IDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(MutualTlsDefaultRoleInitializer.class); public static final String ROLE = "role"; @@ -51,12 +51,6 @@ public MutualTlsDefaultRoleInitializer(Map parameters) identity = parameters.get(IDENTITY); } - @Override - public boolean supportsRoleManager(IRoleManager manager) - { - return manager instanceof CassandraRoleManager; - } - @Override public void createDefaultRole() { diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 4d0ad023308c..9cea1e0c6fef 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -46,7 +46,7 @@ * {@link MutualTlsDefaultRoleInitializer}. Deployments which do use this initializer should rotate or drop the * created role before the native transport is reachable. */ -public class PasswordDefaultRoleInitializer extends AbstractDefaultRoleInitializer +public class PasswordDefaultRoleInitializer implements IDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(PasswordDefaultRoleInitializer.class); @@ -83,12 +83,6 @@ public PasswordDefaultRoleInitializer(Map parameters) : null; } - @Override - public boolean supportsRoleManager(IRoleManager manager) - { - return manager instanceof CassandraRoleManager; - } - @Override public void createDefaultRole() { From ab5f5e8d0ee65d3bc35ed77349b1d91273762155 Mon Sep 17 00:00:00 2001 From: Aparna Naik Date: Thu, 13 Aug 2026 14:34:06 -0700 Subject: [PATCH 13/21] Create AbstractDefaultRoleInitializer class --- conf/cassandra.yaml | 14 ++--- .../auth/AbstractDefaultRoleInitializer.java | 63 +++++++++++++++++++ .../auth/IDefaultRoleInitializer.java | 55 +++++----------- .../auth/MutualTlsDefaultRoleInitializer.java | 8 ++- .../auth/PasswordDefaultRoleInitializer.java | 8 ++- 5 files changed, 98 insertions(+), 50 deletions(-) create mode 100644 src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index b3701e572f6c..bc3a50f007ba 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -253,13 +253,13 @@ role_manager: # certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting # mutual TLS, such as MutualTlsAuthenticator or MutualTlsWithPasswordFallbackAuthenticator. # - # default_role_initializer: - # class_name: PasswordDefaultRoleInitializer # or: MutualTlsDefaultRoleInitializer - # parameters: - # role: cassandra - # password: cassandra # PasswordDefaultRoleInitializer: plaintext password - # # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # ...or a bcrypt hash instead - # # identity: "spiffe1" # MutualTlsDefaultRoleInitializer: cert identity to map +# default_role_initializer: +# class_name: PasswordDefaultRoleInitializer # or: MutualTlsDefaultRoleInitializer +# parameters: +# role: cassandra +# password: cassandra # PasswordDefaultRoleInitializer: plaintext password +# # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # ...or a bcrypt hash instead +# # identity: "spiffe1" # MutualTlsDefaultRoleInitializer: cert identity to map # Network authorization backend, implementing INetworkAuthorizer; used to restrict user # access to certain DCs diff --git a/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java new file mode 100644 index 000000000000..2b595f07b307 --- /dev/null +++ b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java @@ -0,0 +1,63 @@ +/* + * 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.cassandra.auth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.db.ConsistencyLevel; +import org.apache.cassandra.exceptions.RequestExecutionException; +import org.apache.cassandra.schema.SchemaConstants; +import org.apache.cassandra.tcm.ClusterMetadata; + +import static org.apache.cassandra.auth.AuthUtils.escape; + +public abstract class AbstractDefaultRoleInitializer implements IDefaultRoleInitializer +{ + private static final Logger logger = LoggerFactory.getLogger(AbstractDefaultRoleInitializer.class); + + @Override + public final void initializeDefaultRoleIfNeeded() + { + if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) + throw new IllegalStateException(getClass().getSimpleName() + " skipped role setup: no known tokens in the ring"); + + try { + if (!hasExistingRoles()) + createDefaultRole(); + + } catch (RequestExecutionException e) + { + logger.warn(getClass().getSimpleName() + " skipped default role setup: some nodes were not ready"); + throw e; + } + } + + @Override + public final boolean hasExistingRoles() + { + // Try looking up the configured default role first, to avoid the range query if possible. + String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleName())); + String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); + return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() + || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() + || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); + } +} diff --git a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java index b80532349dd2..1ed020a7f9da 100644 --- a/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/IDefaultRoleInitializer.java @@ -21,17 +21,8 @@ import java.util.Map; import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.apache.cassandra.cql3.QueryProcessor; -import org.apache.cassandra.db.ConsistencyLevel; import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.exceptions.RequestExecutionException; -import org.apache.cassandra.schema.SchemaConstants; -import org.apache.cassandra.tcm.ClusterMetadata; - -import static org.apache.cassandra.auth.CassandraRoleManager.escape; /** * Creates the initial role on a cluster which has no roles yet, so that there is some @@ -46,8 +37,6 @@ */ public interface IDefaultRoleInitializer { - Logger logger = LoggerFactory.getLogger(IDefaultRoleInitializer.class); - /** * Creates the default role. * When using this in connection with CassandraRoleManager, every node runs this independently during initial @@ -78,39 +67,23 @@ public interface IDefaultRoleInitializer */ void validateConfiguration() throws ConfigurationException; - /* - * Create the default superuser role to bootstrap role creation on a clean system. Preemptively - * gives the role the default password so PasswordAuthenticator can be used to log in (if - * configured) + /** + * Checks {@link #hasExistingRoles()} and, if the cluster has none yet, calls {@link #createDefaultRole()}. + * Implemented once, sealed, by {@link AbstractDefaultRoleInitializer} — see that class for the logic. */ - default void initializeDefaultRoleIfNeeded() - { - if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) - throw new IllegalStateException(getClass().getSimpleName() + " skipped default role setup: no known tokens in ring"); + void initializeDefaultRoleIfNeeded(); - try - { - if (!hasExistingRoles()) - { - createDefaultRole(); - } - } - catch (RequestExecutionException e) - { - logger.warn(getClass().getSimpleName() + " skipped default role setup: some nodes were not ready"); - throw e; - } - } + /** + * @return true if the cluster already has at least one role (or the configured default role specifically). + * Implemented once, sealed, by {@link AbstractDefaultRoleInitializer} — see that class for the logic. + */ + boolean hasExistingRoles(); - default boolean hasExistingRoles() - { - // Try looking up the configured default role first, to avoid the range query if possible. - String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleName())); - String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); - return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() - || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() - || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); - } + /** + * @param manager manager to check the support of + * @return true if this role initializer conceptually works together with specified role manager, false otherwise + */ + boolean supportsRoleManager(IRoleManager manager); static void validateSupportedParams(Map parameters, Set supportedParams, Class implClass) { diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index 5e60ad42a2ba..d406335a250c 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -34,7 +34,7 @@ import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; import static org.apache.cassandra.auth.AuthUtils.escape; -public class MutualTlsDefaultRoleInitializer implements IDefaultRoleInitializer +public class MutualTlsDefaultRoleInitializer extends AbstractDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(MutualTlsDefaultRoleInitializer.class); public static final String ROLE = "role"; @@ -51,6 +51,12 @@ public MutualTlsDefaultRoleInitializer(Map parameters) identity = parameters.get(IDENTITY); } + @Override + public boolean supportsRoleManager(IRoleManager manager) + { + return manager instanceof CassandraRoleManager; + } + @Override public void createDefaultRole() { diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 9cea1e0c6fef..4d0ad023308c 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -46,7 +46,7 @@ * {@link MutualTlsDefaultRoleInitializer}. Deployments which do use this initializer should rotate or drop the * created role before the native transport is reachable. */ -public class PasswordDefaultRoleInitializer implements IDefaultRoleInitializer +public class PasswordDefaultRoleInitializer extends AbstractDefaultRoleInitializer { private static final Logger logger = LoggerFactory.getLogger(PasswordDefaultRoleInitializer.class); @@ -83,6 +83,12 @@ public PasswordDefaultRoleInitializer(Map parameters) : null; } + @Override + public boolean supportsRoleManager(IRoleManager manager) + { + return manager instanceof CassandraRoleManager; + } + @Override public void createDefaultRole() { From 57fad7a450a40cec10584bbc67f5ebdb5facaa87 Mon Sep 17 00:00:00 2001 From: Aparna Naik Date: Fri, 14 Aug 2026 13:24:05 -0700 Subject: [PATCH 14/21] review fixes --- conf/cassandra_latest.yaml | 20 +++++++++++++++++++ .../org/apache/cassandra/auth/AuthConfig.java | 3 +++ 2 files changed, 23 insertions(+) diff --git a/conf/cassandra_latest.yaml b/conf/cassandra_latest.yaml index 63da0583276e..8ff00dca32d2 100644 --- a/conf/cassandra_latest.yaml +++ b/conf/cassandra_latest.yaml @@ -244,6 +244,26 @@ role_manager: invalid_role_disconnect_task_period: 4h invalid_role_disconnect_task_max_jitter: 1h +# Creates the initial role on a cluster which has no roles yet, implementing IDefaultRoleInitializer. +# Most functions of the IRoleManager require an authenticated login, so a cluster with no roles has no way +# to create the first one; this option controls how that role is bootstrapped. +# +# Defaults to PasswordDefaultRoleInitializer, which creates a 'cassandra' superuser whose password is also +# 'cassandra'. That password is a published constant, so deployments using it must rotate or drop the role +# before the native transport is reachable. +# +# MutualTlsDefaultRoleInitializer instead creates the role with no password at all and maps a client +# certificate identity onto it, so there is no credential to guess. It requires an authenticator supporting +# mutual TLS, such as MutualTlsAuthenticator or MutualTlsWithPasswordFallbackAuthenticator. +# +# default_role_initializer: +# class_name: PasswordDefaultRoleInitializer # or: MutualTlsDefaultRoleInitializer +# parameters: +# role: cassandra +# password: cassandra # PasswordDefaultRoleInitializer: plaintext password +# # password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" # ...or a bcrypt hash instead +# # identity: "spiffe1" # MutualTlsDefaultRoleInitializer: cert identity to map + # Network authorization backend, implementing INetworkAuthorizer; used to restrict user # access to certain DCs # Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllNetworkAuthorizer, diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index 6ba353b1540a..78568f434fd3 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -107,6 +107,9 @@ public static void applyAuth() if (authenticator instanceof PasswordAuthenticator && !(roleManager instanceof CassandraRoleManager)) throw new ConfigurationException(authenticator.getClass().getName() + " requires " + CassandraRoleManager.class.getName(), false); + if (!defaultRoleInitializer.supportsRoleManager(roleManager)) + throw new ConfigurationException(defaultRoleInitializer.getClass().getName() + " does not support " + roleManager.getClass().getName(), false); + DatabaseDescriptor.setRoleManager(roleManager); // authenticator From f256ba66dc034f185e17d64dd5f9d12de1e906ae Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Fri, 14 Aug 2026 17:25:55 -0700 Subject: [PATCH 15/21] fix escape --- .../org/apache/cassandra/auth/AuthUtils.java | 2 +- .../cassandra/auth/CassandraAuthorizer.java | 8 ++---- .../cassandra/auth/CassandraRoleManager.java | 25 +++---------------- .../apache/cassandra/tools/HashPassword.java | 8 ++---- 4 files changed, 9 insertions(+), 34 deletions(-) diff --git a/src/java/org/apache/cassandra/auth/AuthUtils.java b/src/java/org/apache/cassandra/auth/AuthUtils.java index 5a6f69d51750..c6c29c132902 100644 --- a/src/java/org/apache/cassandra/auth/AuthUtils.java +++ b/src/java/org/apache/cassandra/auth/AuthUtils.java @@ -37,7 +37,7 @@ public static String hashpw(String password) public static String escape(String name) { - return StringUtils.replace(name, "", "''"); + return StringUtils.replace(name, "'", "''"); } /** Allows selective overriding of the consistency level for specific roles. */ diff --git a/src/java/org/apache/cassandra/auth/CassandraAuthorizer.java b/src/java/org/apache/cassandra/auth/CassandraAuthorizer.java index 245c87a91ef3..c9ea42c9f95e 100644 --- a/src/java/org/apache/cassandra/auth/CassandraAuthorizer.java +++ b/src/java/org/apache/cassandra/auth/CassandraAuthorizer.java @@ -67,6 +67,8 @@ import org.apache.cassandra.utils.ByteBufferUtil; import org.apache.cassandra.utils.Pair; +import static org.apache.cassandra.auth.AuthUtils.escape; + /** * CassandraAuthorizer is an IAuthorizer implementation that keeps * user permissions internally in C* using the system_auth.role_permissions @@ -428,12 +430,6 @@ private SelectStatement prepare(String entityname, String permissionsTable) return (SelectStatement) QueryProcessor.getStatement(query, ClientState.forInternalCalls()); } - // We only worry about one character ('). Make sure it's properly escaped. - private String escape(String name) - { - return StringUtils.replace(name, "'", "''"); - } - ResultMessage.Rows select(SelectStatement statement, QueryOptions options) { return statement.execute(QueryState.forInternalCalls(), options, Dispatcher.RequestTime.forImmediateExecution()); diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index 4fd82cbc5ad1..52f30a243906 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -83,6 +83,10 @@ import org.apache.cassandra.utils.MBeanWrapper; import org.apache.cassandra.utils.NoSpamLogger; +import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleRead; +import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; +import static org.apache.cassandra.auth.AuthUtils.escape; +import static org.apache.cassandra.auth.AuthUtils.hashpw; import static org.apache.cassandra.service.QueryState.forInternalCalls; import static org.apache.cassandra.utils.ByteBufferUtil.bytes; @@ -732,32 +736,11 @@ private void enforcePasswordUpdateRateLimit(AuthenticatedUser performer, String throw new OverloadedException(failure); } - static String hashpw(String password) - { - return AuthUtils.hashpw(password); - } - - static String escape(String name) - { - return AuthUtils.escape(name); - } - private static ByteBuffer byteBuf(String str) { return UTF8Type.instance.decompose(str); } - /** Allows selective overriding of the consistency level for specific roles. */ - protected static ConsistencyLevel consistencyForRoleWrite(String role) - { - return AuthUtils.consistencyForRoleWrite(role); - } - - protected static ConsistencyLevel consistencyForRoleRead(String role) - { - return AuthUtils.consistencyForRoleRead(role); - } - /** * Executes the provided query. * This shouldn't be used during setup as this will directly return an error if the manager is not setup yet. Setup tasks diff --git a/src/java/org/apache/cassandra/tools/HashPassword.java b/src/java/org/apache/cassandra/tools/HashPassword.java index 68a13d603c94..0852c320421b 100644 --- a/src/java/org/apache/cassandra/tools/HashPassword.java +++ b/src/java/org/apache/cassandra/tools/HashPassword.java @@ -31,11 +31,12 @@ import org.apache.commons.cli.OptionGroup; import org.apache.commons.cli.Options; import org.apache.commons.cli.ParseException; -import org.apache.commons.lang3.StringUtils; import org.mindrot.jbcrypt.BCrypt; import org.apache.cassandra.io.util.File; +import static org.apache.cassandra.auth.AuthUtils.escape; + public class HashPassword { private static final String LOGROUNDS_OPTION = "logrounds"; @@ -180,11 +181,6 @@ private static String hashpw(String password, int rounds) return BCrypt.hashpw(password, BCrypt.gensalt(rounds)); } - private static String escape(String name) - { - return StringUtils.replace(name, "'", "''"); - } - public static void printUsage(Options options) { String usage = "hash_password [options]"; From d65bb16b83b0b87a5cb24d976200d6f5c6fe6271 Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Sun, 16 Aug 2026 07:10:41 -0700 Subject: [PATCH 16/21] Fixes for single quote escape --- .../auth/AbstractDefaultRoleInitializer.java | 4 +- .../org/apache/cassandra/auth/AuthUtils.java | 10 ++- .../cassandra/auth/CassandraAuthorizer.java | 42 +++++----- .../cassandra/auth/CassandraRoleManager.java | 30 +++---- .../auth/MutualTlsDefaultRoleInitializer.java | 8 +- .../cassandra/auth/PasswordAuthenticator.java | 2 +- .../auth/PasswordDefaultRoleInitializer.java | 6 +- .../apache/cassandra/tools/HashPassword.java | 4 +- .../apache/cassandra/auth/AuthUtilsTest.java | 83 +++++++++++++++++++ .../org/apache/cassandra/auth/RolesTest.java | 16 ++-- 10 files changed, 148 insertions(+), 57 deletions(-) create mode 100644 test/unit/org/apache/cassandra/auth/AuthUtilsTest.java diff --git a/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java index 2b595f07b307..e07834c264b3 100644 --- a/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java @@ -27,7 +27,7 @@ import org.apache.cassandra.schema.SchemaConstants; import org.apache.cassandra.tcm.ClusterMetadata; -import static org.apache.cassandra.auth.AuthUtils.escape; +import static org.apache.cassandra.auth.AuthUtils.escapeCqlLiteral; public abstract class AbstractDefaultRoleInitializer implements IDefaultRoleInitializer { @@ -54,7 +54,7 @@ public final void initializeDefaultRoleIfNeeded() public final boolean hasExistingRoles() { // Try looking up the configured default role first, to avoid the range query if possible. - String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escape(defaultRoleName())); + String defaultRoleQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, escapeCqlLiteral(defaultRoleName())); String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES); return !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.ONE).isEmpty() || !QueryProcessor.process(defaultRoleQuery, ConsistencyLevel.QUORUM).isEmpty() diff --git a/src/java/org/apache/cassandra/auth/AuthUtils.java b/src/java/org/apache/cassandra/auth/AuthUtils.java index c6c29c132902..634acdbd9483 100644 --- a/src/java/org/apache/cassandra/auth/AuthUtils.java +++ b/src/java/org/apache/cassandra/auth/AuthUtils.java @@ -35,7 +35,15 @@ public static String hashpw(String password) return BCrypt.hashpw(password, PasswordSaltSupplier.get()); } - public static String escape(String name) + /** + * Escapes a value for safe interpolation into a single-quoted CQL string literal by doubling any + * single quotes it contains (e.g. {@code o'brien} becomes {@code o''brien}). Used when building auth + * DDL/DML from role, identity and resource names via {@link String#format}. + * + * @param name the raw value to escape; {@code null} is returned unchanged + * @return the value with every {@code '} doubled + */ + public static String escapeCqlLiteral(String name) { return StringUtils.replace(name, "'", "''"); } diff --git a/src/java/org/apache/cassandra/auth/CassandraAuthorizer.java b/src/java/org/apache/cassandra/auth/CassandraAuthorizer.java index c9ea42c9f95e..be375d3d6322 100644 --- a/src/java/org/apache/cassandra/auth/CassandraAuthorizer.java +++ b/src/java/org/apache/cassandra/auth/CassandraAuthorizer.java @@ -67,7 +67,7 @@ import org.apache.cassandra.utils.ByteBufferUtil; import org.apache.cassandra.utils.Pair; -import static org.apache.cassandra.auth.AuthUtils.escape; +import static org.apache.cassandra.auth.AuthUtils.escapeCqlLiteral; /** * CassandraAuthorizer is an IAuthorizer implementation that keeps @@ -115,8 +115,8 @@ public Set authorize(AuthenticatedUser user, IResource resource) public Set grant(AuthenticatedUser performer, Set permissions, IResource resource, RoleResource grantee) throws RequestValidationException, RequestExecutionException { - String roleName = escape(grantee.getRoleName()); - String resourceName = escape(resource.getName()); + String roleName = escapeCqlLiteral(grantee.getRoleName()); + String resourceName = escapeCqlLiteral(resource.getName()); Set existingPermissions = getExistingPermissions(roleName, resourceName, permissions); Set nonExistingPermissions = Sets.difference(permissions, existingPermissions); @@ -132,8 +132,8 @@ public Set grant(AuthenticatedUser performer, Set permis public Set revoke(AuthenticatedUser performer, Set permissions, IResource resource, RoleResource revokee) throws RequestValidationException, RequestExecutionException { - String roleName = escape(revokee.getRoleName()); - String resourceName = escape(resource.getName()); + String roleName = escapeCqlLiteral(revokee.getRoleName()); + String resourceName = escapeCqlLiteral(resource.getName()); Set existingPermissions = getExistingPermissions(roleName, resourceName, permissions); if (!existingPermissions.isEmpty()) @@ -157,7 +157,7 @@ public void revokeAllFrom(RoleResource revokee) UntypedResultSet rows = process(String.format("SELECT resource FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLE_PERMISSIONS, - escape(revokee.getRoleName())), + escapeCqlLiteral(revokee.getRoleName())), authReadConsistencyLevel()); List statements = new ArrayList<>(); @@ -167,8 +167,8 @@ public void revokeAllFrom(RoleResource revokee) QueryProcessor.getStatement(String.format("DELETE FROM %s.%s WHERE resource = '%s' AND role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.RESOURCE_ROLE_INDEX, - escape(row.getString("resource")), - escape(revokee.getRoleName())), + escapeCqlLiteral(row.getString("resource")), + escapeCqlLiteral(revokee.getRoleName())), ClientState.forInternalCalls())); } @@ -176,7 +176,7 @@ public void revokeAllFrom(RoleResource revokee) statements.add(QueryProcessor.getStatement(String.format("DELETE FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLE_PERMISSIONS, - escape(revokee.getRoleName())), + escapeCqlLiteral(revokee.getRoleName())), ClientState.forInternalCalls())); executeLoggedBatch(statements); @@ -197,7 +197,7 @@ public void revokeAllOn(IResource droppedResource) UntypedResultSet rows = process(String.format("SELECT role FROM %s.%s WHERE resource = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.RESOURCE_ROLE_INDEX, - escape(droppedResource.getName())), + escapeCqlLiteral(droppedResource.getName())), authReadConsistencyLevel()); List statements = new ArrayList<>(); @@ -206,15 +206,15 @@ public void revokeAllOn(IResource droppedResource) statements.add(QueryProcessor.getStatement(String.format("DELETE FROM %s.%s WHERE role = '%s' AND resource = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLE_PERMISSIONS, - escape(row.getString("role")), - escape(droppedResource.getName())), + escapeCqlLiteral(row.getString("role")), + escapeCqlLiteral(droppedResource.getName())), ClientState.forInternalCalls())); } statements.add(QueryProcessor.getStatement(String.format("DELETE FROM %s.%s WHERE resource = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.RESOURCE_ROLE_INDEX, - escape(droppedResource.getName())), + escapeCqlLiteral(droppedResource.getName())), ClientState.forInternalCalls())); executeLoggedBatch(statements); @@ -299,8 +299,8 @@ private void modifyRolePermissions(Set permissions, IResource resour AuthKeyspace.ROLE_PERMISSIONS, op, "'" + StringUtils.join(permissions, "','") + "'", - escape(role.getRoleName()), - escape(resource.getName())), + escapeCqlLiteral(role.getRoleName()), + escapeCqlLiteral(resource.getName())), authWriteConsistencyLevel()); } @@ -310,8 +310,8 @@ private void removeLookupEntry(IResource resource, RoleResource role) throws Req process(String.format("DELETE FROM %s.%s WHERE resource = '%s' and role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.RESOURCE_ROLE_INDEX, - escape(resource.getName()), - escape(role.getRoleName())), + escapeCqlLiteral(resource.getName()), + escapeCqlLiteral(role.getRoleName())), authWriteConsistencyLevel()); } @@ -321,8 +321,8 @@ private void addLookupEntry(IResource resource, RoleResource role) throws Reques process(String.format("INSERT INTO %s.%s (resource, role) VALUES ('%s','%s')", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.RESOURCE_ROLE_INDEX, - escape(resource.getName()), - escape(role.getRoleName())), + escapeCqlLiteral(resource.getName()), + escapeCqlLiteral(role.getRoleName())), authWriteConsistencyLevel()); } @@ -386,13 +386,13 @@ private String buildListQuery(IResource resource, RoleResource grantee) if (resource != null) { conditions.add("resource = '%s'"); - vars.add(escape(resource.getName())); + vars.add(escapeCqlLiteral(resource.getName())); } if (grantee != null) { conditions.add(ROLE + " = '%s'"); - vars.add(escape(grantee.getRoleName())); + vars.add(escapeCqlLiteral(grantee.getRoleName())); } String query = "SELECT " + ROLE + ", resource, permissions FROM %s.%s"; diff --git a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java index 52f30a243906..4a70108a8ebc 100644 --- a/src/java/org/apache/cassandra/auth/CassandraRoleManager.java +++ b/src/java/org/apache/cassandra/auth/CassandraRoleManager.java @@ -85,7 +85,7 @@ import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleRead; import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; -import static org.apache.cassandra.auth.AuthUtils.escape; +import static org.apache.cassandra.auth.AuthUtils.escapeCqlLiteral; import static org.apache.cassandra.auth.AuthUtils.hashpw; import static org.apache.cassandra.service.QueryState.forInternalCalls; import static org.apache.cassandra.utils.ByteBufferUtil.bytes; @@ -348,14 +348,14 @@ public void createRole(AuthenticatedUser performer, RoleResource role, RoleOptio ? String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) VALUES ('%s', %s, %s, '%s')", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, - escape(role.getRoleName()), + escapeCqlLiteral(role.getRoleName()), options.getSuperuser().orElse(false), options.getLogin().orElse(false), - options.getHashedPassword().orElseGet(() -> escape(hashpw(options.getPassword().get())))) + options.getHashedPassword().orElseGet(() -> escapeCqlLiteral(hashpw(options.getPassword().get())))) : String.format("INSERT INTO %s.%s (role, is_superuser, can_login) VALUES ('%s', %s, %s)", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, - escape(role.getRoleName()), + escapeCqlLiteral(role.getRoleName()), options.getSuperuser().orElse(false), options.getLogin().orElse(false)); process(insertCql, consistencyForRoleWrite(role.getRoleName())); @@ -366,7 +366,7 @@ public void dropRole(AuthenticatedUser performer, RoleResource role) throws Requ process(String.format("DELETE FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, - escape(role.getRoleName())), + escapeCqlLiteral(role.getRoleName())), consistencyForRoleWrite(role.getRoleName())); removeAllMembers(role.getRoleName()); removeAllIdentitiesOfRole(role.getRoleName()); @@ -386,7 +386,7 @@ public void alterRole(AuthenticatedUser performer, RoleResource role, RoleOption SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, assignments, - escape(role.getRoleName())), + escapeCqlLiteral(role.getRoleName())), consistencyForRoleWrite(role.getRoleName())); } } @@ -421,8 +421,8 @@ public void grantRole(AuthenticatedUser performer, RoleResource role, RoleResour process(String.format("INSERT INTO %s.%s (role, member) values ('%s', '%s')", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLE_MEMBERS, - escape(role.getRoleName()), - escape(grantee.getRoleName())), + escapeCqlLiteral(role.getRoleName()), + escapeCqlLiteral(grantee.getRoleName())), consistencyForRoleWrite(role.getRoleName())); } @@ -438,8 +438,8 @@ public void revokeRole(AuthenticatedUser performer, RoleResource role, RoleResou process(String.format("DELETE FROM %s.%s WHERE role = '%s' and member = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLE_MEMBERS, - escape(role.getRoleName()), - escape(revokee.getRoleName())), + escapeCqlLiteral(role.getRoleName()), + escapeCqlLiteral(revokee.getRoleName())), consistencyForRoleWrite(role.getRoleName())); } @@ -617,8 +617,8 @@ private void modifyRoleMembership(String grantee, String role, String op) SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, op, - escape(role), - escape(grantee)), + escapeCqlLiteral(role), + escapeCqlLiteral(grantee)), consistencyForRoleWrite(grantee)); } @@ -656,7 +656,7 @@ private void removeAllMembers(String role) throws RequestValidationException, Re UntypedResultSet rows = process(String.format("SELECT member FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLE_MEMBERS, - escape(role)), + escapeCqlLiteral(role)), consistencyForRoleRead(role)); if (rows.isEmpty()) return; @@ -669,7 +669,7 @@ private void removeAllMembers(String role) throws RequestValidationException, Re process(String.format("DELETE FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLE_MEMBERS, - escape(role)), + escapeCqlLiteral(role)), consistencyForRoleWrite(role)); } @@ -690,7 +690,7 @@ private String optionsToAssignments(Map options) case SUPERUSER: return String.format("is_superuser = %s", entry.getValue()); case PASSWORD: - return String.format("salted_hash = '%s'", escape(hashpw((String) entry.getValue()))); + return String.format("salted_hash = '%s'", escapeCqlLiteral(hashpw((String) entry.getValue()))); case HASHED_PASSWORD: return String.format("salted_hash = '%s'", (String) entry.getValue()); default: diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index d406335a250c..a3287f5478d1 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -32,7 +32,7 @@ import org.apache.cassandra.schema.SchemaConstants; import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; -import static org.apache.cassandra.auth.AuthUtils.escape; +import static org.apache.cassandra.auth.AuthUtils.escapeCqlLiteral; public class MutualTlsDefaultRoleInitializer extends AbstractDefaultRoleInitializer { @@ -64,15 +64,15 @@ public void createDefaultRole() "VALUES ('%s', true, true) USING TIMESTAMP 0", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, - escape(role)), + escapeCqlLiteral(role)), consistencyForRoleWrite(role)); QueryProcessor.process(String.format("INSERT INTO %s.%s (identity, role) " + "VALUES ('%s', '%s') USING TIMESTAMP 0", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.IDENTITY_TO_ROLES, - escape(identity), - escape(role)), + escapeCqlLiteral(identity), + escapeCqlLiteral(role)), consistencyForRoleWrite(role)); logger.info("Created passwordless default superuser role '{}' with mapped identity '{}'", role, identity); diff --git a/src/java/org/apache/cassandra/auth/PasswordAuthenticator.java b/src/java/org/apache/cassandra/auth/PasswordAuthenticator.java index 9b810f644b8d..cfedbed8e734 100644 --- a/src/java/org/apache/cassandra/auth/PasswordAuthenticator.java +++ b/src/java/org/apache/cassandra/auth/PasswordAuthenticator.java @@ -50,7 +50,7 @@ import org.apache.cassandra.transport.messages.ResultMessage; import org.apache.cassandra.utils.ByteBufferUtil; -import static org.apache.cassandra.auth.CassandraRoleManager.consistencyForRoleRead; +import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleRead; /** * PasswordAuthenticator is an IAuthenticator implementation diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 4d0ad023308c..16bf37420a6b 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -32,7 +32,7 @@ import org.apache.cassandra.schema.SchemaConstants; import static org.apache.cassandra.auth.AuthUtils.consistencyForRoleWrite; -import static org.apache.cassandra.auth.AuthUtils.escape; +import static org.apache.cassandra.auth.AuthUtils.escapeCqlLiteral; import static org.apache.cassandra.auth.AuthUtils.hashpw; /** @@ -123,7 +123,7 @@ public String createDefaultRoleQuery() return String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) VALUES ('%s', true, true, '%s') USING TIMESTAMP 0", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, - escape(role), - escape(password == null ? passwordHash : hashpw(password))); + escapeCqlLiteral(role), + escapeCqlLiteral(password == null ? passwordHash : hashpw(password))); } } diff --git a/src/java/org/apache/cassandra/tools/HashPassword.java b/src/java/org/apache/cassandra/tools/HashPassword.java index 0852c320421b..ff8c41860e6b 100644 --- a/src/java/org/apache/cassandra/tools/HashPassword.java +++ b/src/java/org/apache/cassandra/tools/HashPassword.java @@ -35,7 +35,7 @@ import org.apache.cassandra.io.util.File; -import static org.apache.cassandra.auth.AuthUtils.escape; +import static org.apache.cassandra.auth.AuthUtils.escapeCqlLiteral; public class HashPassword { @@ -132,7 +132,7 @@ else if (cmd.hasOption(INPUT)) "(bcrypt) can only compare up to 72 bytes. The password will be accepted and work, but only compared up to 72 bytes.", password.getBytes().length)); - String hashed = escape(hashpw(password, logRounds)); + String hashed = escapeCqlLiteral(hashpw(password, logRounds)); System.out.print(hashed); System.out.flush(); } diff --git a/test/unit/org/apache/cassandra/auth/AuthUtilsTest.java b/test/unit/org/apache/cassandra/auth/AuthUtilsTest.java new file mode 100644 index 000000000000..0447c65899e8 --- /dev/null +++ b/test/unit/org/apache/cassandra/auth/AuthUtilsTest.java @@ -0,0 +1,83 @@ +/* + * 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.cassandra.auth; + +import org.junit.Test; + +import org.mindrot.jbcrypt.BCrypt; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for the pure helpers in {@link AuthUtils} + */ +public class AuthUtilsTest +{ + @Test + public void escapeCqlLiteralLeavesValuesWithoutQuotesUnchanged() + { + assertThat(AuthUtils.escapeCqlLiteral("cassandra")).isEqualTo("cassandra"); + assertThat(AuthUtils.escapeCqlLiteral("")).isEqualTo(""); + assertThat(AuthUtils.escapeCqlLiteral("role_with-various.chars")).isEqualTo("role_with-various.chars"); + } + + @Test + public void escapeCqlLiteralDoublesSingleQuotes() + { + assertThat(AuthUtils.escapeCqlLiteral("o'brien")).isEqualTo("o''brien"); + } + + @Test + public void escapeCqlLiteralDoublesEveryQuote() + { + assertThat(AuthUtils.escapeCqlLiteral("'")).isEqualTo("''"); + assertThat(AuthUtils.escapeCqlLiteral("''")).isEqualTo("''''"); + assertThat(AuthUtils.escapeCqlLiteral("a'b'c")).isEqualTo("a''b''c"); + assertThat(AuthUtils.escapeCqlLiteral("'lead")).isEqualTo("''lead"); + assertThat(AuthUtils.escapeCqlLiteral("trail'")).isEqualTo("trail''"); + } + + @Test + public void escapeCqlLiteralNeutralisesInjectionAttempt() + { + String malicious = "x'; DROP KEYSPACE system_auth; --"; + assertThat(AuthUtils.escapeCqlLiteral(malicious)).isEqualTo("x''; DROP KEYSPACE system_auth; --"); + } + + @Test + public void escapeCqlLiteralReturnsNullUnchanged() + { + assertThat(AuthUtils.escapeCqlLiteral(null)).isNull(); + } + + @Test + public void hashpwProducesVerifiableBcryptHash() + { + String hash = AuthUtils.hashpw("cassandra"); + assertThat(hash).startsWith("$2a$"); + assertThat(BCrypt.checkpw("cassandra", hash)).isTrue(); + assertThat(BCrypt.checkpw("wrong", hash)).isFalse(); + } + + @Test + public void hashpwIsSaltedSoRepeatedHashesDiffer() + { + assertThat(AuthUtils.hashpw("cassandra")).isNotEqualTo(AuthUtils.hashpw("cassandra")); + } +} diff --git a/test/unit/org/apache/cassandra/auth/RolesTest.java b/test/unit/org/apache/cassandra/auth/RolesTest.java index ff1718e16dd6..81ae3f3ab021 100644 --- a/test/unit/org/apache/cassandra/auth/RolesTest.java +++ b/test/unit/org/apache/cassandra/auth/RolesTest.java @@ -119,17 +119,17 @@ public void grantedRoleResourcesAreCached() public void confirmSuperUserConsistency() { // Confirm special treatment of superuser - ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); + ConsistencyLevel readLevel = AuthUtils.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); - ConsistencyLevel writeLevel = CassandraRoleManager.consistencyForRoleWrite(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); + ConsistencyLevel writeLevel = AuthUtils.consistencyForRoleWrite(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); // Confirm standard config-based treatment of non - ConsistencyLevel nonPrivReadLevel = CassandraRoleManager.consistencyForRoleRead("non-privilaged"); + ConsistencyLevel nonPrivReadLevel = AuthUtils.consistencyForRoleRead("non-privilaged"); Assert.assertEquals(nonPrivReadLevel, DatabaseDescriptor.getAuthReadConsistencyLevel()); - ConsistencyLevel nonPrivWriteLevel = CassandraRoleManager.consistencyForRoleWrite("non-privilaged"); + ConsistencyLevel nonPrivWriteLevel = AuthUtils.consistencyForRoleWrite("non-privilaged"); Assert.assertEquals(nonPrivWriteLevel, DatabaseDescriptor.getAuthWriteConsistencyLevel()); } @@ -146,16 +146,16 @@ public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() MutualTlsDefaultRoleInitializer.ROLE, customRole, MutualTlsDefaultRoleInitializer.IDENTITY, "spiffe1"))); - ConsistencyLevel readLevel = CassandraRoleManager.consistencyForRoleRead(customRole); + ConsistencyLevel readLevel = AuthUtils.consistencyForRoleRead(customRole); Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, readLevel); - ConsistencyLevel writeLevel = CassandraRoleManager.consistencyForRoleWrite(customRole); + ConsistencyLevel writeLevel = AuthUtils.consistencyForRoleWrite(customRole); Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, writeLevel); - ConsistencyLevel legacyReadLevel = CassandraRoleManager.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); + ConsistencyLevel legacyReadLevel = AuthUtils.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(legacyReadLevel, DatabaseDescriptor.getAuthReadConsistencyLevel()); - ConsistencyLevel legacyWriteLevel = CassandraRoleManager.consistencyForRoleWrite(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); + ConsistencyLevel legacyWriteLevel = AuthUtils.consistencyForRoleWrite(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME); Assert.assertEquals(legacyWriteLevel, DatabaseDescriptor.getAuthWriteConsistencyLevel()); } finally From 8a3809722270415441a28d60bc03348a10602032 Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Mon, 17 Aug 2026 11:19:20 -0700 Subject: [PATCH 17/21] Detailed review --- .../auth/AbstractDefaultRoleInitializer.java | 9 +++--- .../org/apache/cassandra/auth/AuthUtils.java | 15 ++++++++-- .../auth/PasswordDefaultRoleInitializer.java | 7 +++-- .../cassandra/config/ParameterizedClass.java | 10 ++----- .../MutualTlsDefaultRoleInitializerTest.java | 1 + .../PasswordDefaultRoleInitializerTest.java | 2 ++ .../auth/DefaultRoleInitializerTest.java | 8 +++++ .../org/apache/cassandra/auth/RolesTest.java | 29 +++++++++++++++++++ .../config/ParameterizedClassTest.java | 10 +++++++ 9 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java index e07834c264b3..ce741a0f337d 100644 --- a/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/AbstractDefaultRoleInitializer.java @@ -39,13 +39,14 @@ public final void initializeDefaultRoleIfNeeded() if (ClusterMetadata.current().tokenMap.tokens().isEmpty()) throw new IllegalStateException(getClass().getSimpleName() + " skipped role setup: no known tokens in the ring"); - try { + try + { if (!hasExistingRoles()) createDefaultRole(); - - } catch (RequestExecutionException e) + } + catch (RequestExecutionException e) { - logger.warn(getClass().getSimpleName() + " skipped default role setup: some nodes were not ready"); + logger.warn("{} skipped default role setup: some nodes were not ready", getClass().getSimpleName()); throw e; } } diff --git a/src/java/org/apache/cassandra/auth/AuthUtils.java b/src/java/org/apache/cassandra/auth/AuthUtils.java index 634acdbd9483..821845fe559e 100644 --- a/src/java/org/apache/cassandra/auth/AuthUtils.java +++ b/src/java/org/apache/cassandra/auth/AuthUtils.java @@ -24,6 +24,8 @@ import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.db.ConsistencyLevel; +import static org.apache.cassandra.auth.PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME; + public class AuthUtils { static final ConsistencyLevel DEFAULT_SUPERUSER_CONSISTENCY_LEVEL = ConsistencyLevel.QUORUM; @@ -51,11 +53,20 @@ public static String escapeCqlLiteral(String name) /** Allows selective overriding of the consistency level for specific roles. */ public static ConsistencyLevel consistencyForRoleWrite(String role) { - return role.equals(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authWriteConsistencyLevel(); + return defaultRoleName().equals(role) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authWriteConsistencyLevel(); } public static ConsistencyLevel consistencyForRoleRead(String role) { - return role.equals(DatabaseDescriptor.getRoleManager().defaultRoleInitializer().defaultRoleName()) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authReadConsistencyLevel(); + return defaultRoleName().equals(role) ? DEFAULT_SUPERUSER_CONSISTENCY_LEVEL : CassandraAuthorizer.authReadConsistencyLevel(); + } + + private static String defaultRoleName() + { + IRoleManager roleManager = DatabaseDescriptor.getRoleManager(); + IDefaultRoleInitializer initializer = roleManager != null + ? roleManager.defaultRoleInitializer() + : DatabaseDescriptor.getDefaultRoleInitializer(); + return initializer != null ? initializer.defaultRoleName() : DEFAULT_SUPERUSER_NAME; } } diff --git a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java index 16bf37420a6b..5e86ec04a8f1 100644 --- a/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/PasswordDefaultRoleInitializer.java @@ -78,9 +78,10 @@ public PasswordDefaultRoleInitializer(Map parameters) role = parameters.getOrDefault(ROLE, DEFAULT_SUPERUSER_NAME); passwordHash = parameters.get(PASSWORD_HASH); - password = passwordHash == null - ? parameters.getOrDefault(PASSWORD, DEFAULT_SUPERUSER_PASSWORD) - : null; + String configuredPassword = parameters.get(PASSWORD); + password = (configuredPassword == null && passwordHash == null) + ? DEFAULT_SUPERUSER_PASSWORD + : configuredPassword; } @Override diff --git a/src/java/org/apache/cassandra/config/ParameterizedClass.java b/src/java/org/apache/cassandra/config/ParameterizedClass.java index 3bfe2175dbca..ea7258c2a09f 100644 --- a/src/java/org/apache/cassandra/config/ParameterizedClass.java +++ b/src/java/org/apache/cassandra/config/ParameterizedClass.java @@ -32,7 +32,6 @@ import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.utils.Shared; -import static java.util.stream.Collectors.toMap; import static org.apache.cassandra.utils.LocalizeString.toLowerCaseLocalized; import static org.apache.cassandra.utils.Shared.Scope.SIMULATION; @@ -178,12 +177,9 @@ public String toString() } else { - Map sanitizedMap = parameters.entrySet() - .stream() - .collect(toMap(Entry::getKey, - e -> isSensitive(e.getKey()) ? "" : e.getValue(), - (a, b) -> a, - TreeMap::new)); + Map sanitizedMap = new TreeMap<>(); + for (Entry entry : parameters.entrySet()) + sanitizedMap.put(entry.getKey(), isSensitive(entry.getKey()) ? "" : entry.getValue()); return class_name + sanitizedMap; } } diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsDefaultRoleInitializerTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsDefaultRoleInitializerTest.java index 9a77d4e24868..de341cfb031a 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsDefaultRoleInitializerTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsDefaultRoleInitializerTest.java @@ -131,6 +131,7 @@ public void testDefaultRoleAndIdentityCreatedAtBootstrap() UntypedResultSet result = QueryProcessor.executeInternal( String.format("SELECT is_superuser, can_login, salted_hash FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, TEST_ROLE)); + Assert.assertNotNull(result); if (result.isEmpty()) return null; UntypedResultSet.Row row = result.one(); diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java index 89c59881a79e..bed3156052b5 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/PasswordDefaultRoleInitializerTest.java @@ -26,6 +26,7 @@ import com.datastax.driver.core.policies.LoadBalancingPolicy; import org.junit.AfterClass; +import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; @@ -81,6 +82,7 @@ public void testClassicSuperuserBootstrappedByDefault() UntypedResultSet result = QueryProcessor.executeInternal( String.format("SELECT is_superuser, can_login, salted_hash FROM %s.%s WHERE role = '%s'", SchemaConstants.AUTH_KEYSPACE_NAME, AuthKeyspace.ROLES, DEFAULT_SUPERUSER_NAME)); + Assert.assertNotNull(result); if (result.isEmpty()) return null; UntypedResultSet.Row row = result.one(); diff --git a/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java b/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java index c93fdfc5df55..a5e713c1d042 100644 --- a/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java +++ b/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java @@ -68,6 +68,14 @@ public void passwordInitializerRejectsEmptyPassword() .hasMessageContaining("password"); } + @Test + public void passwordInitializerRejectsBothPasswordAndHash() + { + assertThatThrownBy(() -> new PasswordDefaultRoleInitializer(Map.of("password", "x", "password_hash", "$2a$04$abc")).validateConfiguration()) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("Only one of password, password_hash can be specified."); + } + @Test public void passwordInitializerDefaultRoleNameMatchesConfiguredRole() { diff --git a/test/unit/org/apache/cassandra/auth/RolesTest.java b/test/unit/org/apache/cassandra/auth/RolesTest.java index 81ae3f3ab021..b67313f1c253 100644 --- a/test/unit/org/apache/cassandra/auth/RolesTest.java +++ b/test/unit/org/apache/cassandra/auth/RolesTest.java @@ -165,6 +165,35 @@ public void confirmSuperUserConsistencyWithConfiguredDefaultRoleName() } } + @Test + public void consistencyForRoleToleratesUnsetRoleManager() + { + IRoleManager previousRoleManager = DatabaseDescriptor.getRoleManager(); + IDefaultRoleInitializer previousInitializer = DatabaseDescriptor.getDefaultRoleInitializer(); + try + { + // Before auth setup runs, both the role manager and the initializer can be unset. consistencyForRole* + // must not NPE: it falls back to the historical default role name (see AuthUtils#defaultRoleName). + DatabaseDescriptor.setRoleManager(null); + DatabaseDescriptor.setDefaultRoleInitializer(null); + + Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, + AuthUtils.consistencyForRoleWrite(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME)); + Assert.assertEquals(AuthUtils.DEFAULT_SUPERUSER_CONSISTENCY_LEVEL, + AuthUtils.consistencyForRoleRead(PasswordDefaultRoleInitializer.DEFAULT_SUPERUSER_NAME)); + + Assert.assertEquals(DatabaseDescriptor.getAuthWriteConsistencyLevel(), + AuthUtils.consistencyForRoleWrite("non-privileged")); + Assert.assertEquals(DatabaseDescriptor.getAuthReadConsistencyLevel(), + AuthUtils.consistencyForRoleRead("non-privileged")); + } + finally + { + DatabaseDescriptor.setRoleManager(previousRoleManager); + DatabaseDescriptor.setDefaultRoleInitializer(previousInitializer); + } + } + @Test public void testSuperUsers() { diff --git a/test/unit/org/apache/cassandra/config/ParameterizedClassTest.java b/test/unit/org/apache/cassandra/config/ParameterizedClassTest.java index 14725f431621..ee6e45c27700 100644 --- a/test/unit/org/apache/cassandra/config/ParameterizedClassTest.java +++ b/test/unit/org/apache/cassandra/config/ParameterizedClassTest.java @@ -217,6 +217,16 @@ public void testToStringRedactsSensitiveKeyWithNullValueWithoutThrowing() assertThat(pc.toString()).isEqualTo("Foo{password=}"); } + @Test + public void testToStringRendersNonSensitiveNullValueWithoutThrowing() + { + Map params = new HashMap<>(); + params.put("identity", null); + ParameterizedClass pc = new ParameterizedClass("Foo", params); + // A non-sensitive key with a null value must render as key=null + assertThat(pc.toString()).isEqualTo("Foo{identity=null}"); + } + @Test public void testIsSensitiveMatchesPasswordAndHashCaseInsensitively() { From 1df3b7a1a2f47993438fc646c972a04e6a417925 Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Mon, 17 Aug 2026 11:25:36 -0700 Subject: [PATCH 18/21] insert mapping first and then role for MutualTlsDefaultRoleInitializer to prevent failures disallowing you from logging in to the cluster because the identity mapping does not exist --- .../auth/MutualTlsDefaultRoleInitializer.java | 40 ++++++++++++------- .../auth/DefaultRoleInitializerTest.java | 14 +++++++ 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java index a3287f5478d1..9e255da3ee69 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsDefaultRoleInitializer.java @@ -18,9 +18,11 @@ package org.apache.cassandra.auth; +import java.util.List; import java.util.Map; import java.util.Set; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import org.slf4j.Logger; @@ -60,24 +62,34 @@ public boolean supportsRoleManager(IRoleManager manager) @Override public void createDefaultRole() { - QueryProcessor.process(String.format("INSERT INTO %s.%s (role, is_superuser, can_login) " + - "VALUES ('%s', true, true) USING TIMESTAMP 0", - SchemaConstants.AUTH_KEYSPACE_NAME, - AuthKeyspace.ROLES, - escapeCqlLiteral(role)), - consistencyForRoleWrite(role)); - - QueryProcessor.process(String.format("INSERT INTO %s.%s (identity, role) " + - "VALUES ('%s', '%s') USING TIMESTAMP 0", - SchemaConstants.AUTH_KEYSPACE_NAME, - AuthKeyspace.IDENTITY_TO_ROLES, - escapeCqlLiteral(identity), - escapeCqlLiteral(role)), - consistencyForRoleWrite(role)); + for (String cql : defaultRoleStatements()) + QueryProcessor.process(cql, consistencyForRoleWrite(role)); logger.info("Created passwordless default superuser role '{}' with mapped identity '{}'", role, identity); } + /** + * The statements {@link #createDefaultRole()} runs, in execution order. The role row is written LAST because + * {@link #hasExistingRoles()} gates on it: if a write fails after the identity mapping but before the role, the + * retry sees no role, re-runs both idempotent ({@code USING TIMESTAMP 0}) statements and heals the mapping. Were + * the role written first, a failure before the mapping would leave the gate satisfied and the mapping never written. + */ + @VisibleForTesting + List defaultRoleStatements() + { + return List.of(String.format("INSERT INTO %s.%s (identity, role) " + + "VALUES ('%s', '%s') USING TIMESTAMP 0", + SchemaConstants.AUTH_KEYSPACE_NAME, + AuthKeyspace.IDENTITY_TO_ROLES, + escapeCqlLiteral(identity), + escapeCqlLiteral(role)), + String.format("INSERT INTO %s.%s (role, is_superuser, can_login) " + + "VALUES ('%s', true, true) USING TIMESTAMP 0", + SchemaConstants.AUTH_KEYSPACE_NAME, + AuthKeyspace.ROLES, + escapeCqlLiteral(role))); + } + @Override public String defaultRoleName() { diff --git a/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java b/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java index a5e713c1d042..291b71f231c2 100644 --- a/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java +++ b/test/unit/org/apache/cassandra/auth/DefaultRoleInitializerTest.java @@ -19,6 +19,7 @@ package org.apache.cassandra.auth; import java.util.Collections; +import java.util.List; import java.util.Map; import org.junit.Test; @@ -121,4 +122,17 @@ public void mutualTlsInitializerDefaultRoleNameMatchesConfiguredRole() MutualTlsDefaultRoleInitializer initializer = new MutualTlsDefaultRoleInitializer(Map.of("role", "cassandra", "identity", "spiffe1")); assertThat(initializer.defaultRoleName()).isEqualTo("cassandra"); } + + @Test + public void mutualTlsWritesIdentityMappingBeforeRole() + { + MutualTlsDefaultRoleInitializer initializer = new MutualTlsDefaultRoleInitializer(Map.of("role", "cassandra", "identity", "spiffe1")); + List statements = initializer.defaultRoleStatements(); + + // The identity mapping must be written before the role row. hasExistingRoles() gates on the role, so writing + // it last means a retry after a partial write re-drives both idempotent statements and heals the mapping. + assertThat(statements).hasSize(2); + assertThat(statements.get(0)).contains(AuthKeyspace.IDENTITY_TO_ROLES).contains("(identity, role)"); + assertThat(statements.get(1)).contains("(role, is_superuser, can_login)"); + } } From 3b801615adb43562dfbf00400776d4bfc5d47e2e Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Mon, 17 Aug 2026 11:37:29 -0700 Subject: [PATCH 19/21] do not validate default role initializer when it's not explicitly configured in the config --- .../org/apache/cassandra/auth/AuthConfig.java | 13 +++++-- .../apache/cassandra/auth/AuthConfigTest.java | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index 78568f434fd3..da97bdae9ec7 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -107,8 +107,7 @@ public static void applyAuth() if (authenticator instanceof PasswordAuthenticator && !(roleManager instanceof CassandraRoleManager)) throw new ConfigurationException(authenticator.getClass().getName() + " requires " + CassandraRoleManager.class.getName(), false); - if (!defaultRoleInitializer.supportsRoleManager(roleManager)) - throw new ConfigurationException(defaultRoleInitializer.getClass().getName() + " does not support " + roleManager.getClass().getName(), false); + validateDefaultRoleInitializerSupportsRoleManager(conf.default_role_initializer, defaultRoleInitializer, roleManager); DatabaseDescriptor.setRoleManager(roleManager); @@ -157,6 +156,16 @@ public static void applyAuth() DatabaseDescriptor.getInternodeAuthenticator().validateConfiguration(); } + @VisibleForTesting + static void validateDefaultRoleInitializerSupportsRoleManager(ParameterizedClass configuredInitializer, + IDefaultRoleInitializer defaultRoleInitializer, + IRoleManager roleManager) + { + boolean explicitlyConfigured = configuredInitializer != null && configuredInitializer.class_name != null; + if (explicitlyConfigured && !defaultRoleInitializer.supportsRoleManager(roleManager)) + throw new ConfigurationException(defaultRoleInitializer.getClass().getName() + " does not support " + roleManager.getClass().getName(), false); + } + private static T authInstantiate(ParameterizedClass authCls, Class expectedType, Class defaultCls) { if (authCls != null && authCls.class_name != null) diff --git a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java index 8f21d9bf6049..e674b8f88bc3 100644 --- a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java +++ b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java @@ -193,6 +193,42 @@ public void testMutualTlsDefaultRoleInitializerRejectedWithIncompatibleAuthentic .hasMessageContaining("creates a role with no password"); } + @Test + public void testDefaultRoleInitializerSupportEnforcedOnlyWhenExplicitlyConfigured() + { + baseConfig(); + IRoleManager roleManager = DatabaseDescriptor.getRoleManager(); + assertNotNull(roleManager); + ParameterizedClass configured = new ParameterizedClass("SomeInitializer", Collections.emptyMap()); + + // Explicitly configured + unsupported role manager -> rejected (the guard is still active). + assertThatThrownBy(() -> AuthConfig.validateDefaultRoleInitializerSupportsRoleManager(configured, new FixedSupportInitializer(false), roleManager)) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("does not support"); + + AuthConfig.validateDefaultRoleInitializerSupportsRoleManager(null, new FixedSupportInitializer(false), roleManager); + + AuthConfig.validateDefaultRoleInitializerSupportsRoleManager(configured, new FixedSupportInitializer(true), roleManager); + } + + /** IDefaultRoleInitializer whose role-manager support is fixed at construction, for exercising the guard. */ + private static class FixedSupportInitializer implements IDefaultRoleInitializer + { + private final boolean supports; + + FixedSupportInitializer(boolean supports) + { + this.supports = supports; + } + + public void createDefaultRole() {} + public String defaultRoleName() { return "test"; } + public void validateConfiguration() {} + public void initializeDefaultRoleIfNeeded() {} + public boolean hasExistingRoles() { return false; } + public boolean supportsRoleManager(IRoleManager manager) { return supports; } + } + private static final String PROBE = ClassLoadingTestNonAssignable.class.getName(); private static Config baseConfig() From 1d651c1a998b766b31b358b7947a32d87e59684c Mon Sep 17 00:00:00 2001 From: Francisco Guerrero Date: Mon, 17 Aug 2026 11:53:04 -0700 Subject: [PATCH 20/21] fix checkstyle --- test/unit/org/apache/cassandra/auth/AuthUtilsTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/org/apache/cassandra/auth/AuthUtilsTest.java b/test/unit/org/apache/cassandra/auth/AuthUtilsTest.java index 0447c65899e8..26ec25c1d58e 100644 --- a/test/unit/org/apache/cassandra/auth/AuthUtilsTest.java +++ b/test/unit/org/apache/cassandra/auth/AuthUtilsTest.java @@ -19,7 +19,6 @@ package org.apache.cassandra.auth; import org.junit.Test; - import org.mindrot.jbcrypt.BCrypt; import static org.assertj.core.api.Assertions.assertThat; From d38ba24249274bcd51264fbb1cd1b0065f19a058 Mon Sep 17 00:00:00 2001 From: Aparna Naik Date: Tue, 18 Aug 2026 11:24:47 -0700 Subject: [PATCH 21/21] NEWS.txt, CHANGES.txt, Documentation for website --- CHANGES.txt | 1 + NEWS.txt | 7 +++ .../pages/managing/operating/security.adoc | 43 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index 5b33484d30c7..080b77698030 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 7.0 + * Support pluggable default role initialization (CASSANDRA-21546) * Don't increment client metrics on messaging service connection unpause (CASSANDRA-21491) * Add nodetool getreplicas (CASSANDRA-17665) * Implementation of CEP-49: Hardware-accelerated compression (CASSANDRA-20975) diff --git a/NEWS.txt b/NEWS.txt index 028266355fea..44ffed9cf9d3 100644 --- a/NEWS.txt +++ b/NEWS.txt @@ -83,6 +83,13 @@ New features - CEP-49 - it is possible to implement custom compression providers, e.g. integrating with a specialized hardware which makes de/compression faster. See CASSANDRA-20975 for more information. + - It is now possible to bootstrap a cluster's initial superuser role without + creating the 'cassandra/cassandra' password, by configuring 'default_role_initializer' + in cassandra.yaml. The new MutualTlsDefaultRoleInitializer creates the role + passwordless and maps a client certificate identity onto it instead, for + deployments that authenticate over mutual TLS. PasswordDefaultRoleInitializer + remains the default and produces the historical behaviour. See CASSANDRA-21546 + for more information. Upgrading --------- diff --git a/doc/modules/cassandra/pages/managing/operating/security.adoc b/doc/modules/cassandra/pages/managing/operating/security.adoc index 8abf888296c0..3f8031600dba 100644 --- a/doc/modules/cassandra/pages/managing/operating/security.adoc +++ b/doc/modules/cassandra/pages/managing/operating/security.adoc @@ -216,6 +216,49 @@ information in the tables of the `system_auth` keyspace. See also the xref:cassandra:developing/cql/security.adoc#database-roles[`CQL documentation on roles`]. +[[default-role-initialization]] +=== Bootstrapping the default role + +`IRoleManager` requires an authenticated login, so a freshly started cluster +with no roles yet has no way to create the first one. This bootstrap step is +pluggable and is configured by using `default_role_initializer` setting in +`cassandra.yaml`. + +By default, Cassandra uses `PasswordDefaultRoleInitializer`, which creates a +`cassandra` superuser whose password is also `cassandra` - reproducing +Cassandra's historical bootstrap behaviour. Because that password is published, +well-known constant, any deployment relying on this default must rotate or disable +the `cassandra` role before the native transport is reachable by anyone other than +the operator performing this setup. + +[source,yaml] +---- +default_role_initializer: +class_name: MutualTlsDefaultRoleInitializer +parameters: + role: cassandra + identity: "spiffe://example.org/cassandra-admin" +---- + +This requires an authenticator that supports mutual TLS, such as a `MutualTlsAuthenticator` +or `MutualTlsWithPasswordFallbackAuthenticator` - Cassandra refuses to start otherwise, +since the role it would create could never be logged into. + +`PasswordDefaultRoleInitializer` also accepts `role`, `password` and `password_hash` +parameters, to choose a different default role name or avoid the known default password +entirely. + +[source,yaml] +---- +default_role_initializer: +class_name: PasswordDefaultRoleInitializer +parameters: + role: cassandra + password_hash: "$2a$04$wsvzFamDJPDrTwMjgfcgpO.mKc.CMEuHBFZSjhGz2Ts6.v8PUO2rC" +---- + +See `conf/cassandra.yaml` for full list of supported parameters. + == Authentication Authentication is pluggable in Cassandra and is configured using the