diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index 42935df4d6b7..7d630298e000 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -2682,6 +2682,20 @@ max_security_label_length: 48 # compressed by dictionary compressor and training_min_frequency is set to 0m (the default when unset). #unset_training_min_frequency_enabled: true +# Minimum client driver versions. Connections from drivers whose version is below +# the configured minimum will be warned or rejected. Connections that do not report +# a driver name or version are considered valid. The map key is the driver name +# as reported in the native protocol STARTUP message. The value is the minimum +# version string. +#minimum_client_driver_versions_warned: +# DataStax Java Driver: "4.0.0" +# DataStax Python Driver: "3.0.0" +# github.com/apache/cassandra-gocql-driver: "2.0.0" +#minimum_client_driver_versions_disallowed: +# DataStax Java Driver: "4.0.0" +# DataStax Python Driver: "3.0.0" +# github.com/apache/cassandra-gocql-driver: "2.0.0" + # Startup Checks are executed as part of Cassandra startup process, not all of them # are configurable (so you can disable them) but these which are enumerated bellow. # Uncomment the startup checks and configure them appropriately to cover your needs. diff --git a/conf/cassandra_latest.yaml b/conf/cassandra_latest.yaml index 13b54aa53fbc..db80b9f191a2 100644 --- a/conf/cassandra_latest.yaml +++ b/conf/cassandra_latest.yaml @@ -2446,6 +2446,20 @@ default_secondary_index_enabled: true # compressed by dictionary compressor and training_min_frequency is set to 0m (the default when unset). #unset_training_min_frequency_enabled: true +# Minimum client driver versions. Connections from drivers whose version is below +# the configured minimum will be warned or rejected. Connections that do not report +# a driver name or version are considered valid. The map key is the driver name +# as reported in the native protocol STARTUP message. The value is the minimum +# version string. +#minimum_client_driver_versions_warned: +# DataStax Java Driver: "4.0.0" +# DataStax Python Driver: "3.0.0" +# github.com/apache/cassandra-gocql-driver: "2.0.0" +#minimum_client_driver_versions_disallowed: +# DataStax Java Driver: "4.0.0" +# DataStax Python Driver: "3.0.0" +# github.com/apache/cassandra-gocql-driver: "2.0.0" + # Startup Checks are executed as part of Cassandra startup process, not all of them # are configurable (so you can disable them) but these which are enumerated bellow. # Uncomment the startup checks and configure them appropriately to cover your needs. diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index c777d0fc89b4..22b3fe2cef7e 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -1006,6 +1006,9 @@ public static void setClientMode(boolean clientMode) public volatile boolean unset_training_min_frequency_warned = true; public volatile boolean unset_training_min_frequency_enabled = true; + public volatile Map minimum_client_driver_versions_warned = Collections.emptyMap(); + public volatile Map minimum_client_driver_versions_disallowed = Collections.emptyMap(); + public volatile int sai_sstable_indexes_per_query_warn_threshold = 32; public volatile int sai_sstable_indexes_per_query_fail_threshold = -1; public volatile DataStorageSpec.LongBytesBound sai_string_term_size_warn_threshold = new DataStorageSpec.LongBytesBound("1KiB"); diff --git a/src/java/org/apache/cassandra/config/GuardrailsOptions.java b/src/java/org/apache/cassandra/config/GuardrailsOptions.java index b7bea893d273..c166e71ec1cf 100644 --- a/src/java/org/apache/cassandra/config/GuardrailsOptions.java +++ b/src/java/org/apache/cassandra/config/GuardrailsOptions.java @@ -18,15 +18,20 @@ package org.apache.cassandra.config; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.regex.Pattern; import javax.annotation.Nullable; +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Sets; +import com.vdurmont.semver4j.Semver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -113,6 +118,8 @@ public GuardrailsOptions(Config config) false); validatePasswordPolicy(config.password_policy); validateRoleNamePolicy(config.role_name_policy); + validateAndSanitizeClientDriverVersions(config.minimum_client_driver_versions_warned, "minimum_client_driver_versions_warned"); + validateAndSanitizeClientDriverVersions(config.minimum_client_driver_versions_disallowed, "minimum_client_driver_versions_disallowed"); } @Override @@ -195,7 +202,7 @@ public Set getKeyspacePropertiesWarned() { return config.keyspace_properties_warned; } - + public void setKeyspacePropertiesWarned(Set properties) { updatePropertyWithLogging("keyspace_properties_warned", @@ -209,7 +216,7 @@ public Set getKeyspacePropertiesIgnored() { return config.keyspace_properties_ignored; } - + public void setKeyspacePropertiesIgnored(Set properties) { updatePropertyWithLogging("keyspace_properties_ignored", @@ -223,7 +230,7 @@ public Set getKeyspacePropertiesDisallowed() { return config.keyspace_properties_disallowed; } - + public void setKeyspacePropertiesDisallowed(Set properties) { updatePropertyWithLogging("keyspace_properties_disallowed", @@ -1368,6 +1375,34 @@ public boolean getUnsetTrainingMinFrequencyEnabled() return config.unset_training_min_frequency_enabled; } + @Override + public Map getMinimumClientDriverVersionsWarned() + { + return config.minimum_client_driver_versions_warned; + } + + @Override + public Map getMinimumClientDriverVersionsDisallowed() + { + return config.minimum_client_driver_versions_disallowed; + } + + public void setMinimumClientDriverVersionsWarned(Map versions) + { + updatePropertyWithLogging("minimum_client_driver_versions_warned", + versions, + () -> config.minimum_client_driver_versions_warned, + x -> config.minimum_client_driver_versions_warned = x); + } + + public void setMinimumClientDriverVersionsDisallowed(Map versions) + { + updatePropertyWithLogging("minimum_client_driver_versions_disallowed", + versions, + () -> config.minimum_client_driver_versions_disallowed, + x -> config.minimum_client_driver_versions_disallowed = x); + } + private static void updatePropertyWithLogging(String propertyName, T newValue, Supplier getter, Consumer setter) { T oldValue = getter.get(); @@ -1601,4 +1636,57 @@ private static void validateRoleNamePolicy(CustomGuardrailConfig config) { ValueGenerator.getGenerator("role_name_policy", config).generate(ValueValidator.getValidator("role_name_policy", config), Map.of()); } + + @VisibleForTesting + public static void validateAndSanitizeClientDriverVersions(Map map, String guardrailName) + { + if (map == null || map.isEmpty()) + return; + + List invalidEntries = new ArrayList<>(); + + for (Map.Entry entry : map.entrySet()) + { + String sanitized = sanitizeVersion(entry.getValue()); + if (!isValidVersion(sanitized)) + invalidEntries.add(entry.getKey()); + } + + if (!invalidEntries.isEmpty()) + throw new IllegalArgumentException("Invalid version entries for " + guardrailName + " guardrail, they do not follow semver: " + invalidEntries); + + map.replaceAll((driver, version) -> sanitizeVersion(version)); + } + + public static boolean isValidVersion(String version) + { + if (version == null) + return false; + + // try to construct it + try + { + new Semver(version); + } + catch (Throwable t) + { + return false; + } + + return true; + } + + private static final Pattern VERSION_SANITATION_PATTERN = Pattern.compile("^[vV]"); + + public static String sanitizeVersion(String driverVersion) + { + String sanitizedVersionId = driverVersion == null ? null : driverVersion.trim(); + if (sanitizedVersionId != null) + sanitizedVersionId = VERSION_SANITATION_PATTERN.matcher(sanitizedVersionId).replaceFirst(""); + + if (sanitizedVersionId == null || sanitizedVersionId.isBlank()) + return null; + + return sanitizedVersionId; + } } diff --git a/src/java/org/apache/cassandra/db/guardrails/ClientDriverVersionGuardrail.java b/src/java/org/apache/cassandra/db/guardrails/ClientDriverVersionGuardrail.java new file mode 100644 index 000000000000..a631261bee16 --- /dev/null +++ b/src/java/org/apache/cassandra/db/guardrails/ClientDriverVersionGuardrail.java @@ -0,0 +1,160 @@ +/* + * 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.Function; + +import javax.annotation.Nullable; + +import com.vdurmont.semver4j.Semver; +import com.vdurmont.semver4j.Semver.SemverType; + +import org.apache.cassandra.config.GuardrailsOptions; +import org.apache.cassandra.service.ClientState; + +/** + * A guardrail that warns or rejects client connections whose driver version + * is below a configured minimum per driver name. + *

