From 975555b1cfd48964fcfdb1d9893018a4603445bc Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 25 Mar 2025 14:52:14 +0100 Subject: [PATCH] needs some caching to avoid too many lookups --- .../encryption/MongoEncryptionConverter.java | 17 ++-- .../core/encryption/EncryptionOptions.java | 94 +++++++------------ .../encryption/MongoClientEncryption.java | 59 +++++++++++- .../core/encryption/RangeEncryptionTests.java | 81 +++++++++++++++- 4 files changed, 176 insertions(+), 75 deletions(-) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java index 2136e4021..5712c13d1 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java @@ -15,10 +15,9 @@ */ package org.springframework.data.mongodb.core.convert.encryption; -import static java.util.Arrays.*; -import static java.util.Collections.*; -import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*; -import static org.springframework.data.mongodb.core.encryption.EncryptionOptions.*; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.springframework.data.mongodb.core.encryption.EncryptionOptions.QueryableEncryptionOptions; import java.util.Collection; import java.util.LinkedHashMap; @@ -188,12 +187,12 @@ public class MongoEncryptionConverter implements EncryptingConverter= 0) { queryableEncryptionOptions = queryableEncryptionOptions - .contentionFactor(rangeEncryptedAnnotation.contentionFactor()); + .contentionFactor(rangeEncryptedAnnotation.contentionFactor()); } boolean isPartOfARangeQuery = fieldNameAndQueryOperator != null; @@ -240,9 +239,9 @@ public class MongoEncryptionConverter implements EncryptingConverterThe mongodb-crypt {@code encryptExpression} has strict formatting requirements so this method - * ensures these requirements are met and then picks out and returns just the value for use with a range query. + *

