From d336dda1123af0c272c69e42b6214577e30447e1 Mon Sep 17 00:00:00 2001
From: Stefan Miklosovic
Date: Mon, 10 Jun 2024 17:28:39 +0200
Subject: [PATCH] CEP-24 Password validation / generation
patch by Stefan Miklosovic; reviewed by Dinesh Joshi, Francisco Guerrero for CASSANDRA-17457
---
.build/cassandra-deps-template.xml | 4 +
.build/parent-pom-template.xml | 5 +
CHANGES.txt | 1 +
NEWS.txt | 4 +
README.asc | 2 +-
conf/cassandra.yaml | 61 +++
doc/cql3/CQL.textile | 38 +-
.../pages/developing/cql/changes.adoc | 1 +
pylib/cqlshlib/cql3handling.py | 5 +-
pylib/cqlshlib/cqlshmain.py | 10 +-
pylib/cqlshlib/test/test_cqlsh_completion.py | 7 +-
src/antlr/Lexer.g | 1 +
src/antlr/Parser.g | 35 ++
.../cassandra/audit/AuditLogManager.java | 5 +
.../cassandra/auth/CassandraRoleManager.java | 4 +-
.../apache/cassandra/auth/IRoleManager.java | 2 +-
.../apache/cassandra/auth/RoleOptions.java | 13 +
.../config/CassandraRelevantProperties.java | 4 +
.../org/apache/cassandra/config/Config.java | 4 +
.../cassandra/config/DatabaseDescriptor.java | 5 +
.../cassandra/config/GuardrailsOptions.java | 27 +-
.../cql3/statements/AlterRoleStatement.java | 31 +-
.../statements/AuthenticationStatement.java | 32 ++
.../cql3/statements/CreateRoleStatement.java | 32 +-
.../CassandraPasswordConfiguration.java | 269 +++++++++++
.../CassandraPasswordGenerator.java | 100 ++++
.../CassandraPasswordValidator.java | 439 ++++++++++++++++++
.../db/guardrails/CustomGuardrail.java | 186 ++++++++
.../db/guardrails/CustomGuardrailConfig.java | 103 ++++
.../cassandra/db/guardrails/Guardrail.java | 2 +-
.../cassandra/db/guardrails/Guardrails.java | 22 +-
.../db/guardrails/GuardrailsConfig.java | 5 +
.../db/guardrails/GuardrailsMBean.java | 13 +
.../db/guardrails/NoOpGenerator.java | 63 +++
.../db/guardrails/NoOpValidator.java | 63 +++
.../db/guardrails/PasswordGuardrail.java | 99 ++++
.../db/guardrails/ValueGenerator.java | 133 ++++++
.../db/guardrails/ValueValidator.java | 155 +++++++
.../guardrails/GuardrailPasswordTest.java | 97 ++++
.../cassandra/auth/RoleOptionsTest.java | 10 +
.../config/DatabaseDescriptorRefTest.java | 1 +
.../org/apache/cassandra/cql3/CQLTester.java | 6 +
.../CassandraPasswordGeneratorTest.java | 135 ++++++
.../CassandraPasswordValidatorTest.java | 369 +++++++++++++++
.../db/guardrails/GuardrailPasswordTest.java | 265 +++++++++++
.../db/guardrails/GuardrailTester.java | 68 ++-
.../db/guardrails/ValueGeneratorTest.java | 110 +++++
.../db/guardrails/ValueValidatorTest.java | 122 +++++
48 files changed, 3127 insertions(+), 41 deletions(-)
create mode 100644 src/java/org/apache/cassandra/db/guardrails/CassandraPasswordConfiguration.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/CassandraPasswordGenerator.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/CassandraPasswordValidator.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/CustomGuardrail.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/CustomGuardrailConfig.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/NoOpGenerator.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/NoOpValidator.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/PasswordGuardrail.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/ValueGenerator.java
create mode 100644 src/java/org/apache/cassandra/db/guardrails/ValueValidator.java
create mode 100644 test/distributed/org/apache/cassandra/distributed/test/guardrails/GuardrailPasswordTest.java
create mode 100644 test/unit/org/apache/cassandra/db/guardrails/CassandraPasswordGeneratorTest.java
create mode 100644 test/unit/org/apache/cassandra/db/guardrails/CassandraPasswordValidatorTest.java
create mode 100644 test/unit/org/apache/cassandra/db/guardrails/GuardrailPasswordTest.java
create mode 100644 test/unit/org/apache/cassandra/db/guardrails/ValueGeneratorTest.java
create mode 100644 test/unit/org/apache/cassandra/db/guardrails/ValueValidatorTest.java
diff --git a/.build/cassandra-deps-template.xml b/.build/cassandra-deps-template.xml
index f0b0a5defc..a7c27ee126 100644
--- a/.build/cassandra-deps-template.xml
+++ b/.build/cassandra-deps-template.xml
@@ -376,5 +376,9 @@
com.vdurmontsemver4j
+
+ org.passay
+ passay
+
diff --git a/.build/parent-pom-template.xml b/.build/parent-pom-template.xml
index 455a0adac8..fd503030e0 100644
--- a/.build/parent-pom-template.xml
+++ b/.build/parent-pom-template.xml
@@ -1252,6 +1252,11 @@
semver4j3.1.0
+
+ org.passay
+ passay
+ 1.6.4
+
diff --git a/CHANGES.txt b/CHANGES.txt
index 0011fbd995..e56329be91 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
5.1
+ * CEP-24 Password validation / generation (CASSANDRA-17457)
* Reconfigure CMS after replacement, bootstrap and move operations (CASSANDRA-19705)
* Support querying LocalStrategy tables with any partitioner (CASSANDRA-19692)
* Relax slow_query_log_timeout for MultiNodeSAITest (CASSANDRA-19693)
diff --git a/NEWS.txt b/NEWS.txt
index 9968918d8f..202290c574 100644
--- a/NEWS.txt
+++ b/NEWS.txt
@@ -84,6 +84,10 @@ New features
- Authentication mode is exposed in system_views.clients table, nodetool clientstats and ClientMetrics
to help operators identify which authentication modes are being used. nodetool clientstats introduces --verbose flag
behind which this information is visible.
+ - CEP-24 - Password validation / generation. When built-in 'password_validator' guardrail is enabled, it will
+ generate a password of configured password strength policy upon role creation or alteration
+ when 'GENERATED PASSWORD' clause is used. Character sets supported are: English, Cyrillic, modern Cyrillic,
+ German, Polish and Czech.
Upgrading
diff --git a/README.asc b/README.asc
index cd12f6537e..8493407054 100644
--- a/README.asc
+++ b/README.asc
@@ -42,7 +42,7 @@ be sitting in front of a prompt:
----
Connected to Test Cluster at localhost:9160.
-[cqlsh 6.3.0 | Cassandra 5.0-SNAPSHOT | CQL spec 3.4.7 | Native protocol v5]
+[cqlsh 6.3.0 | Cassandra 5.0-SNAPSHOT | CQL spec 3.4.8 | Native protocol v5]
Use HELP for help.
cqlsh>
----
diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml
index 9a4a41fbc5..e49e9db172 100644
--- a/conf/cassandra.yaml
+++ b/conf/cassandra.yaml
@@ -2148,6 +2148,67 @@ drop_compact_storage_enabled: false
# This would also apply to system keyspaces.
# maximum_replication_factor_warn_threshold: -1
# maximum_replication_factor_fail_threshold: -1
+#
+# Guardrail to warn or fail when setting / altering a password.
+# Supported character sets are (both upper and lower-case): English, Cyrillic and modern Cyrillic, Czech, German, Polish.
+# Password is invalid if all characters are from non-supported character set. If a password is otherwise valid,
+# but it contains characters from unsupported language, these characters contribute only to password length rule.
+# All digits and all following special characters are supported too: !"#$%&()*+,-./:;<=>?@[\]^_`{|}~
+#password_validator:
+# # Implementation class of a validator. When not in form of FQCN, the
+# # package name org.apache.cassandra.db.guardrails.validators is prepended.
+# # By default, there is no validator.
+# class_name: CassandraPasswordValidator
+# # Implementation class of related generator which generates values which are valid when
+# # tested against this validator. When not in form of FQCN, the
+# # package name org.apache.cassandra.db.guardrails.generators is prepended.
+# # By default, there is no generator.
+# generator_class_name: CassandraPasswordGenerator
+# # There are four characteristics:
+# # upper-case, lower-case, special character and digit.
+# # If this value is set e.g. to 3, a password has to consist of 3 out of 4 characteristics.
+# # For example, it has to contain at least 2 upper-case characters, 2 lower-case, and 2 digits to pass,
+# # but it does not have to contain any special characters.
+# # If number of characteristics found in the password is less than or equal to this number, it will emit warning.
+# characteristic_warn: 3
+# # If number of characteristics found in the password is less than or equal to this number, it will emit failure.
+# characteristic_fail: 2
+# # Maximum length of a password. Defaults to 1000.
+# max_length: 1000
+# # If password is shorter than this value, the validator will emit a warning.
+# length_warn: 12
+# # If a password is shorter than this value, the validator will emit a failure.
+# length_fail: 8
+# # If a password does not contain at least n upper-case characters, the validator will emit a warning.
+# upper_case_warn: 2
+# # If a password does not contain at least n upper-case characters, the validator will emit a failure.
+# upper_case_fail: 1
+# # If a password does not contain at least n lower-case characters, the validator will emit a warning.
+# lower_case_warn: 2
+# # If a password does not contain at least n lower-case characters, the validator will emit a failure.
+# lower_case_fail: 1
+# # If a password does not contain at least n digits, the validator will emit a warning.
+# digit_warn: 2
+# # If a password does not contain at least n digits, the validator will emit a failure.
+# digit_fail: 1
+# # If a password does not contain at least n special characters, the validator will emit a warning.
+# special_warn: 2
+# # If a password does not contain at least n special characters, the validator will emit a failure.
+# special_fail: 1
+# # If a password contain illegal sequences that at least this long, it is invalid.
+# # Illegal sequences might be either alphabetical (form 'abcde'),
+# # numerical (form '34567'), or US qwerty (form 'asdfg') as well as sequencies from supported character sets.
+# # The minimum value for this property is 3, by default it is set to 5.
+# illegal_sequence_length: 5
+# # If set to true, a user will be informed what policies a suggested password is missing in order to be valid.
+# # Defaults to true.
+# detailed_messages: true
+
+# If this is set to false, then it is not possible to call reconfiguration
+# method in GuardrailsMBean. It will effectively forbid the reconfiguration of password validator in runtime.
+# You would need to stop the node, change the configuration in cassandra.yaml and start the node again.
+# Defaults to true, which means that reconfiguration of password validator via JMX is possible.
+# password_validator_reconfiguration_enabled: true
# Guardrail to enable a CREATE or ALTER TABLE statement when default_time_to_live is set to 0
# and the table is using TimeWindowCompactionStrategy compaction or a subclass of it.
diff --git a/doc/cql3/CQL.textile b/doc/cql3/CQL.textile
index c85fef5ab5..d60b4d476f 100644
--- a/doc/cql3/CQL.textile
+++ b/doc/cql3/CQL.textile
@@ -18,7 +18,7 @@
#
-->
-h1. Cassandra Query Language (CQL) v3.4.7
+h1. Cassandra Query Language (CQL) v3.4.8
@@ -1280,6 +1280,7 @@ bc(syntax)..
::= CREATE ROLE ( IF NOT EXISTS )? ( WITH
+ * Passwords consisting of all characters belonging to unsupported character sets will be rejected and such
+ * passwords will not be validated. For example, this password will be rejected: {@code 卡桑德拉-卡桑德拉山山羊棚}.
+ *
+ * The password validator acts on two levels when used in a guardrail, executed in this order:
+ *
+ *
tests if a password does not violate a failure threshold, if it does, failure is emitted and query failed
+ *
tests if a password does not violate a warning threshold, if it does, warning is emitted
+ *
+ *
+ *
+ * @see CharacterCharacteristicsRule
+ * @see CassandraPasswordConfiguration
+ * @see WhitespaceRule
+ * @see LengthRule
+ * @see IllegalSequenceRule
+ * @see ValueValidator#shouldWarn(Object, boolean)
+ * @see ValueValidator#shouldFail(Object, boolean)
+ */
+public class CassandraPasswordValidator extends ValueValidator
+{
+ protected final PasswordValidator warnValidator;
+ protected final PasswordValidator failValidator;
+
+ protected final CassandraPasswordConfiguration configuration;
+ private final UnsupportedCharsetRule unsupportedCharsetRule = new UnsupportedCharsetRule();
+ private final boolean provideDetailedMessages;
+
+ public CassandraPasswordValidator(CustomGuardrailConfig config)
+ {
+ super(config);
+ configuration = new CassandraPasswordConfiguration(config);
+ provideDetailedMessages = configuration.detailedMessages;
+
+ warnValidator = new PasswordValidator(getRules(configuration.lengthWarn,
+ configuration.maxLength,
+ configuration.characteristicsWarn,
+ configuration.illegalSequenceLength,
+ getCharacterValidationRules(configuration.upperCaseWarn,
+ configuration.lowerCaseWarn,
+ configuration.digitsWarn,
+ configuration.specialsWarn)));
+ failValidator = new PasswordValidator(getRules(configuration.lengthFail,
+ configuration.maxLength,
+ configuration.characteristicsFail,
+ configuration.illegalSequenceLength,
+ getCharacterValidationRules(configuration.upperCaseFail,
+ configuration.lowerCaseFail,
+ configuration.digitsFail,
+ configuration.specialsFail)));
+ }
+
+ @Nonnull
+ @Override
+ public CustomGuardrailConfig getParameters()
+ {
+ return configuration.asCustomGuardrailConfig();
+ }
+
+ @Override
+ public Optional shouldWarn(String password, boolean calledBySuperuser)
+ {
+ return executeValidation(warnValidator, password, calledBySuperuser, true);
+ }
+
+ @Override
+ public Optional shouldFail(String password, boolean calledBySuperUser)
+ {
+ return executeValidation(failValidator, password, calledBySuperUser, false);
+ }
+
+ private Optional executeValidation(PasswordValidator validator,
+ String passwordToValidate,
+ boolean calledBySuperUser,
+ boolean toWarn)
+ {
+ PasswordData passwordData = new PasswordData(passwordToValidate);
+ if (!unsupportedCharsetRule.validate(passwordData).isValid())
+ {
+ String message = (calledBySuperUser || provideDetailedMessages) ? "Unsupported language / character set for password validator"
+ : "Password complexity policy not met.";
+ return Optional.of(new ValidationViolation(message, UnsupportedCharsetRule.ERROR_CODE));
+ }
+ else
+ {
+ RuleResult result = validator.validate(passwordData);
+ return result.isValid() ? empty() : Optional.of(getValidationMessage(calledBySuperUser, validator, toWarn, result));
+ }
+ }
+
+ @Override
+ public void validateParameters() throws ConfigurationException
+ {
+ configuration.validateParameters();
+ }
+
+ protected List getCharacterValidationRules(int upper, int lower, int digits, int special)
+ {
+ return Arrays.asList(new CharacterRule(new CustomUpperCaseCharacterData(), upper),
+ new CharacterRule(new CustomLowerCaseCharacterData(), lower),
+ new CharacterRule(Digit, digits),
+ new CharacterRule(specialCharacters, special));
+ }
+
+ protected List getRules(int minLength,
+ int maxLength,
+ int characteristics,
+ int illegalSequenceLength,
+ List characterRules)
+ {
+ List rules = new ArrayList<>();
+
+ rules.add(new LengthRule(minLength, maxLength));
+
+ CharacterCharacteristicsRule characteristicsRule = new CharacterCharacteristicsRule();
+ characteristicsRule.setNumberOfCharacteristics(characteristics + 1);
+ characteristicsRule.getRules().addAll(characterRules);
+ rules.add(characteristicsRule);
+
+ rules.add(new WhitespaceRule());
+
+ for (SequenceData sequenceData : getSequenceData())
+ rules.add(new IllegalSequenceRule(sequenceData, illegalSequenceLength, false));
+
+ return rules;
+ }
+
+ private ValidationViolation getValidationMessage(boolean calledBySuperuser,
+ PasswordValidator validator,
+ boolean toWarn,
+ RuleResult result)
+ {
+ Set errorCodes = new HashSet<>();
+ for (RuleResultDetail ruleResultDetail : result.getDetails())
+ errorCodes.add(ruleResultDetail.getErrorCode());
+
+ String redactedMessage = errorCodes.toString();
+
+ if (calledBySuperuser || provideDetailedMessages)
+ {
+ String type = toWarn ? "warning" : "error";
+ StringBuilder sb = new StringBuilder();
+ sb.append("Password was")
+ .append(toWarn ? " set, however it might not be strong enough according to the " +
+ "configured password strength policy. "
+ : " not set as it violated configured password strength policy. ")
+ .append("To fix this ")
+ .append(type)
+ .append(", the following has to be resolved: ");
+
+ for (String message : validator.getMessages(result))
+ sb.append(message).append(' ');
+
+ sb.append("You may also use 'GENERATED PASSWORD' upon role creation or alteration.");
+
+ String message = sb.toString();
+
+ return new ValidationViolation(message, redactedMessage);
+ }
+ else
+ {
+ if (toWarn)
+ {
+ return new ValidationViolation("Password was set, however it might not be strong enough " +
+ "according to the configured password strength policy.",
+ redactedMessage);
+ }
+ else
+ {
+ return new ValidationViolation("Password was not set as it violated configured password " +
+ "strength policy. You may also use 'GENERATED PASSWORD' upon role " +
+ "creation or alteration.",
+ redactedMessage);
+ }
+ }
+ }
+
+ protected static class CustomLowerCaseCharacterData implements CharacterData
+ {
+ @Override
+ public String getErrorCode()
+ {
+ return "INSUFFICIENT_LOWERCASE";
+ }
+
+ @Override
+ public String getCharacters()
+ {
+ return EnglishCharacterData.LowerCase.getCharacters() +
+ CyrillicCharacterData.LowerCase.getCharacters() +
+ CyrillicModernCharacterData.LowerCase.getCharacters() +
+ CzechCharacterData.LowerCase.getCharacters() +
+ GermanCharacterData.LowerCase.getCharacters() +
+ PolishCharacterData.LowerCase.getCharacters();
+ }
+ }
+
+ protected static class CustomUpperCaseCharacterData implements CharacterData
+ {
+ @Override
+ public String getErrorCode()
+ {
+ return "INSUFFICIENT_UPPERCASE";
+ }
+
+ @Override
+ public String getCharacters()
+ {
+ return EnglishCharacterData.UpperCase.getCharacters() +
+ CyrillicCharacterData.UpperCase.getCharacters() +
+ CyrillicModernCharacterData.UpperCase.getCharacters() +
+ CzechCharacterData.UpperCase.getCharacters() +
+ GermanCharacterData.UpperCase.getCharacters() +
+ PolishCharacterData.UpperCase.getCharacters();
+ }
+ }
+
+ protected static final CharacterData specialCharacters = new CharacterData()
+ {
+ @Override
+ public String getErrorCode()
+ {
+ return "INSUFFICIENT_SPECIAL";
+ }
+
+ @Override
+ public String getCharacters()
+ {
+ return "!\"#$%&()*+,-./:;<=>?@[\\]^_`{|}~";
+ }
+ };
+
+ protected List getSequenceData()
+ {
+ return List.of(EnglishSequenceData.Alphabetical,
+ EnglishSequenceData.Numerical,
+ EnglishSequenceData.USQwerty,
+ CyrillicSequenceData.Alphabetical,
+ CyrillicModernSequenceData.Alphabetical,
+ CzechSequenceData.Alphabetical,
+ GermanSequenceData.Alphabetical,
+ PolishSequenceData.Alphabetical);
+ }
+
+ public static class UnsupportedCharsetRule implements Rule
+ {
+ private static final char[] supportedAlphabeticChars = UnsupportedCharsetRule.supportedChars(true);
+ private static final char[] allSupportedCharsChars = UnsupportedCharsetRule.supportedChars(false);
+
+ public static final String ERROR_CODE = "UNSUPPORTED_CHARSET";
+
+ @Override
+ public RuleResult validate(final PasswordData passwordData)
+ {
+ final String text = passwordData.getPassword();
+
+ final RuleResult result = new RuleResult();
+ if (text.isEmpty())
+ return result;
+
+ int unsupportedChars = 0;
+ int supportedNonAlphabeticChars = 0;
+ for (char c : text.toCharArray())
+ {
+ if (Arrays.binarySearch(allSupportedCharsChars, c) < 0)
+ unsupportedChars++;
+ else if (Arrays.binarySearch(supportedAlphabeticChars, c) < 0)
+ supportedNonAlphabeticChars++;
+ }
+
+ if (unsupportedChars > 0)
+ {
+ if (unsupportedChars + supportedNonAlphabeticChars == text.length())
+ {
+ result.setValid(false);
+ result.addError(ERROR_CODE, Map.of());
+ }
+
+ result.setMetadata(new RuleResultMetadata(RuleResultMetadata.CountCategory.Illegal, unsupportedChars));
+ }
+
+ return result;
+ }
+
+ private static char[] supportedChars(boolean onlyAlphabetic)
+ {
+ char[] charArray = getChars(onlyAlphabetic);
+
+ Set charactersSet = new HashSet<>();
+
+ // filter out the duplicates
+ for (int i = 0; i < charArray.length; i++)
+ charactersSet.add(charArray[i]);
+
+ char[] result = new char[charactersSet.size()];
+
+ Iterator characterIterator = charactersSet.iterator();
+ int i = 0;
+ while (characterIterator.hasNext())
+ result[i++] = characterIterator.next();
+
+ Arrays.sort(result); // important for binary search
+ return result;
+ }
+
+ private static char[] getChars(boolean onlyAlphabetic)
+ {
+ if (onlyAlphabetic)
+ return (new CassandraPasswordValidator.CustomUpperCaseCharacterData().getCharacters() +
+ new CassandraPasswordValidator.CustomLowerCaseCharacterData().getCharacters()).toCharArray();
+ else
+ return (new CassandraPasswordValidator.CustomUpperCaseCharacterData().getCharacters() +
+ new CassandraPasswordValidator.CustomLowerCaseCharacterData().getCharacters() +
+ CassandraPasswordValidator.specialCharacters.getCharacters() +
+ "0123456789").toCharArray();
+ }
+ }
+}
diff --git a/src/java/org/apache/cassandra/db/guardrails/CustomGuardrail.java b/src/java/org/apache/cassandra/db/guardrails/CustomGuardrail.java
new file mode 100644
index 0000000000..9e82172c50
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/guardrails/CustomGuardrail.java
@@ -0,0 +1,186 @@
+/*
+ * 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.db.guardrails;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Supplier;
+import javax.annotation.Nullable;
+
+import org.apache.cassandra.db.guardrails.ValueValidator.ValidationViolation;
+import org.apache.cassandra.exceptions.ConfigurationException;
+import org.apache.cassandra.service.ClientState;
+
+/**
+ * Custom guardrail represents a way how to validate arbitrary values. Values are validated by an instance of
+ * a {@link ValueValidator}. A validator is instantiated upon node's start. If {@link Guardrails} enables it,
+ * it is possible to reconfigure a custom guardrail via JMX. JMX reconfiguration
+ * mechanism has to eventually call {@link CustomGuardrail#reconfigure(Map)} to achieve that.
+ *
+ * Some custom guardrails are not meant to be reconfigurable in runtime. In that case, {@link Guardrails} should not
+ * provide any way to do so.
+ *
+ * @param type of the value a validator for this guardrail validates.
+ */
+public class CustomGuardrail extends Guardrail
+{
+ private volatile Holder holder;
+ protected final Supplier configSupplier;
+ private final boolean guardWhileSuperuser;
+
+ /**
+ * @param name name of the custom guardrail
+ * @param configSupplier configuration supplier of the custom guardrail
+ * @param guardWhileSuperuser when true, the guardrail will be executed even the caller is a superuser. If
+ * false, this guardrail will be called only in case a caller is not a superuser.
+ */
+ public CustomGuardrail(String name,
+ String reason,
+ Supplier configSupplier,
+ boolean guardWhileSuperuser)
+ {
+ super(name, reason);
+
+ this.configSupplier = configSupplier;
+ this.guardWhileSuperuser = guardWhileSuperuser;
+ }
+
+ public ValueValidator getValidator()
+ {
+ maybeInitialize();
+ return holder.validator;
+ }
+
+ public ValueGenerator getGenerator()
+ {
+ maybeInitialize();
+ return holder.generator;
+ }
+
+ @Override
+ public boolean enabled(@Nullable ClientState state)
+ {
+ return guardWhileSuperuser ? super.enabled(null) : super.enabled(state);
+ }
+
+ /**
+ * @param value value to validate by the validator of this guardrail
+ * @param state client's state
+ */
+ public void guard(VALUE value, ClientState state)
+ {
+ if (!enabled(state))
+ return;
+
+ ValueValidator currentValidator = getValidator();
+ boolean calledBySuperuser = isCalledBySuperuser(state);
+ Optional maybeViolation = currentValidator.shouldFail(value, calledBySuperuser);
+
+ if (maybeViolation.isPresent())
+ fail(maybeViolation.get().message,
+ maybeViolation.get().redactedMessage,
+ state);
+ else
+ currentValidator.shouldWarn(value, calledBySuperuser).ifPresent(result -> warn(result.message, result.redactedMessage));
+ }
+
+ private boolean isCalledBySuperuser(ClientState clientState)
+ {
+ return clientState != null && clientState.getUser() != null && clientState.getUser().isSuper();
+ }
+
+ /**
+ * @return unmodifiable view of the configuration parameters of underlying value validator.
+ */
+ public CustomGuardrailConfig getConfig()
+ {
+ return getValidator().getParameters();
+ }
+
+ /**
+ * Generates a value of given size.
+ *
+ * @param size size of value to be generated
+ * @return generated value of given size
+ */
+ public VALUE generate(int size)
+ {
+ return getGenerator().generate(size, getValidator());
+ }
+
+ /**
+ * Generates a valid value.
+ *
+ * @return generated and valid value
+ */
+ public VALUE generate()
+ {
+ return getGenerator().generate(getValidator());
+ }
+
+ /**
+ * Reconfigures this custom guardrail. After the successful finish of this method, every
+ * new call to this guardrail will use new configuration for its validator.
+ *
+ * New configuration is merged into the old one. Values for the keys in the old configuration
+ * are replaced by the values of the same key in the new configuration.
+ *
+ *
+ * @param newConfig if null or the configuration is an empty map, no reconfiguration happens.
+ * @throws ConfigurationException when new validator can not replace the old one or when it is not possible
+ * to instantiate new validator or generator.
+ */
+ void reconfigure(@Nullable Map newConfig)
+ {
+ Map mergedMap = new HashMap<>(getValidator().getParameters());
+
+ if (newConfig != null)
+ mergedMap.putAll(newConfig);
+
+ CustomGuardrailConfig config = new CustomGuardrailConfig(mergedMap);
+
+ ValueValidator newValidator = ValueValidator.getValidator(name, config);
+ ValueGenerator newGenerator = ValueGenerator.getGenerator(name, config);
+
+ holder = new Holder<>(newValidator, newGenerator);
+
+ logger.info("Reconfigured validator {} for guardrail '{}' with parameters {}", holder.validator.getClass(), name, holder.validator.getParameters());
+ logger.info("Reconfigured generator {} for guardrail '{}' with parameters {}", holder.generator.getClass(), name, holder.generator.getParameters());
+ }
+
+ private void maybeInitialize()
+ {
+ if (holder == null)
+ holder = new Holder<>(ValueValidator.getValidator(name, configSupplier.get()),
+ ValueGenerator.getGenerator(name, configSupplier.get()));
+ }
+
+ private static class Holder
+ {
+ final ValueValidator validator;
+ final ValueGenerator generator;
+
+ public Holder(ValueValidator validator, ValueGenerator generator)
+ {
+ this.validator = validator;
+ this.generator = generator;
+ }
+ }
+}
diff --git a/src/java/org/apache/cassandra/db/guardrails/CustomGuardrailConfig.java b/src/java/org/apache/cassandra/db/guardrails/CustomGuardrailConfig.java
new file mode 100644
index 0000000000..c24dbd36c8
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/guardrails/CustomGuardrailConfig.java
@@ -0,0 +1,103 @@
+/*
+ * 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.db.guardrails;
+
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static java.lang.String.format;
+
+public class CustomGuardrailConfig extends HashMap
+{
+ private static final Logger logger = LoggerFactory.getLogger(CustomGuardrailConfig.class);
+
+ public CustomGuardrailConfig()
+ {
+ // for snakeyaml
+ }
+
+ @SuppressWarnings("unchecked")
+ public CustomGuardrailConfig(Map p)
+ {
+ super(p);
+ }
+
+ public String resolveString(@Nullable String key)
+ {
+ return resolveString(key, null);
+ }
+
+ public String resolveString(@Nullable String key, String defaultValue)
+ {
+ if (key == null)
+ return defaultValue;
+
+ Object resolvedString = getOrDefault(key, defaultValue);
+
+ if (resolvedString == null)
+ return null;
+ if (resolvedString instanceof String)
+ return (String) resolvedString;
+
+ return resolvedString.toString();
+ }
+
+ public int resolveInteger(@Nullable String key, Integer defaultValue)
+ {
+ if (key == null)
+ return defaultValue;
+
+ Object resolvedValue = getOrDefault(key, defaultValue.toString());
+
+ try
+ {
+ if (resolvedValue instanceof Integer)
+ return (Integer) resolvedValue;
+ if (resolvedValue instanceof String)
+ return Integer.parseInt((String) resolvedValue);
+
+ throw new IllegalStateException();
+ }
+ catch (IllegalStateException | NumberFormatException ex)
+ {
+ logger.warn(format("Unable to parse value %s of key %s. Value has to be integer. " +
+ "The default of value %s will be used.",
+ resolvedValue, key, defaultValue));
+ }
+ return defaultValue;
+ }
+
+ public boolean resolveBoolean(@Nullable String key, boolean defaultValue)
+ {
+ Object value = get(key);
+
+ if (value == null)
+ return defaultValue;
+ if (value instanceof Boolean)
+ return (boolean) value;
+ if (value instanceof String)
+ return Boolean.parseBoolean((String) value);
+
+ return defaultValue;
+ }
+}
diff --git a/src/java/org/apache/cassandra/db/guardrails/Guardrail.java b/src/java/org/apache/cassandra/db/guardrails/Guardrail.java
index b9d21a412d..a2d7d76a87 100644
--- a/src/java/org/apache/cassandra/db/guardrails/Guardrail.java
+++ b/src/java/org/apache/cassandra/db/guardrails/Guardrail.java
@@ -65,7 +65,7 @@ public abstract class Guardrail
private volatile long lastFailInMs = 0;
/** Should throw exception if null client state is provided. */
- private volatile boolean throwOnNullClientState = false;
+ protected volatile boolean throwOnNullClientState = false;
Guardrail(String name, @Nullable String reason)
{
diff --git a/src/java/org/apache/cassandra/db/guardrails/Guardrails.java b/src/java/org/apache/cassandra/db/guardrails/Guardrails.java
index 31532abb47..bc64b4bef7 100644
--- a/src/java/org/apache/cassandra/db/guardrails/Guardrails.java
+++ b/src/java/org/apache/cassandra/db/guardrails/Guardrails.java
@@ -19,6 +19,7 @@
package org.apache.cassandra.db.guardrails;
import java.util.Collections;
+import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -450,6 +451,11 @@ public final class Guardrails implements GuardrailsMBean
(isWarning, value) ->
isWarning ? "Replica disk usage exceeds warning threshold"
: "Write request failed because disk usage exceeds failure threshold");
+ /**
+ * Guardrail on passwords for CREATE / ALTER ROLE statements.
+ */
+ public static final PasswordGuardrail password =
+ new PasswordGuardrail(() -> CONFIG_PROVIDER.getOrCreate(null).getPasswordValidatorConfig());
static
{
@@ -489,8 +495,8 @@ public final class Guardrails implements GuardrailsMBean
state -> maximumTimestampAsRelativeMicros(CONFIG_PROVIDER.getOrCreate(state).getMaximumTimestampWarnThreshold()),
state -> maximumTimestampAsRelativeMicros(CONFIG_PROVIDER.getOrCreate(state).getMaximumTimestampFailThreshold()),
(isWarning, what, value, threshold) ->
- format("The modification to table %s has a timestamp %s after the maximum allowable %s threshold %s",
- what, value, isWarning ? "warning" : "failure", threshold));
+ format("The modification to table %s has a timestamp %s after the maximum allowable %s threshold %s",
+ what, value, isWarning ? "warning" : "failure", threshold));
public static final MinThreshold minimumAllowableTimestamp =
new MinThreshold("minimum_timestamp",
@@ -1178,6 +1184,18 @@ public final class Guardrails implements GuardrailsMBean
DEFAULT_CONFIG.setMaximumReplicationFactorThreshold(warn, fail);
}
+ @Override
+ public Map getPasswordValidatorConfig()
+ {
+ return password.getConfig();
+ }
+
+ @Override
+ public void reconfigurePasswordValidator(Map config)
+ {
+ password.reconfigure(config);
+ }
+
@Override
public int getDataDiskUsagePercentageWarnThreshold()
{
diff --git a/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java b/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java
index 8309971c5b..24628aab38 100644
--- a/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java
+++ b/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java
@@ -540,4 +540,9 @@ public interface GuardrailsConfig
* @param enabled {@code true} if a query without partition key is enabled or not
*/
void setNonPartitionRestrictedQueryEnabled(boolean enabled);
+
+ /**
+ * @return configuration for password validation guardrail.
+ */
+ CustomGuardrailConfig getPasswordValidatorConfig();
}
diff --git a/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java b/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java
index 68dd9e1e3c..59fa4c908a 100644
--- a/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java
+++ b/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java
@@ -18,6 +18,7 @@
package org.apache.cassandra.db.guardrails;
+import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
@@ -904,4 +905,16 @@ public interface GuardrailsMBean
boolean getIntersectFilteringQueryEnabled();
void setIntersectFilteringQueryEnabled(boolean value);
+
+ /**
+ * @return the configuration of password validator.
+ */
+ Map getPasswordValidatorConfig();
+
+ /**
+ * Reconfigures password validator.
+ *
+ * @param config configuration of new password validator
+ */
+ void reconfigurePasswordValidator(Map config);
}
diff --git a/src/java/org/apache/cassandra/db/guardrails/NoOpGenerator.java b/src/java/org/apache/cassandra/db/guardrails/NoOpGenerator.java
new file mode 100644
index 0000000000..a9c2b3ad9b
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/guardrails/NoOpGenerator.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.db.guardrails;
+
+import javax.annotation.Nonnull;
+
+import org.apache.cassandra.exceptions.ConfigurationException;
+
+/**
+ * Generator which does not generate any value, it has the empty configuration which is valid.
+ * Generators are meant to generate such values which would pass when tested against respective validators.
+ */
+public class NoOpGenerator extends ValueGenerator
+{
+ private static final CustomGuardrailConfig config = new CustomGuardrailConfig();
+
+ public static NoOpGenerator INSTANCE = new NoOpGenerator<>(config);
+
+ public NoOpGenerator(CustomGuardrailConfig unused)
+ {
+ super(config);
+ }
+
+ @Override
+ public VALUE generate(int size, ValueValidator validator)
+ {
+ return null;
+ }
+
+ @Override
+ public VALUE generate(ValueValidator validator)
+ {
+ return null;
+ }
+
+ @Nonnull
+ @Override
+ public CustomGuardrailConfig getParameters()
+ {
+ return config;
+ }
+
+ @Override
+ public void validateParameters() throws ConfigurationException
+ {
+ }
+}
diff --git a/src/java/org/apache/cassandra/db/guardrails/NoOpValidator.java b/src/java/org/apache/cassandra/db/guardrails/NoOpValidator.java
new file mode 100644
index 0000000000..2e01edfbe3
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/guardrails/NoOpValidator.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.db.guardrails;
+
+import java.util.Optional;
+
+import javax.annotation.Nonnull;
+
+import org.apache.cassandra.exceptions.ConfigurationException;
+
+/**
+ * Validator which does nothing when it validates a value. It never fails nor warns, it
+ * has the empty configuration which is valid.
+ */
+public class NoOpValidator extends ValueValidator
+{
+ private static final CustomGuardrailConfig config = new CustomGuardrailConfig();
+
+ public NoOpValidator(CustomGuardrailConfig unused)
+ {
+ super(NoOpValidator.config);
+ }
+
+ @Override
+ public Optional shouldWarn(T value, boolean calledBySuperuser)
+ {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional shouldFail(T value, boolean calledBySuperUser)
+ {
+ return Optional.empty();
+ }
+
+ @Nonnull
+ @Override
+ public CustomGuardrailConfig getParameters()
+ {
+ return config;
+ }
+
+ @Override
+ public void validateParameters() throws ConfigurationException
+ {
+ }
+}
diff --git a/src/java/org/apache/cassandra/db/guardrails/PasswordGuardrail.java b/src/java/org/apache/cassandra/db/guardrails/PasswordGuardrail.java
new file mode 100644
index 0000000000..0199cf62fa
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/guardrails/PasswordGuardrail.java
@@ -0,0 +1,99 @@
+/*
+ * 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.db.guardrails;
+
+import java.util.Map;
+import java.util.function.Supplier;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.service.ClientState;
+import org.apache.cassandra.service.ClientWarn;
+import org.apache.cassandra.tracing.Tracing;
+
+public class PasswordGuardrail extends CustomGuardrail
+{
+ private static final Logger logger = LoggerFactory.getLogger(PasswordGuardrail.class);
+
+ /**
+ * @param configSupplier configuration supplier of the custom guardrail
+ */
+ public PasswordGuardrail(Supplier configSupplier)
+ {
+ super("password", null, configSupplier, true);
+ }
+
+ @Override
+ protected void warn(String message, String redactedMessage)
+ {
+ String msg = decorateMessage(message);
+ String redactedMsg = decorateMessage(redactedMessage);
+
+ ClientWarn.instance.warn(msg);
+ Tracing.trace(redactedMsg);
+ GuardrailsDiagnostics.warned(name, redactedMsg);
+ }
+
+ @Override
+ protected void fail(String message, String redactedMessage, @Nullable ClientState state)
+ {
+ String msg = decorateMessage(message);
+ String redactedMsg = decorateMessage(redactedMessage);
+
+ ClientWarn.instance.warn(msg);
+ Tracing.trace(redactedMsg);
+ GuardrailsDiagnostics.failed(name, redactedMsg);
+
+ if (state != null || throwOnNullClientState)
+ throw new PasswordGuardrailException(message, redactedMessage);
+ }
+
+ @Override
+ String decorateMessage(String message)
+ {
+ return String.format("Guardrail %s violated: %s", name, message);
+ }
+
+ @Override
+ void reconfigure(@Nullable Map newConfig)
+ {
+ if (!DatabaseDescriptor.isPasswordValidatorReconfigurationEnabled())
+ {
+ logger.warn("It is not possible to reconfigure password guardrail because " +
+ "property 'password_validator_reconfiguration_enabled' is set to false.");
+ return;
+ }
+
+ super.reconfigure(newConfig);
+ }
+
+ public static class PasswordGuardrailException extends GuardrailViolatedException
+ {
+ public final String redactedMessage;
+
+ PasswordGuardrailException(String message, String redactedMessage)
+ {
+ super(message);
+ this.redactedMessage = redactedMessage;
+ }
+ }
+}
diff --git a/src/java/org/apache/cassandra/db/guardrails/ValueGenerator.java b/src/java/org/apache/cassandra/db/guardrails/ValueGenerator.java
new file mode 100644
index 0000000000..d07f66ca91
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/guardrails/ValueGenerator.java
@@ -0,0 +1,133 @@
+/*
+ * 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.db.guardrails;
+
+import javax.annotation.Nonnull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.exceptions.ConfigurationException;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static java.lang.String.format;
+import static java.util.Map.of;
+
+/**
+ * Generates a value which respective {@link ValueValidator} successfuly validates.
+ */
+public abstract class ValueGenerator
+{
+ private static final Logger logger = LoggerFactory.getLogger(ValueValidator.class);
+
+ public static final String GENERATOR_CLASS_NAME_KEY = "generator_class_name";
+
+ private static final ValueGenerator> NO_OP_GENERATOR =
+ new NoOpGenerator<>(new CustomGuardrailConfig(of(GENERATOR_CLASS_NAME_KEY, NoOpGenerator.class.getCanonicalName())));
+
+ private static final String DEFAULT_VALIDATOR_IMPLEMENTATION_PACKAGE = ValueGenerator.class.getPackage().getName();
+
+ protected final CustomGuardrailConfig config;
+
+ public ValueGenerator(CustomGuardrailConfig config)
+ {
+ this.config = config;
+ }
+
+ /**
+ * Generates a value of given size.
+ *
+ * @param size size of value to be generated
+ * @param validator validator to validate generated value with
+ * @return generated value of given size
+ */
+ public abstract VALUE generate(int size, ValueValidator validator);
+
+ /**
+ * Generates a valid value.
+ *
+ * @param validator validator to validate generated value with
+ * @return generated and valid value
+ */
+ public abstract VALUE generate(ValueValidator validator);
+
+ /**
+ * @return parameters for this generator
+ */
+ @Nonnull
+ public abstract CustomGuardrailConfig getParameters();
+
+ /**
+ * Validates parameters for this generator.
+ *
+ * @throws ConfigurationException in case configuration for this generator is invalid
+ */
+ public abstract void validateParameters() throws ConfigurationException;
+
+ /**
+ * Returns an instance of a validator according to the parameters in {@code config}.
+ *
+ * @param name name of a guardrail a generator is created for
+ * @param config configuration to instantiate a generator with. After a generator is instantiated, it will
+ * validate this configuration internally and throw an exception if such configuration is invalid.
+ * @return instance of a generator of class as specified under key {@code class_name} in {@code config}. If not set,
+ * {@link NoOpGenerator} will be used.
+ * @throws ConfigurationException thrown in case {@code config} for the constructed generator is invalid, or it was
+ * not possible to construct a generator.
+ */
+ public static ValueGenerator getGenerator(String name, @Nonnull CustomGuardrailConfig config)
+ {
+ String className = config.resolveString(GENERATOR_CLASS_NAME_KEY);
+
+ if (className == null || className.isEmpty())
+ {
+ logger.debug("Configuration for generator for guardrail '{}' does not contain key " +
+ "'generator_class_name' or its value is null or empty string. No-op generator will be used.",
+ name);
+ return (ValueGenerator) NO_OP_GENERATOR;
+ }
+
+ if (!className.contains("."))
+ className = DEFAULT_VALIDATOR_IMPLEMENTATION_PACKAGE + '.' + className;
+
+ try
+ {
+ Class extends ValueGenerator> generatorClass =
+ FBUtilities.classForName(className, "generator");
+
+ @SuppressWarnings("unchecked")
+ ValueGenerator generator = generatorClass.getConstructor(CustomGuardrailConfig.class)
+ .newInstance(config);
+ logger.debug("Using {} generator for guardrail '{}' with parameters {}",
+ generator.getClass(), name, generator.getParameters());
+ return generator;
+ }
+ catch (Exception ex)
+ {
+ String message;
+ if (ex.getCause() instanceof ConfigurationException)
+ message = ex.getCause().getMessage();
+ else
+ message = ex.getMessage();
+
+ throw new ConfigurationException(format("Unable to create instance of generator of class %s: %s",
+ className, message), ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/java/org/apache/cassandra/db/guardrails/ValueValidator.java b/src/java/org/apache/cassandra/db/guardrails/ValueValidator.java
new file mode 100644
index 0000000000..3c85670126
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/guardrails/ValueValidator.java
@@ -0,0 +1,155 @@
+/*
+ * 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.db.guardrails;
+
+import java.util.Optional;
+import javax.annotation.Nonnull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.exceptions.ConfigurationException;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static java.lang.String.format;
+import static java.util.Map.of;
+
+/**
+ * Validates a value by calling {@link ValueValidator#shouldFail} or {@link ValueValidator#shouldWarn} methods.
+ * These methods are called from {@link CustomGuardrail} and CQL request either emits a failure when a value is invalid
+ * or a warning when it is still not valid, but it validates against the least strict validation.
+ *
+ * @param type parameter of a value this validator validates.
+ */
+public abstract class ValueValidator
+{
+ private static final Logger logger = LoggerFactory.getLogger(ValueValidator.class);
+
+ public static final String CLASS_NAME_KEY = "class_name";
+
+ private static final ValueValidator> NO_OP_VALIDATOR =
+ new NoOpValidator<>(new CustomGuardrailConfig(of(CLASS_NAME_KEY, NoOpValidator.class.getCanonicalName())));
+
+ private static final String DEFAULT_VALIDATOR_IMPLEMENTATION_PACKAGE = ValueValidator.class.getPackage().getName();
+
+ protected final CustomGuardrailConfig config;
+
+ public ValueValidator(CustomGuardrailConfig config)
+ {
+ this.config = config;
+ }
+
+ public static class ValidationViolation
+ {
+ public final String message;
+ public final String redactedMessage;
+
+ public ValidationViolation(String message)
+ {
+ this(message, message);
+ }
+
+ public ValidationViolation(String message, String redactedMessage)
+ {
+ this.message = message;
+ this.redactedMessage = redactedMessage;
+ }
+ }
+
+ /**
+ * Test a value to see if it emits warnings.
+ *
+ * @param value value to validate
+ * @param calledBySuperuser client state
+ * @return if optional is empty, value is valid, otherwise it returns warning violation message
+ */
+ public abstract Optional shouldWarn(VALUE value, boolean calledBySuperuser);
+
+ /**
+ * Test a value to see if it emits failures.
+ *
+ * @param value value to validate
+ * @param calledBySuperUser whether this is called by a super-user or not
+ * @return if optional is empty, value is valid, otherwise it returns failure violation message
+ */
+ public abstract Optional shouldFail(VALUE value, boolean calledBySuperUser);
+
+ /**
+ * Validates parameters for this validator.
+ *
+ * @throws ConfigurationException in case configuration for this validator is invalid
+ */
+ public abstract void validateParameters() throws ConfigurationException;
+
+ /**
+ * @return parameters for this validator
+ */
+ @Nonnull
+ public abstract CustomGuardrailConfig getParameters();
+
+ /**
+ * Returns an instance of a validator according to the parameters in {@code config}.
+ *
+ * @param name name of a guardrail a validator is created for
+ * @param config configuration to instantiate a validator with. After a validator is instantiated, it will
+ * validate this configuration internally and throw an exception if such configuration is invalid.
+ * @return instance of a validator of class as specified under key {@code class_name} in {@code config}. If not set,
+ * {@link NoOpValidator} will be used.
+ * @throws ConfigurationException thrown in case {@code config} for the constructed validator is invalid, or it was
+ * not possible to construct a validator.
+ */
+ public static ValueValidator getValidator(String name, @Nonnull CustomGuardrailConfig config)
+ {
+ String className = config.resolveString(CLASS_NAME_KEY);
+
+ if (className == null || className.isEmpty())
+ {
+ logger.debug("Configuration for validator for guardrail '{}' does not contain key " +
+ "'class_name' or its value is null or empty string. No-op validator will be used.", name);
+ return (ValueValidator) NO_OP_VALIDATOR;
+ }
+
+ if (!className.contains("."))
+ className = DEFAULT_VALIDATOR_IMPLEMENTATION_PACKAGE + '.' + className;
+
+ try
+ {
+ Class extends ValueValidator> validatorClass =
+ FBUtilities.classForName(className, "validator");
+
+ @SuppressWarnings("unchecked")
+ ValueValidator validator = validatorClass.getConstructor(CustomGuardrailConfig.class)
+ .newInstance(config);
+ logger.debug("Using {} validator for guardrail '{}' with parameters {}",
+ validator.getClass(), name, validator.getParameters());
+ return validator;
+ }
+ catch (Exception ex)
+ {
+ String message;
+ if (ex.getCause() instanceof ConfigurationException)
+ message = ex.getCause().getMessage();
+ else
+ message = ex.getMessage();
+
+ throw new ConfigurationException(format("Unable to create instance of validator of class %s: %s",
+ className, message), ex);
+ }
+ }
+}
diff --git a/test/distributed/org/apache/cassandra/distributed/test/guardrails/GuardrailPasswordTest.java b/test/distributed/org/apache/cassandra/distributed/test/guardrails/GuardrailPasswordTest.java
new file mode 100644
index 0000000000..9b48199f61
--- /dev/null
+++ b/test/distributed/org/apache/cassandra/distributed/test/guardrails/GuardrailPasswordTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.guardrails;
+
+import java.util.Map;
+import javax.management.MBeanServerConnection;
+import javax.management.ObjectName;
+import javax.management.remote.JMXConnector;
+
+import org.junit.Test;
+
+import org.apache.cassandra.db.guardrails.CassandraPasswordGenerator;
+import org.apache.cassandra.db.guardrails.CassandraPasswordValidator;
+import org.apache.cassandra.db.guardrails.CustomGuardrailConfig;
+import org.apache.cassandra.db.guardrails.Guardrails;
+import org.apache.cassandra.distributed.Cluster;
+import org.apache.cassandra.distributed.api.Feature;
+import org.apache.cassandra.distributed.shared.JMXUtil;
+import org.apache.cassandra.distributed.test.TestBaseImpl;
+import org.apache.cassandra.exceptions.ConfigurationException;
+
+import static org.apache.cassandra.distributed.Cluster.build;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.fail;
+
+public class GuardrailPasswordTest extends TestBaseImpl
+{
+ @Test
+ public void testInvalidConfigurationPreventsNodeFromStart()
+ {
+ CustomGuardrailConfig config = new CustomGuardrailConfig();
+ config.put("class_name", CassandraPasswordValidator.class.getName());
+ config.put("generator_class_name", CassandraPasswordGenerator.class.getName());
+ config.put("illegal_sequence_length", -1);
+
+ try (Cluster ignored = build(1)
+ .withConfig(c -> c.with(Feature.NETWORK, Feature.NATIVE_PROTOCOL, Feature.GOSSIP)
+ .set("password_validator", config))
+ .start())
+ {
+ fail("should throw ConfigurationException");
+ }
+ catch (Exception ex)
+ {
+ assertEquals(ConfigurationException.class.getName(), ex.getClass().getName());
+ }
+ }
+
+ @Test
+ public void testDisabledReconfiguration() throws Throwable
+ {
+ CustomGuardrailConfig config = new CustomGuardrailConfig();
+ config.put("class_name", CassandraPasswordValidator.class.getName());
+ config.put("generator_class_name", CassandraPasswordGenerator.class.getName());
+
+ try (Cluster cluster = build(1)
+ .withConfig(c -> c.with(Feature.JMX)
+ .set("password_validator_reconfiguration_enabled", false)
+ .set("password_validator", config))
+ .start();
+ JMXConnector connector = JMXUtil.getJmxConnector(cluster.get(1).config()))
+ {
+ MBeanServerConnection mbsc = connector.getMBeanServerConnection();
+
+ config.put("illegal_sequence_length", -1);
+
+ mbsc.invoke(ObjectName.getInstance(Guardrails.MBEAN_NAME),
+ "reconfigurePasswordValidator",
+ new Object[]{ config },
+ new String[]{ Map.class.getName() });
+
+ assertFalse(cluster.get(1)
+ .logs()
+ .watchFor("It is not possible to reconfigure password guardrail because " +
+ "property 'password_validator_reconfiguration_enabled' is set to false.")
+ .getResult()
+ .isEmpty());
+ }
+ }
+}
diff --git a/test/unit/org/apache/cassandra/auth/RoleOptionsTest.java b/test/unit/org/apache/cassandra/auth/RoleOptionsTest.java
index 49130f7e8d..019066b085 100644
--- a/test/unit/org/apache/cassandra/auth/RoleOptionsTest.java
+++ b/test/unit/org/apache/cassandra/auth/RoleOptionsTest.java
@@ -68,6 +68,16 @@ public class RoleOptionsTest
opts.setOption(IRoleManager.Option.HASHED_PASSWORD, "$2a$10$JSJEMFm6GeaW9XxT5JIheuEtPvat6i7uKbnTcxX3c1wshIIsGyUtG");
assertInvalidOptions(opts, "Properties 'PASSWORD' and 'HASHED_PASSWORD' are mutually exclusive");
+ opts = new RoleOptions();
+ opts.setOption(IRoleManager.Option.GENERATED_PASSWORD, true);
+ opts.setOption(IRoleManager.Option.HASHED_PASSWORD, "$2a$10$JSJEMFm6GeaW9XxT5JIheuEtPvat6i7uKbnTcxX3c1wshIIsGyUtG");
+ assertInvalidOptions(opts, "Properties 'HASHED_PASSWORD' and 'GENERATED_PASSWORD' are mutually exclusive");
+
+ opts = new RoleOptions();
+ opts.setOption(IRoleManager.Option.PASSWORD, "abc");
+ opts.setOption(IRoleManager.Option.GENERATED_PASSWORD, true);
+ assertInvalidOptions(opts, "Properties 'PASSWORD' and 'GENERATED_PASSWORD' are mutually exclusive");
+
opts = new RoleOptions();
opts.setOption(IRoleManager.Option.LOGIN, true);
opts.setOption(IRoleManager.Option.SUPERUSER, false);
diff --git a/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java b/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java
index 74351ab5dd..840ef49d71 100644
--- a/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java
+++ b/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java
@@ -170,6 +170,7 @@ public class DatabaseDescriptorRefTest
"org.apache.cassandra.db.commitlog.CommitLogSegmentManagerFactory",
"org.apache.cassandra.db.commitlog.CommitLogSegmentManagerStandard",
"org.apache.cassandra.db.commitlog.DefaultCommitLogSegmentMgrFactory",
+ "org.apache.cassandra.db.guardrails.CustomGuardrailConfig",
"org.apache.cassandra.db.guardrails.GuardrailsConfig",
"org.apache.cassandra.db.guardrails.GuardrailsConfig$ConsistencyLevels",
"org.apache.cassandra.db.guardrails.GuardrailsConfig$TableProperties",
diff --git a/test/unit/org/apache/cassandra/cql3/CQLTester.java b/test/unit/org/apache/cassandra/cql3/CQLTester.java
index 4eb5039094..6195fce256 100644
--- a/test/unit/org/apache/cassandra/cql3/CQLTester.java
+++ b/test/unit/org/apache/cassandra/cql3/CQLTester.java
@@ -2375,6 +2375,12 @@ public abstract class CQLTester
void apply() throws Throwable;
}
+ @FunctionalInterface
+ public interface CheckedSupplier
+ {
+ ResultMessage get() throws Throwable;
+ }
+
/**
* Runs the given function before and after a flush of sstables. This is useful for checking that behavior is
* the same whether data is in memtables or sstables.
diff --git a/test/unit/org/apache/cassandra/db/guardrails/CassandraPasswordGeneratorTest.java b/test/unit/org/apache/cassandra/db/guardrails/CassandraPasswordGeneratorTest.java
new file mode 100644
index 0000000000..9976c82e19
--- /dev/null
+++ b/test/unit/org/apache/cassandra/db/guardrails/CassandraPasswordGeneratorTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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.db.guardrails;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.Test;
+
+import org.assertj.core.api.Assertions;
+import org.passay.CharacterRule;
+import org.passay.EnglishCharacterData;
+import org.passay.LengthRule;
+import org.passay.PasswordData;
+import org.passay.Rule;
+import org.passay.RuleResultDetail;
+
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.LENGTH_FAIL_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.LENGTH_WARN_KEY;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class CassandraPasswordGeneratorTest
+{
+ @Test
+ public void testPasswordGenerator()
+ {
+ CustomGuardrailConfig config = new CustomGuardrailConfig();
+
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(config);
+ CassandraPasswordGenerator generator = new CassandraPasswordGenerator(config);
+
+ PasswordData passwordData = new PasswordData(generator.generate(validator));
+ for (Rule rule : validator.warnValidator.getRules())
+ assertTrue(rule.validate(passwordData).isValid());
+
+ for (Rule rule : validator.failValidator.getRules())
+ assertTrue(rule.validate(passwordData).isValid());
+
+ assertTrue(validator.shouldWarn(passwordData.getPassword(), true).isEmpty());
+ assertTrue(validator.shouldFail(passwordData.getPassword(), true).isEmpty());
+ }
+
+ @Test
+ public void testPasswordGenerationLength()
+ {
+ CustomGuardrailConfig config = new CustomGuardrailConfig();
+ config.put(LENGTH_WARN_KEY, 20);
+ config.put(LENGTH_FAIL_KEY, 15);
+
+ CassandraPasswordGenerator generator = new CassandraPasswordGenerator(config);
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(config);
+
+ assertEquals(20, generator.generate(validator).length());
+ assertEquals(30, generator.generate(30, validator).length());
+ }
+
+ @Test
+ public void testPasswordGenerationOfLengthViolatingThreshold()
+ {
+ CustomGuardrailConfig config = new CustomGuardrailConfig();
+ config.put(LENGTH_WARN_KEY, 20);
+ config.put(LENGTH_FAIL_KEY, 15);
+
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(config);
+
+ PasswordData passwordData = new PasswordData("13gE.^Tg$sSd%Tx34.w");
+
+ for (Rule rule : validator.warnValidator.getRules())
+ {
+ if (rule instanceof LengthRule)
+ {
+ assertFalse(rule.validate(passwordData).isValid());
+ List details = rule.validate(passwordData).getDetails();
+ assertEquals(1, details.size());
+ RuleResultDetail ruleResultDetail = details.get(0);
+ assertEquals("TOO_SHORT", ruleResultDetail.getErrorCode());
+ }
+ else
+ {
+ assertTrue(rule.validate(passwordData).isValid());
+ }
+ }
+
+ for (Rule rule : validator.failValidator.getRules())
+ assertTrue(rule.validate(passwordData).isValid());
+
+ assertFalse(validator.shouldWarn(passwordData.getPassword(), true).isEmpty());
+ assertTrue(validator.shouldFail(passwordData.getPassword(), true).isEmpty());
+ }
+
+ @Test
+ public void testGeneratedPasswordWithoutSpecialCharsIsInvalidWithDefaultValidator()
+ {
+ CustomGuardrailConfig config = new CustomGuardrailConfig();
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(config);
+ CassandraPasswordGenerator testGenerator = new TestPasswordGenerator(config);
+ Assertions.assertThatThrownBy(() -> testGenerator.generate(validator))
+ .hasMessageContaining("It was not possible to generate a valid password");
+ }
+
+ private static class TestPasswordGenerator extends CassandraPasswordGenerator
+ {
+ public TestPasswordGenerator(CustomGuardrailConfig config)
+ {
+ super(config);
+ }
+
+ @Override
+ protected List getCharacterGenerationRules(int upper, int lower, int digits, int special)
+ {
+ // omit special chars rule on purpose
+ return Arrays.asList(new CharacterRule(EnglishCharacterData.UpperCase, upper),
+ new CharacterRule(EnglishCharacterData.LowerCase, lower),
+ new CharacterRule(EnglishCharacterData.Digit, digits));
+ }
+ }
+}
diff --git a/test/unit/org/apache/cassandra/db/guardrails/CassandraPasswordValidatorTest.java b/test/unit/org/apache/cassandra/db/guardrails/CassandraPasswordValidatorTest.java
new file mode 100644
index 0000000000..d0604c5243
--- /dev/null
+++ b/test/unit/org/apache/cassandra/db/guardrails/CassandraPasswordValidatorTest.java
@@ -0,0 +1,369 @@
+/*
+ * 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.db.guardrails;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+import org.junit.Test;
+
+import org.apache.cassandra.db.guardrails.ValueValidator.ValidationViolation;
+import org.apache.cassandra.exceptions.ConfigurationException;
+import org.passay.IllegalSequenceRule;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static java.lang.String.format;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_CHARACTERISTIC_FAIL;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_ILLEGAL_SEQUENCE_LENGTH;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_CHARACTERISTIC_WARN;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_LENGTH_FAIL;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_LENGTH_WARN;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_LOWER_CASE_FAIL;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_LOWER_CASE_WARN;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_SPECIAL_FAIL;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_SPECIAL_WARN;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_UPPER_CASE_FAIL;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DEFAULT_UPPER_CASE_WARN;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.ILLEGAL_SEQUENCE_LENGTH_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.MAX_CHARACTERISTICS;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.MAX_LENGTH_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.CHARACTERISTIC_FAIL_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.CHARACTERISTIC_WARN_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DIGIT_FAIL_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.DIGIT_WARN_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.LENGTH_FAIL_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.LENGTH_WARN_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.LOWER_CASE_FAIL_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.LOWER_CASE_WARN_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.SPECIAL_FAIL_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.SPECIAL_WARN_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.UPPER_CASE_FAIL_KEY;
+import static org.apache.cassandra.db.guardrails.CassandraPasswordConfiguration.UPPER_CASE_WARN_KEY;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+public class CassandraPasswordValidatorTest
+{
+ @Test
+ public void testEmptyConfigIsValid()
+ {
+ new CassandraPasswordValidator(new CustomGuardrailConfig());
+ }
+
+ @Test
+ public void testDefaultConfiguration()
+ {
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(new CustomGuardrailConfig());
+ CustomGuardrailConfig parameters = validator.getParameters();
+ CassandraPasswordConfiguration conf = new CassandraPasswordConfiguration(parameters);
+
+ assertEquals(DEFAULT_CHARACTERISTIC_WARN, conf.characteristicsWarn);
+ assertEquals(DEFAULT_CHARACTERISTIC_FAIL, conf.characteristicsFail);
+
+ assertEquals(DEFAULT_LENGTH_WARN, conf.lengthWarn);
+ assertEquals(DEFAULT_LENGTH_FAIL, conf.lengthFail);
+
+ assertEquals(DEFAULT_UPPER_CASE_WARN, conf.upperCaseWarn);
+ assertEquals(DEFAULT_UPPER_CASE_FAIL, conf.upperCaseFail);
+
+ assertEquals(DEFAULT_LOWER_CASE_WARN, conf.lowerCaseWarn);
+ assertEquals(DEFAULT_LOWER_CASE_FAIL, conf.lowerCaseFail);
+
+ assertEquals(DEFAULT_SPECIAL_WARN, conf.specialsWarn);
+ assertEquals(DEFAULT_SPECIAL_FAIL, conf.specialsFail);
+
+ assertEquals(DEFAULT_ILLEGAL_SEQUENCE_LENGTH, conf.illegalSequenceLength);
+ }
+
+ @Test
+ public void testInvalidParameters()
+ {
+ for (int[] warnAndFail : new int[][]{ { 10, 10 }, { 5, 10 } })
+ {
+ int warn = warnAndFail[0];
+ int fail = warnAndFail[1];
+ validateWithConfig(() -> Map.of(LENGTH_WARN_KEY, warn, LENGTH_FAIL_KEY, fail),
+ format("%s of value %s is less or equal to %s of value %s",
+ LENGTH_WARN_KEY, warn, LENGTH_FAIL_KEY, fail));
+
+ validateWithConfig(() -> Map.of(SPECIAL_WARN_KEY, warn, SPECIAL_FAIL_KEY, fail),
+ format("%s of value %s is less or equal to %s of value %s",
+ SPECIAL_WARN_KEY, warn, SPECIAL_FAIL_KEY, fail));
+
+ validateWithConfig(() -> Map.of(DIGIT_WARN_KEY, warn, DIGIT_FAIL_KEY, fail),
+ format("%s of value %s is less or equal to %s of value %s",
+ DIGIT_WARN_KEY, warn, DIGIT_FAIL_KEY, fail));
+
+ validateWithConfig(() -> Map.of(LOWER_CASE_WARN_KEY, warn, LOWER_CASE_FAIL_KEY, fail),
+ format("%s of value %s is less or equal to %s of value %s",
+ LOWER_CASE_WARN_KEY, warn, LOWER_CASE_FAIL_KEY, fail));
+
+ validateWithConfig(() -> Map.of(UPPER_CASE_WARN_KEY, warn, UPPER_CASE_FAIL_KEY, fail),
+ format("%s of value %s is less or equal to %s of value %s",
+ UPPER_CASE_WARN_KEY, warn, UPPER_CASE_FAIL_KEY, fail));
+ }
+
+ validateWithConfig(() -> Map.of(ILLEGAL_SEQUENCE_LENGTH_KEY, 2),
+ format("Illegal sequence length can not be lower than %s.",
+ IllegalSequenceRule.MINIMUM_SEQUENCE_LENGTH));
+
+ validateWithConfig(() -> Map.of(CHARACTERISTIC_WARN_KEY, 5),
+ format("%s can not be bigger than %s",
+ CHARACTERISTIC_WARN_KEY, MAX_CHARACTERISTICS));
+
+ validateWithConfig(() -> Map.of(CHARACTERISTIC_FAIL_KEY, 5),
+ format("%s can not be bigger than %s",
+ CHARACTERISTIC_FAIL_KEY, MAX_CHARACTERISTICS));
+
+ validateWithConfig(() -> Map.of(CHARACTERISTIC_WARN_KEY, 3, CHARACTERISTIC_FAIL_KEY, 3),
+ format("%s can not be equal to %s. You set %s and %s respectively.",
+ CHARACTERISTIC_FAIL_KEY, CHARACTERISTIC_WARN_KEY, 3, 3));
+
+ validateWithConfig(() -> Map.of(CHARACTERISTIC_WARN_KEY, 3, CHARACTERISTIC_FAIL_KEY, 4),
+ format("%s can not be bigger than %s. You have set %s and %s respectively.",
+ CHARACTERISTIC_FAIL_KEY, CHARACTERISTIC_WARN_KEY, 4, 3));
+
+ validateWithConfig(() -> Map.of(SPECIAL_WARN_KEY, 1,
+ SPECIAL_FAIL_KEY, 0,
+ DIGIT_WARN_KEY, 1,
+ DIGIT_FAIL_KEY, 0,
+ UPPER_CASE_WARN_KEY, 2,
+ LOWER_CASE_WARN_KEY, 2,
+ CHARACTERISTIC_WARN_KEY, 3,
+ CHARACTERISTIC_FAIL_KEY, 2,
+ LENGTH_WARN_KEY, 3,
+ LENGTH_FAIL_KEY, 2),
+ format("The shortest password to pass the warning validator for any %s characteristics " +
+ "out of %s is %s but you have set the %s to %s.",
+ 3, MAX_CHARACTERISTICS, 4, LENGTH_WARN_KEY, 3));
+
+ validateWithConfig(() ->
+ new HashMap<>()
+ {{
+ put(SPECIAL_FAIL_KEY, 1);
+ put(DIGIT_WARN_KEY, 2);
+ put(DIGIT_FAIL_KEY, 1);
+ put(UPPER_CASE_WARN_KEY, 2);
+ put(UPPER_CASE_FAIL_KEY, 1);
+ put(LOWER_CASE_WARN_KEY, 2);
+ put(LOWER_CASE_FAIL_KEY, 1);
+ put(CHARACTERISTIC_WARN_KEY, 4);
+ put(CHARACTERISTIC_FAIL_KEY, 3);
+ put(LENGTH_WARN_KEY, 8);
+ put(LENGTH_FAIL_KEY, 2);
+ }},
+ format("The shortest password to pass the failing validator for any %s characteristics " +
+ "out of %s is %s but you have set the %s to %s.",
+ 3, MAX_CHARACTERISTICS, 3, LENGTH_FAIL_KEY, 2));
+
+ for (Map.Entry entry : new HashMap()
+ {{
+ put(MAX_LENGTH_KEY, -1);
+ put(SPECIAL_FAIL_KEY, -1);
+ put(DIGIT_WARN_KEY, -1);
+ put(DIGIT_FAIL_KEY, -1);
+ put(UPPER_CASE_WARN_KEY, -1);
+ put(UPPER_CASE_FAIL_KEY, -1);
+ put(LOWER_CASE_WARN_KEY, -1);
+ put(LOWER_CASE_FAIL_KEY, -1);
+ put(CHARACTERISTIC_WARN_KEY, -1);
+ put(CHARACTERISTIC_FAIL_KEY, -1);
+ put(LENGTH_WARN_KEY, -1);
+ put(LENGTH_FAIL_KEY, -1);
+ }}.entrySet())
+ {
+ validateWithConfig(() -> Map.of(entry.getKey(), entry.getValue()),
+ format("%s must be positive.", entry.getKey()));
+ }
+ }
+
+ @Test
+ public void testIllegalSequences()
+ {
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(new CustomGuardrailConfig());
+ Optional validationResult = validator.shouldFail("A1$abcdefgh", true);
+ assertTrue(validationResult.isPresent());
+ assertThat(validationResult.get().message,
+ containsString("Password contains the illegal alphabetical sequence 'abcdefgh'."));
+
+ validationResult = validator.shouldFail("A1$a123456", true);
+ assertTrue(validationResult.isPresent());
+ assertThat(validationResult.get().message,
+ containsString("Password contains the illegal numerical sequence '123456'."));
+
+ validationResult = validator.shouldFail("A1$asdfghjkl", true);
+ assertTrue(validationResult.isPresent());
+ assertThat(validationResult.get().message,
+ containsString("Password contains the illegal QWERTY sequence 'asdfghjkl'."));
+
+ // test e.g. illegal polish sequence
+ validationResult = validator.shouldFail("A1$ęlłmnńoóp", true);
+ assertTrue(validationResult.isPresent());
+ assertThat(validationResult.get().message,
+ containsString("Password contains the illegal alphabetical sequence 'lłmnńoóp'."));
+ }
+
+ @Test
+ public void testWhitespace()
+ {
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(new CustomGuardrailConfig());
+ Optional validationResult = validator.shouldFail("A1$abcd efgh", true);
+ assertTrue(validationResult.isPresent());
+ assertThat(validationResult.get().message, containsString("Password contains a whitespace character."));
+ }
+
+ @Test
+ public void testFailingValidationResult()
+ {
+ String verboseMessage = "Password was not set as it violated configured password strength policy. " +
+ "To fix this error, the following has to be resolved: " +
+ "Password must contain 1 or more uppercase characters. " +
+ "Password must contain 1 or more digit characters. " +
+ "Password must contain 1 or more special characters. " +
+ "Password matches 1 of 4 character rules, but 3 are required. " +
+ "You may also use 'GENERATED PASSWORD' upon role creation or alteration.";
+
+ String simpleMessage = "Password was not set as it violated configured password strength policy. You may also use 'GENERATED PASSWORD' upon role creation or alteration.";
+
+ for (Object[] entry : new Object[][]{
+ // first boolean is if detailed messages should be provided
+ // second boolean is if it is called by a super-user
+ { simpleMessage, FALSE, FALSE },
+ { verboseMessage, FALSE, TRUE },
+ { verboseMessage, TRUE, FALSE },
+ { verboseMessage, TRUE, TRUE } })
+ {
+ CustomGuardrailConfig config = new CustomGuardrailConfig();
+ config.put(CassandraPasswordConfiguration.DETAILED_MESSAGES_KEY, entry[1]);
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(config);
+
+ Optional validationResult = validator.shouldFail("acefghuiiui", (boolean) entry[2]);
+ assertTrue(validationResult.isPresent());
+ assertEquals("[INSUFFICIENT_DIGIT, INSUFFICIENT_CHARACTERISTICS, INSUFFICIENT_SPECIAL, INSUFFICIENT_UPPERCASE]",
+ validationResult.get().redactedMessage);
+ assertEquals(entry[0], validationResult.get().message);
+
+ // additionally, we satisfied special chars and digit characteristics,
+ // by that we satisfied 3 out of 4 character rules, so we are not emitting a failure
+ validationResult = validator.shouldFail("a5ef1hu.iu$", (boolean) entry[2]);
+ assertFalse(validationResult.isPresent());
+ }
+ }
+
+ @Test
+ public void testWarningValidationResult()
+ {
+ String verboseMessage = "Password was set, however it might not be strong enough according to the configured password strength policy. " +
+ "To fix this warning, the following has to be resolved: " +
+ "Password must contain 2 or more uppercase characters. " +
+ "Password must contain 2 or more digit characters. " +
+ "Password matches 2 of 4 character rules, but 4 are required. " +
+ "You may also use 'GENERATED PASSWORD' upon role creation or alteration.";
+
+ String simpleMessage = "Password was set, however it might not be strong enough according to the configured password strength policy.";
+
+ for (Object[] entry : new Object[][]{
+ // first boolean is if detailed messages should be provided
+ // second boolean is if it is called by a super-user
+ { simpleMessage, FALSE, FALSE },
+ { verboseMessage, FALSE, TRUE },
+ { verboseMessage, TRUE, FALSE },
+ { verboseMessage, TRUE, TRUE } })
+ {
+ CustomGuardrailConfig config = new CustomGuardrailConfig();
+ config.put(CassandraPasswordConfiguration.DETAILED_MESSAGES_KEY, entry[1]);
+ CassandraPasswordValidator validator = new CassandraPasswordValidator(config);
+
+ Optional validationResult = validator.shouldWarn("t$Efg1#a..fr", (boolean) entry[2]);
+ assertTrue(validationResult.isPresent());
+ assertEquals("[INSUFFICIENT_DIGIT, INSUFFICIENT_CHARACTERISTICS, INSUFFICIENT_UPPERCASE]",
+ validationResult.get().redactedMessage);
+ assertEquals(entry[0], validationResult.get().message);
+
+ // we added one more number and one more upper-case character,
+ // so we started to satisfy 4 out of 4 characteristics which does not emit any warning
+ assertFalse(validator.shouldWarn("A$Efg1#a..6r", (boolean) entry[2]).isPresent());
+ }
+ }
+
+ private void validateWithConfig(Supplier