+ * The guardrail is configured with maps of driver name to minimum version string. + * Connections that do not report a driver name or version are considered valid + * and are not subject to this guardrail. + *

+ * Version comparison uses semantic versioning via {@link Semver} with loose parsing + * to handle non-standard version strings reported by drivers. + */ +public class ClientDriverVersionGuardrail extends Predicates +{ + private final Function> warnVersions; + private final Function> disallowVersions; + + public ClientDriverVersionGuardrail(Function> warnVersions, + Function> disallowVersions) + { + super("minimum_client_driver_versions", null, null, null, null); + this.warnVersions = warnVersions; + this.disallowVersions = disallowVersions; + } + + public void guard(@Nullable String driverName, @Nullable String driverVersion, @Nullable ClientState state) + { + if (!enabled(state)) + return; + + String sanitizedDriverId = driverName == null ? null : driverName.trim(); + if (sanitizedDriverId == null || sanitizedDriverId.isBlank()) + { + logger.debug("minimum_client_driver_versions guardrail identified empty driver " + + "id to check the minimum version of, such connections will be allowed but " + + "an operator should check what kind of clients are connecting to the cluster."); + return; + } + + String sanitizedDriverVersion = GuardrailsOptions.sanitizeVersion(driverVersion); + if (sanitizedDriverVersion == null) + { + logger.debug("minimum_client_driver_versions guardrail identified empty driver " + + "version to check the minimum version of, such connections will be allowed but " + + "an operator should check what kind of clients are connecting to the cluster."); + return; + } + + if (!GuardrailsOptions.isValidVersion(sanitizedDriverVersion)) + { + logger.debug("minimum_client_driver_versions guardrail identified driver " + + "version which is not compliant semver version, such connections will be allowed but " + + "an operator should check what kind of clients are connecting to the cluster."); + return; + } + + Map disallowed = disallowVersions.apply(state); + if (disallowed != null && !disallowed.isEmpty()) + { + String minimumVersionFail = disallowed.get(sanitizedDriverId); + if (minimumVersionFail != null && isBelowMinimum(sanitizedDriverVersion, minimumVersionFail)) + { + fail(String.format("Client driver %s is below required minimum version %s, connection rejected", + sanitizedDriverId, minimumVersionFail), state); + return; + } + } + + Map warned = warnVersions.apply(state); + if (warned != null && !warned.isEmpty()) + { + String minimumVersionWarn = warned.get(sanitizedDriverId); + if (minimumVersionWarn != null && isBelowMinimum(sanitizedDriverVersion, minimumVersionWarn)) + { + warn(String.format("Client driver %s is below recommended minimum version %s", + sanitizedDriverId, minimumVersionWarn)); + } + } + } + + /** + * Driver id should be in the format "driver:version". It will be parsed and + * {@link #guard(String, String, ClientState)} called. + *

+ * This method is not expected to be called in normal circumstances, it is just that we + * would need to pass it with colon separator from StartupMessage as guard method + * expects one string as a value to check, just so that we would break it apart in turn to get + * driver id and version again. + * + * @param rawDriverId the value to check, driver name and version delimited by a colon. + * @param state client state + */ + @Override + public void guard(String rawDriverId, @Nullable ClientState state) + { + if (rawDriverId == null) + return; + + String[] pair = rawDriverId.trim().split(":"); + if (pair.length != 2) + return; + + String sanitizedDriverName = pair[0].trim(); + if (sanitizedDriverName.isBlank()) + return; + + String sanitizedDriverVersion = pair[1].trim(); + if (sanitizedDriverVersion.isBlank()) + return; + + guard(sanitizedDriverName, sanitizedDriverVersion, state); + } + + /** + * Checks if the driver version is below the minimum version + * specified in the config map. If driver name is not in minimum versions, + * such connection is considered to be allowed. + *

+ * + * @param driverVersion version of a driver + * @param minimumVersion minimum allowed version + * @return true if the driver version is lower than the configured minimum + */ + static boolean isBelowMinimum(String driverVersion, String minimumVersion) + { + Semver versionToCheck = new Semver(driverVersion, SemverType.LOOSE); + Semver minimumVersionAllowed = new Semver(minimumVersion, SemverType.LOOSE); + return versionToCheck.isLowerThan(minimumVersionAllowed); + } +} diff --git a/src/java/org/apache/cassandra/db/guardrails/Guardrails.java b/src/java/org/apache/cassandra/db/guardrails/Guardrails.java index 9f0e63bbcdf2..cea9d59511c6 100644 --- a/src/java/org/apache/cassandra/db/guardrails/Guardrails.java +++ b/src/java/org/apache/cassandra/db/guardrails/Guardrails.java @@ -19,12 +19,14 @@ 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; import javax.annotation.Nullable; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableSet; @@ -571,6 +573,16 @@ public final class Guardrails implements GuardrailsMBean isWarning ? "Disk usage in keyspace datacenter exceeds warning threshold" : "Write request failed because disk usage exceeds failure threshold in keyspace datacenter."); + /** + * Guardrail on client driver versions. Warns or rejects connections from drivers + * whose version is below the configured minimum for that driver name. + * Connections that do not report a driver name or version are considered valid. + */ + public static final ClientDriverVersionGuardrail minimumClientDriverVersion = + new ClientDriverVersionGuardrail( + state -> CONFIG_PROVIDER.getOrCreate(state).getMinimumClientDriverVersionsWarned(), + state -> CONFIG_PROVIDER.getOrCreate(state).getMinimumClientDriverVersionsDisallowed()); + /** * Guardrail on passwords for CREATE / ALTER ROLE statements. */ @@ -1853,6 +1865,62 @@ public boolean getUnsetTrainingMinFrequencyEnabled() return DEFAULT_CONFIG.getUnsetTrainingMinFrequencyEnabled(); } + @Override + public String getMinimumClientDriverVersionsWarned() + { + try + { + return JsonUtils.JSON_OBJECT_MAPPER.writeValueAsString(DEFAULT_CONFIG.getMinimumClientDriverVersionsWarned()); + } + catch (Throwable t) + { + throw new RuntimeException("Unable to serialize minimum_client_driver_versions_warned configuration: " + t.getMessage()); + } + } + + @Override + public String getMinimumClientDriverVersionsDisallowed() + { + try + { + return JsonUtils.JSON_OBJECT_MAPPER.writeValueAsString(DEFAULT_CONFIG.getMinimumClientDriverVersionsDisallowed()); + } + catch (Throwable t) + { + throw new RuntimeException("Unable to serialize minimum_client_driver_versions_disallowed configuration: " + t.getMessage()); + } + } + + @Override + public void setMinimumClientDriverVersionsWarned(String value) + { + try + { + Map map = JsonUtils.JSON_OBJECT_MAPPER.readValue(value, new TypeReference<>() {}); + GuardrailsOptions.validateAndSanitizeClientDriverVersions(map, "minimum_client_driver_versions_warned"); + DEFAULT_CONFIG.setMinimumClientDriverVersionsWarned(map); + } + catch (JsonProcessingException t) + { + throw new RuntimeException("Unable to deserialize payload for minimum_client_driver_versions_warned: " + t.getMessage()); + } + } + + @Override + public void setMinimumClientDriverVersionsDisallowed(String value) + { + try + { + Map map = JsonUtils.JSON_OBJECT_MAPPER.readValue(value, new TypeReference<>() {}); + GuardrailsOptions.validateAndSanitizeClientDriverVersions(map, "minimum_client_driver_versions_disallowed"); + DEFAULT_CONFIG.setMinimumClientDriverVersionsDisallowed(map); + } + catch (JsonProcessingException t) + { + throw new RuntimeException("Unable to deserialize minimum_client_driver_versions_disallowed: " + t.getMessage()); + } + } + private static String toCSV(Set values) { return values == null || values.isEmpty() ? "" : String.join(",", values); diff --git a/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java b/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java index 70e1b7f7d357..2d893fe3f5e2 100644 --- a/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java +++ b/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java @@ -18,6 +18,7 @@ package org.apache.cassandra.db.guardrails; +import java.util.Map; import java.util.Set; import javax.annotation.Nullable; @@ -682,4 +683,16 @@ void setMinimumTimestampThreshold(@Nullable DurationSpec.LongMicrosecondsBound w * dictionary compressor as frequently as needed, without any limits, false otherwise. */ boolean getUnsetTrainingMinFrequencyEnabled(); + + /** + * @return a map of driver name to minimum version that triggers a warning when the client's + * driver version is below the specified minimum. + */ + Map getMinimumClientDriverVersionsWarned(); + + /** + * @return a map of driver name to minimum version that triggers a failure when the client's + * driver version is below the specified minimum. + */ + Map getMinimumClientDriverVersionsDisallowed(); } diff --git a/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java b/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java index fe139b3bbb12..9b341425df29 100644 --- a/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java +++ b/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java @@ -1190,4 +1190,28 @@ public interface GuardrailsMBean * dictionary compressor as frequently as needed, without any limits, false otherwise. */ boolean getUnsetTrainingMinFrequencyEnabled(); + + /** + * @return JSON representation of the minimum client driver versions that trigger a warning. + */ + String getMinimumClientDriverVersionsWarned(); + + /** + * @return JSON representation of the minimum client driver versions that trigger a failure. + */ + String getMinimumClientDriverVersionsDisallowed(); + + /** + * Sets the minimum client driver versions that trigger a warning. + * + * @param value JSON representation of a map of driver name to minimum version string. + */ + void setMinimumClientDriverVersionsWarned(String value); + + /** + * Sets the minimum client driver versions that trigger a failure. + * + * @param value JSON representation of a map of driver name to minimum version string. + */ + void setMinimumClientDriverVersionsDisallowed(String value); } diff --git a/src/java/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommand.java b/src/java/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommand.java index 095b4b85f1ea..780112b47db6 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommand.java +++ b/src/java/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommand.java @@ -365,7 +365,9 @@ private T getNumber(String value, Function transformer, T default "SimpleStrategyEnabled", "simplestrategy_enabled", "NonPartitionRestrictedQueryEnabled", "non_partition_restricted_index_query_enabled"); - private static final Set ignored = Set.of("password_policy", "role_name_policy"); + private static final Set ignored = Set.of("password_policy", "role_name_policy", + "minimum_client_driver_versions_warned", + "minimum_client_driver_versions_disallowed"); /** * Set of guardrails which are flags, even though their suffix would suggest they are part of "values" which have warned, ignored, and disallowed sub-categories diff --git a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java index 85c531c20aa6..c76db6826e30 100644 --- a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java +++ b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java @@ -22,6 +22,7 @@ import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.db.guardrails.Guardrails; import org.apache.cassandra.service.ClientState; import org.apache.cassandra.service.QueryState; import org.apache.cassandra.transport.CBUtil; @@ -124,12 +125,15 @@ else if (compression.equals("lz4")) ClientState clientState = state.getClientState(); clientState.setClientOptions(options); String driverName = options.get(DRIVER_NAME); + String driverVersion = options.get(DRIVER_VERSION); if (null != driverName) { clientState.setDriverName(driverName); - clientState.setDriverVersion(options.get(DRIVER_VERSION)); + clientState.setDriverVersion(driverVersion); } + Guardrails.minimumClientDriverVersion.guard(driverName, driverVersion, clientState); + IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); if (authenticator.requireAuthentication()) { diff --git a/test/unit/org/apache/cassandra/db/guardrails/GuardrailClientDriverVersionTest.java b/test/unit/org/apache/cassandra/db/guardrails/GuardrailClientDriverVersionTest.java new file mode 100644 index 000000000000..4b9fc29860e7 --- /dev/null +++ b/test/unit/org/apache/cassandra/db/guardrails/GuardrailClientDriverVersionTest.java @@ -0,0 +1,311 @@ +/* + * 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.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.apache.cassandra.config.GuardrailsOptions.validateAndSanitizeClientDriverVersions; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class GuardrailClientDriverVersionTest extends GuardrailTester +{ + private String originalWarned; + private String originalDisallowed; + + @Before + public void setup() + { + originalWarned = guardrails().getMinimumClientDriverVersionsWarned(); + originalDisallowed = guardrails().getMinimumClientDriverVersionsDisallowed(); + } + + @After + public void teardown() + { + guardrails().setMinimumClientDriverVersionsWarned(originalWarned != null ? originalWarned : "{}"); + guardrails().setMinimumClientDriverVersionsDisallowed(originalDisallowed != null ? originalDisallowed : "{}"); + } + + @Test + public void testVersionEqual() + { + assertFalse(ClientDriverVersionGuardrail.isBelowMinimum("4.18.0", "4.18.0")); + } + + @Test + public void testVersionLessThan() + { + assertTrue(ClientDriverVersionGuardrail.isBelowMinimum("4.17.0", "4.18.0")); + } + + @Test + public void testVersionGreaterThan() + { + assertFalse(ClientDriverVersionGuardrail.isBelowMinimum("4.19.0", "4.18.0")); + } + + @Test + public void testVersionMajorDifference() + { + assertTrue(ClientDriverVersionGuardrail.isBelowMinimum("3.11.0", "4.0.0")); + } + + @Test + public void testVersionMinorDifference() + { + assertTrue(ClientDriverVersionGuardrail.isBelowMinimum("4.2.0", "4.18.0")); + } + + @Test + public void testVersionWithVPrefix() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"driver\":\"v2.0.1\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + // above minimum — no warn + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("driver", "v2.0.2", userClientState)); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("driver", "2.0.2", userClientState)); + + // below minimum — warn + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("driver", "v2.0.0", userClientState), + "Client driver driver is below recommended minimum version 2.0.1"); + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("driver", "2.0.0", userClientState), + "Client driver driver is below recommended minimum version 2.0.1"); + } + + @Test + public void testGuardrailNotTriggeredWhenEmpty() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "3.0.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredWhenVersionAboveMinimum() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "4.20.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForUnknownDriver() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("gocql", "1.0.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForNullDriverName() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard(null, "4.15.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForNullDriverVersion() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", null, userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForBothNullDriverAndVersion() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard(null, null, userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForEmptyDriverName() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("", "4.15.0", userClientState)); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard(" ", "4.15.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForEmptyDriverVersion() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "", userClientState)); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", " ", userClientState)); + } + + @Test + public void testGuardrailWarns() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "4.15.0", userClientState), + "Client driver DataStax Java Driver is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailFails() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\"}"); + + assertFails(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "3.11.0", userClientState), + "Client driver DataStax Java Driver is below required minimum version 4.0.0, connection rejected"); + } + + @Test + public void testGuardrailFailTakesPrecedenceOverWarn() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\"}"); + + assertFails(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "3.11.0", userClientState), + "Client driver DataStax Java Driver is below required minimum version 4.0.0, connection rejected"); + } + + @Test + public void testGuardrailWarnOnly() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\"}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "4.15.0", userClientState), + "Client driver DataStax Java Driver is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailWithVPrefixVersion() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "v4.15.0", userClientState), + "Client driver DataStax Java Driver is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailColonFormatWarns() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver:4.15.0", userClientState), + "Client driver DataStax Java Driver is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailColonFormatNull() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard(null, userClientState)); + } + + @Test + public void testGuardrailColonFormatNoSeparator() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", userClientState)); + } + + @Test + public void testJmxGetSetWarned() + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + String json = guardrails().getMinimumClientDriverVersionsWarned(); + assertTrue(json.contains("DataStax Java Driver")); + assertTrue(json.contains("4.18.0")); + } + + @Test + public void testJmxGetSetDisallowed() + { + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\",\"DataStax Python Driver\":\"3.0.0\"}"); + String json = guardrails().getMinimumClientDriverVersionsDisallowed(); + assertTrue(json.contains("DataStax Java Driver")); + assertTrue(json.contains("DataStax Python Driver")); + } + + @Test + public void testValidateValidVersions() + { + validateAndSanitizeClientDriverVersions(new HashMap<>(Map.of("driver", "4.18.0")), "test"); + validateAndSanitizeClientDriverVersions(new HashMap<>(Map.of("driver", "v4.18.0")), "test"); + validateAndSanitizeClientDriverVersions(new HashMap<>(Map.of("driver", "V4.18.0")), "test"); + validateAndSanitizeClientDriverVersions(new HashMap<>(Map.of("a", "1.0.0", "b", "2.0.0")), "test"); + } + + @Test + public void testValidateNullMap() + { + validateAndSanitizeClientDriverVersions(null, "test"); + } + + @Test + public void testValidateEmptyMap() + { + validateAndSanitizeClientDriverVersions(Collections.emptyMap(), "test"); + } + + @Test + public void testValidateInvalidVersion() + { + assertThatThrownBy(() -> validateAndSanitizeClientDriverVersions(Map.of("driver", "not-a-version"), "test")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testValidateEmptyVersion() + { + assertThatThrownBy(() -> validateAndSanitizeClientDriverVersions(Map.of("driver", ""), "test")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testValidateBlankVersion() + { + assertThatThrownBy(() -> validateAndSanitizeClientDriverVersions(Map.of("driver", " "), "test")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testValidateMixedValidAndInvalid() + { + Map map = new HashMap<>(); + map.put("good-driver", "4.18.0"); + map.put("bad-driver", "xyz"); + assertThatThrownBy(() -> validateAndSanitizeClientDriverVersions(map, "test")) + .isInstanceOf(IllegalArgumentException.class); + } +}