+ * The mongodb-crypt {@code encryptExpression} has strict formatting requirements so this method ensures these + * requirements are met and then picks out and returns just the value for use with a range query. * * @param fieldNameAndQueryOperator field name and query operator * @param value the value of the expression to be encrypted diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java index 5affbeddb..5e6841dcd 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java @@ -15,21 +15,21 @@ */ package org.springframework.data.mongodb.core.encryption; +import java.util.Map; import java.util.Objects; -import java.util.Optional; -import com.mongodb.client.model.vault.RangeOptions; -import org.bson.Document; -import org.springframework.data.mongodb.core.FindAndReplaceOptions; import org.springframework.data.mongodb.util.BsonUtils; -import org.springframework.data.util.Optionals; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; +import com.mongodb.client.model.vault.RangeOptions; + /** - * Options, like the {@link #algorithm()}, to apply when encrypting values. - * + * Options used to provide additional information when {@link Encryption encrypting} values. like the + * {@link #algorithm()} to be used. + * * @author Christoph Strobl * @author Ross Lawley * @since 4.1 @@ -45,6 +45,7 @@ public class EncryptionOptions { } public EncryptionOptions(String algorithm, EncryptionKey key, QueryableEncryptionOptions queryableEncryptionOptions) { + Assert.hasText(algorithm, "Algorithm must not be empty"); Assert.notNull(key, "EncryptionKey must not be empty"); Assert.notNull(key, "QueryableEncryptionOptions must not be empty"); @@ -107,20 +108,23 @@ public class EncryptionOptions { * Options, like the {@link #getQueryType()}, to apply when encrypting queryable values. * * @author Ross Lawley + * @author Christoph Strobl + * @since 4.5 */ public static class QueryableEncryptionOptions { - private static final QueryableEncryptionOptions NONE = new QueryableEncryptionOptions(null, null, null); + public static final QueryableEncryptionOptions NONE = new QueryableEncryptionOptions(null, null, Map.of()); private final @Nullable String queryType; private final @Nullable Long contentionFactor; - private final @Nullable Document rangeOptions; + private final Map attributes; private QueryableEncryptionOptions(@Nullable String queryType, @Nullable Long contentionFactor, - @Nullable Document rangeOptions) { + Map attributes) { + this.queryType = queryType; this.contentionFactor = contentionFactor; - this.rangeOptions = rangeOptions; + this.attributes = attributes; } /** @@ -139,7 +143,7 @@ public class EncryptionOptions { * @return new instance of {@link QueryableEncryptionOptions}. */ public QueryableEncryptionOptions queryType(@Nullable String queryType) { - return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions); + return new QueryableEncryptionOptions(queryType, contentionFactor, attributes); } /** @@ -149,89 +153,57 @@ public class EncryptionOptions { * @return new instance of {@link QueryableEncryptionOptions}. */ public QueryableEncryptionOptions contentionFactor(@Nullable Long contentionFactor) { - return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions); + return new QueryableEncryptionOptions(queryType, contentionFactor, attributes); } /** * Define the {@code rangeOptions} to be used for queryable document encryption. * - * @param rangeOptions can be {@literal null}. + * @param attributes can be {@literal null}. * @return new instance of {@link QueryableEncryptionOptions}. */ - public QueryableEncryptionOptions rangeOptions(@Nullable Document rangeOptions) { - return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions); + public QueryableEncryptionOptions attributes(Map attributes) { + return new QueryableEncryptionOptions(queryType, contentionFactor, attributes); } /** * Get the {@code queryType} to apply. * - * @return {@link Optional#empty()} if not set. + * @return {@literal null} if not set. */ - public Optional getQueryType() { - return Optional.ofNullable(queryType); + public @Nullable String getQueryType() { + return queryType; } /** * Get the {@code contentionFactor} to apply. * - * @return {@link Optional#empty()} if not set. + * @return {@literal null} if not set. */ - public Optional getContentionFactor() { - return Optional.ofNullable(contentionFactor); + public @Nullable Long getContentionFactor() { + return contentionFactor; } /** * Get the {@code rangeOptions} to apply. * - * @return {@link Optional#empty()} if not set. + * @return never {@literal null}. */ - public Optional getRangeOptions() { - if (rangeOptions == null) { - return Optional.empty(); - } - RangeOptions encryptionRangeOptions = new RangeOptions(); - - if (rangeOptions.containsKey("min")) { - encryptionRangeOptions.min(BsonUtils.simpleToBsonValue(rangeOptions.get("min"))); - } - if (rangeOptions.containsKey("max")) { - encryptionRangeOptions.max(BsonUtils.simpleToBsonValue(rangeOptions.get("max"))); - } - if (rangeOptions.containsKey("trimFactor")) { - Object trimFactor = rangeOptions.get("trimFactor"); - Assert.isInstanceOf(Integer.class, trimFactor, () -> String - .format("Expected to find a %s but it turned out to be %s.", Integer.class, trimFactor.getClass())); - - encryptionRangeOptions.trimFactor((Integer) trimFactor); - } - - if (rangeOptions.containsKey("sparsity")) { - Object sparsity = rangeOptions.get("sparsity"); - Assert.isInstanceOf(Number.class, sparsity, - () -> String.format("Expected to find a %s but it turned out to be %s.", Long.class, sparsity.getClass())); - encryptionRangeOptions.sparsity(((Number) sparsity).longValue()); - } - - if (rangeOptions.containsKey("precision")) { - Object precision = rangeOptions.get("precision"); - Assert.isInstanceOf(Number.class, precision, () -> String - .format("Expected to find a %s but it turned out to be %s.", Integer.class, precision.getClass())); - encryptionRangeOptions.precision(((Number) precision).intValue()); - } - return Optional.of(encryptionRangeOptions); + public Map getAttributes() { + return Map.copyOf(attributes); } /** * @return {@literal true} if no arguments set. */ boolean isEmpty() { - return !Optionals.isAnyPresent(getQueryType(), getContentionFactor(), getRangeOptions()); + return getQueryType() == null && getContentionFactor() == null && getAttributes().isEmpty(); } @Override public String toString() { return "QueryableEncryptionOptions{" + "queryType='" + queryType + '\'' + ", contentionFactor=" + contentionFactor - + ", rangeOptions=" + rangeOptions + '}'; + + ", attributes=" + attributes + '}'; } @Override @@ -251,12 +223,12 @@ public class EncryptionOptions { if (!ObjectUtils.nullSafeEquals(contentionFactor, that.contentionFactor)) { return false; } - return ObjectUtils.nullSafeEquals(rangeOptions, that.rangeOptions); + return ObjectUtils.nullSafeEquals(attributes, that.attributes); } @Override public int hashCode() { - return Objects.hash(queryType, contentionFactor, rangeOptions); + return Objects.hash(queryType, contentionFactor, attributes); } } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java index 4d250fba0..07dfd1817 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java @@ -15,15 +15,19 @@ */ package org.springframework.data.mongodb.core.encryption; +import java.util.Map; import java.util.function.Supplier; import org.bson.BsonBinary; import org.bson.BsonDocument; import org.bson.BsonValue; import org.springframework.data.mongodb.core.encryption.EncryptionKey.Type; +import org.springframework.data.mongodb.core.encryption.EncryptionOptions.QueryableEncryptionOptions; +import org.springframework.data.mongodb.util.BsonUtils; import org.springframework.util.Assert; import com.mongodb.client.model.vault.EncryptOptions; +import com.mongodb.client.model.vault.RangeOptions; import com.mongodb.client.vault.ClientEncryption; /** @@ -74,6 +78,7 @@ public class MongoClientEncryption implements Encryption } private EncryptOptions createEncryptOptions(EncryptionOptions options) { + EncryptOptions encryptOptions = new EncryptOptions(options.algorithm()); if (Type.ALT.equals(options.key().type())) { @@ -82,10 +87,58 @@ public class MongoClientEncryption implements Encryption encryptOptions = encryptOptions.keyId((BsonBinary) options.key().value()); } - options.queryableEncryptionOptions().getQueryType().map(encryptOptions::queryType); - options.queryableEncryptionOptions().getContentionFactor().map(encryptOptions::contentionFactor); - options.queryableEncryptionOptions().getRangeOptions().map(encryptOptions::rangeOptions); + if (options.queryableEncryptionOptions().isEmpty()) { + return encryptOptions; + } + + QueryableEncryptionOptions qeOptions = options.queryableEncryptionOptions(); + if (qeOptions.getQueryType() != null) { + encryptOptions.queryType(qeOptions.getQueryType()); + } + if (qeOptions.getContentionFactor() != null) { + encryptOptions.contentionFactor(qeOptions.getContentionFactor()); + } + if (!qeOptions.getAttributes().isEmpty()) { + encryptOptions.rangeOptions(rangeOptions(qeOptions.getAttributes())); + } return encryptOptions; } + protected RangeOptions rangeOptions(Map attributes) { + + RangeOptions encryptionRangeOptions = new RangeOptions(); + if (attributes.isEmpty()) { + return encryptionRangeOptions; + } + + if (attributes.containsKey("min")) { + encryptionRangeOptions.min(BsonUtils.simpleToBsonValue(attributes.get("min"))); + } + if (attributes.containsKey("max")) { + encryptionRangeOptions.max(BsonUtils.simpleToBsonValue(attributes.get("max"))); + } + if (attributes.containsKey("trimFactor")) { + Object trimFactor = attributes.get("trimFactor"); + Assert.isInstanceOf(Integer.class, trimFactor, () -> String + .format("Expected to find a %s but it turned out to be %s.", Integer.class, trimFactor.getClass())); + + encryptionRangeOptions.trimFactor((Integer) trimFactor); + } + + if (attributes.containsKey("sparsity")) { + Object sparsity = attributes.get("sparsity"); + Assert.isInstanceOf(Number.class, sparsity, + () -> String.format("Expected to find a %s but it turned out to be %s.", Long.class, sparsity.getClass())); + encryptionRangeOptions.sparsity(((Number) sparsity).longValue()); + } + + if (attributes.containsKey("precision")) { + Object precision = attributes.get("precision"); + Assert.isInstanceOf(Number.class, precision, () -> String + .format("Expected to find a %s but it turned out to be %s.", Integer.class, precision.getClass())); + encryptionRangeOptions.precision(((Number) precision).intValue()); + } + return encryptionRangeOptions; + } + } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java index 4cfb84087..613e1cd7e 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java @@ -16,6 +16,8 @@ package org.springframework.data.mongodb.core.encryption; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.data.mongodb.core.query.Criteria.where; import java.security.SecureRandom; import java.util.List; @@ -38,6 +40,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.data.convert.PropertyValueConverterFactory; +import org.springframework.data.convert.ValueConverter; import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; import org.springframework.data.mongodb.core.CollectionOptions; import org.springframework.data.mongodb.core.CollectionOptions.EncryptedFieldsOptions; @@ -47,6 +50,7 @@ import org.springframework.data.mongodb.core.convert.MongoCustomConversions.Mong import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.mapping.RangeEncrypted; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.schema.MongoJsonSchema; import org.springframework.data.mongodb.test.util.EnableIfMongoServerVersion; import org.springframework.data.mongodb.test.util.EnableIfReplicaSetAvailable; @@ -123,6 +127,74 @@ class RangeEncryptionTests { assertThat(result).containsEntry("encryptedInt", 101); } + @Test + void canLesserThanEqualMatchRangeEncryptedField() { + Person source = createPerson(); + template.insert(source); + + Person loaded = template.query(Person.class).matching(where("encryptedInt").lte(source.encryptedInt)).firstValue(); + assertThat(loaded).isEqualTo(source); + } + + @Test + void canRangeMatchRangeEncryptedField() { + Person source = createPerson(); + template.insert(source); + + Query q = Query.query(where("encryptedLong").lte(1001L).gte(1001L)); + q.fields().exclude("__safeContent__"); + Person loaded = template.query(Person.class).matching(q).firstValue(); + assertThat(loaded).isEqualTo(source); + } + + @Test + void canUpdateRangeEncryptedField() { + Person source = createPerson(); + template.insert(source); + + source.encryptedInt = 123; + source.encryptedLong = 9999L; + template.save(source); + + Person loaded = template.query(Person.class).matching(where("id").is(source.id)).firstValue(); + assertThat(loaded).isEqualTo(source); + } + + @Test + void errorsWhenUsingNonRangeOperatorEqOnRangeEncryptedField() { + Person source = createPerson(); + template.insert(source); + + assertThatThrownBy( + () -> template.query(Person.class).matching(where("encryptedInt").is(source.encryptedInt)).firstValue()) + .isInstanceOf(AssertionError.class) + .hasMessageStartingWith("Not a valid range query. Querying a range encrypted field but " + + "the query operator '$eq' for field path 'encryptedInt' is not a range query."); + + } + + @Test + void errorsWhenUsingNonRangeOperatorInOnRangeEncryptedField() { + Person source = createPerson(); + template.insert(source); + + assertThatThrownBy( + () -> template.query(Person.class).matching(where("encryptedLong").in(1001L, 9999L)).firstValue()) + .isInstanceOf(AssertionError.class) + .hasMessageStartingWith("Not a valid range query. Querying a range encrypted field but " + + "the query operator '$in' for field path 'encryptedLong' is not a range query."); + + } + + private Person createPerson() { + Person source = new Person(); + source.id = "id-1"; + source.name = "it'se me mario!"; + source.encryptedInt = 101; + source.encryptedLong = 1001L; + return source; + } + protected static class EncryptionConfig extends AbstractMongoClientConfiguration { private static final String LOCAL_KMS_PROVIDER = "local"; @@ -277,10 +349,15 @@ class RangeEncryptionTests { String id; String name; + @ValueConverter(MongoEncryptionConverter.class) @RangeEncrypted(contentionFactor = 0L, - rangeOptions = "{\"min\": 0, \"max\": 200, \"trimFactor\": 1, \"sparsity\": 1}") Integer encryptedInt; + rangeOptions = "{\"min\": 0, \"max\": 200, \"trimFactor\": 1, \"sparsity\": 1}") // + Integer encryptedInt; + + @ValueConverter(MongoEncryptionConverter.class) @RangeEncrypted(contentionFactor = 0L, - rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") Long encryptedLong; + rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") // + Long encryptedLong; public String getId() { return this.id;