From 94d2f6f3fa768cf99d4df5c0b0e039db603eba2f Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Fri, 24 Jan 2025 13:13:20 +0000 Subject: [PATCH 1/3] Adding queryable encryption range support Supports range style queries for encrypted fields --- .../data/mongodb/core/CollectionOptions.java | 56 ++- .../mongodb/core/EncryptionAlgorithms.java | 2 + .../data/mongodb/core/EntityOperations.java | 2 + .../core/convert/MongoConversionContext.java | 24 +- .../mongodb/core/convert/QueryMapper.java | 22 +- .../encryption/ExplicitEncryptionContext.java | 7 + .../encryption/MongoEncryptionConverter.java | 83 +++- .../mongodb/core/encryption/Encryption.java | 15 + .../core/encryption/EncryptionContext.java | 10 + .../core/encryption/EncryptionOptions.java | 188 ++++++++- .../encryption/MongoClientEncryption.java | 23 +- .../core/mapping/ExplicitEncrypted.java | 23 +- .../util/MongoCompatibilityAdapter.java | 27 ++ .../core/encryption/RangeEncryptionTests.java | 373 ++++++++++++++++++ 14 files changed, 829 insertions(+), 26 deletions(-) create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java index d627ba2468..97cbfb536d 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java @@ -19,6 +19,7 @@ import java.util.Optional; import java.util.function.Function; +import org.bson.conversions.Bson; import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.schema.MongoJsonSchema; @@ -41,6 +42,7 @@ * @author Mark Paluch * @author Andreas Zink * @author Ben Foster + * @author Ross Lawley */ public class CollectionOptions { @@ -51,10 +53,11 @@ public class CollectionOptions { private ValidationOptions validationOptions; private @Nullable TimeSeriesOptions timeSeriesOptions; private @Nullable CollectionChangeStreamOptions changeStreamOptions; + private @Nullable Bson encryptedFields; private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nullable Boolean capped, @Nullable Collation collation, ValidationOptions validationOptions, @Nullable TimeSeriesOptions timeSeriesOptions, - @Nullable CollectionChangeStreamOptions changeStreamOptions) { + @Nullable CollectionChangeStreamOptions changeStreamOptions, @Nullable Bson encryptedFields) { this.maxDocuments = maxDocuments; this.size = size; @@ -63,6 +66,7 @@ private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nul this.validationOptions = validationOptions; this.timeSeriesOptions = timeSeriesOptions; this.changeStreamOptions = changeStreamOptions; + this.encryptedFields = encryptedFields; } /** @@ -76,7 +80,7 @@ public static CollectionOptions just(Collation collation) { Assert.notNull(collation, "Collation must not be null"); - return new CollectionOptions(null, null, null, collation, ValidationOptions.none(), null, null); + return new CollectionOptions(null, null, null, collation, ValidationOptions.none(), null, null, null); } /** @@ -86,7 +90,7 @@ public static CollectionOptions just(Collation collation) { * @since 2.0 */ public static CollectionOptions empty() { - return new CollectionOptions(null, null, null, null, ValidationOptions.none(), null, null); + return new CollectionOptions(null, null, null, null, ValidationOptions.none(), null, null, null); } /** @@ -136,7 +140,7 @@ public static CollectionOptions emitChangedRevisions() { */ public CollectionOptions capped() { return new CollectionOptions(size, maxDocuments, true, collation, validationOptions, timeSeriesOptions, - changeStreamOptions); + changeStreamOptions, encryptedFields); } /** @@ -148,7 +152,7 @@ public CollectionOptions capped() { */ public CollectionOptions maxDocuments(long maxDocuments) { return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, - changeStreamOptions); + changeStreamOptions, encryptedFields); } /** @@ -160,7 +164,7 @@ public CollectionOptions maxDocuments(long maxDocuments) { */ public CollectionOptions size(long size) { return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, - changeStreamOptions); + changeStreamOptions, encryptedFields); } /** @@ -172,7 +176,7 @@ public CollectionOptions size(long size) { */ public CollectionOptions collation(@Nullable Collation collation) { return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, - changeStreamOptions); + changeStreamOptions, encryptedFields); } /** @@ -293,7 +297,7 @@ public CollectionOptions validation(ValidationOptions validationOptions) { Assert.notNull(validationOptions, "ValidationOptions must not be null"); return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, - changeStreamOptions); + changeStreamOptions, encryptedFields); } /** @@ -307,7 +311,7 @@ public CollectionOptions timeSeries(TimeSeriesOptions timeSeriesOptions) { Assert.notNull(timeSeriesOptions, "TimeSeriesOptions must not be null"); return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, - changeStreamOptions); + changeStreamOptions, encryptedFields); } /** @@ -321,7 +325,19 @@ public CollectionOptions changeStream(CollectionChangeStreamOptions changeStream Assert.notNull(changeStreamOptions, "ChangeStreamOptions must not be null"); return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, - changeStreamOptions); + changeStreamOptions, encryptedFields); + } + + /** + * Create new {@link CollectionOptions} with the given {@code encryptedFields}. + * + * @param encryptedFields can be null + * @return new instance of {@link CollectionOptions}. + * @since 4.5.0 + */ + public CollectionOptions encryptedFields(@Nullable Bson encryptedFields) { + return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, + changeStreamOptions, encryptedFields); } /** @@ -392,12 +408,22 @@ public Optional getChangeStreamOptions() { return Optional.ofNullable(changeStreamOptions); } + /** + * Get the {@code encryptedFields} if available. + * + * @return {@link Optional#empty()} if not specified. + * @since 4.5.0 + */ + public Optional getEncryptedFields() { + return Optional.ofNullable(encryptedFields); + } + @Override public String toString() { return "CollectionOptions{" + "maxDocuments=" + maxDocuments + ", size=" + size + ", capped=" + capped + ", collation=" + collation + ", validationOptions=" + validationOptions + ", timeSeriesOptions=" - + timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", disableValidation=" - + disableValidation() + ", strictValidation=" + strictValidation() + ", moderateValidation=" + + timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", encryptedFields=" + encryptedFields + + ", disableValidation=" + disableValidation() + ", strictValidation=" + strictValidation() + ", moderateValidation=" + moderateValidation() + ", warnOnValidationError=" + warnOnValidationError() + ", failOnValidationError=" + failOnValidationError() + '}'; } @@ -431,7 +457,10 @@ public boolean equals(@Nullable Object o) { if (!ObjectUtils.nullSafeEquals(timeSeriesOptions, that.timeSeriesOptions)) { return false; } - return ObjectUtils.nullSafeEquals(changeStreamOptions, that.changeStreamOptions); + if (!ObjectUtils.nullSafeEquals(changeStreamOptions, that.changeStreamOptions)) { + return false; + } + return ObjectUtils.nullSafeEquals(encryptedFields, that.encryptedFields); } @Override @@ -443,6 +472,7 @@ public int hashCode() { result = 31 * result + ObjectUtils.nullSafeHashCode(validationOptions); result = 31 * result + ObjectUtils.nullSafeHashCode(timeSeriesOptions); result = 31 * result + ObjectUtils.nullSafeHashCode(changeStreamOptions); + result = 31 * result + ObjectUtils.nullSafeHashCode(encryptedFields); return result; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java index f64391e8cd..601b6898b8 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java @@ -19,11 +19,13 @@ * Encryption algorithms supported by MongoDB Client Side Field Level Encryption. * * @author Christoph Strobl + * @author Ross Lawley * @since 3.3 */ public final class EncryptionAlgorithms { public static final String AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic"; public static final String AEAD_AES_256_CBC_HMAC_SHA_512_Random = "AEAD_AES_256_CBC_HMAC_SHA_512-Random"; + public static final String RANGE = "Range"; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java index 65a5131dd1..b7a2380ce9 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java @@ -83,6 +83,7 @@ * @author Mark Paluch * @author Christoph Strobl * @author Ben Foster + * @author Ross Lawley * @since 2.1 * @see MongoTemplate * @see ReactiveMongoTemplate @@ -378,6 +379,7 @@ public CreateCollectionOptions convertToCreateCollectionOptions(@Nullable Collec collectionOptions.getChangeStreamOptions().ifPresent(it -> result .changeStreamPreAndPostImagesOptions(new ChangeStreamPreAndPostImagesOptions(it.getPreAndPostImages()))); + collectionOptions.getEncryptedFields().ifPresent(result::encryptedFields); return result; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java index 5fde0acddd..acc8dfacb7 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java @@ -28,29 +28,44 @@ * {@link ValueConversionContext} that allows to delegate read/write to an underlying {@link MongoConverter}. * * @author Christoph Strobl + * @author Ross Lawley * @since 3.4 */ public class MongoConversionContext implements ValueConversionContext { private final PropertyValueProvider accessor; // TODO: generics - private final @Nullable MongoPersistentProperty persistentProperty; private final MongoConverter mongoConverter; + @Nullable private final MongoPersistentProperty persistentProperty; @Nullable private final SpELContext spELContext; + @Nullable private final String fieldNameAndQueryOperator; public MongoConversionContext(PropertyValueProvider accessor, @Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) { - this(accessor, persistentProperty, mongoConverter, null); + this(accessor, persistentProperty, mongoConverter, null, null); } public MongoConversionContext(PropertyValueProvider accessor, @Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter, @Nullable SpELContext spELContext) { + this(accessor, persistentProperty, mongoConverter, spELContext, null); + } + + public MongoConversionContext(PropertyValueProvider accessor, + @Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter, + @Nullable String fieldNameAndQueryOperator) { + this(accessor, persistentProperty, mongoConverter, null, fieldNameAndQueryOperator); + } + + public MongoConversionContext(PropertyValueProvider accessor, + @Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter, + @Nullable SpELContext spELContext, @Nullable String fieldNameAndQueryOperator) { this.accessor = accessor; this.persistentProperty = persistentProperty; this.mongoConverter = mongoConverter; this.spELContext = spELContext; + this.fieldNameAndQueryOperator = fieldNameAndQueryOperator; } @Override @@ -84,4 +99,9 @@ public T read(@Nullable Object value, TypeInformation target) { public SpELContext getSpELContext() { return spELContext; } + + @Nullable + public String getFieldNameAndQueryOperator() { + return fieldNameAndQueryOperator; + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java index 39559b9979..45c036e8af 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java @@ -88,6 +88,7 @@ * @author David Julia * @author Divya Srivastava * @author Gyungrai Wang + * @author Ross Lawley */ public class QueryMapper { @@ -670,9 +671,23 @@ private Object convertValue(Field documentField, Object sourceValue, Object valu PropertyValueConverter> valueConverter) { MongoPersistentProperty property = documentField.getProperty(); + + String fieldNameAndQueryOperator = property != null && !property.getFieldName().equals(documentField.name) + ? property.getFieldName() + "." + documentField.name + : documentField.name; + MongoConversionContext conversionContext = new MongoConversionContext(NoPropertyPropertyValueProvider.INSTANCE, - property, converter); + property, converter, fieldNameAndQueryOperator); + + return convertValueWithConversionContext(documentField, sourceValue, value, valueConverter, conversionContext); + } + @Nullable + private Object convertValueWithConversionContext(Field documentField, Object sourceValue, Object value, + PropertyValueConverter> valueConverter, + MongoConversionContext conversionContext) { + + MongoPersistentProperty property = documentField.getProperty(); /* might be an $in clause with multiple entries */ if (property != null && !property.isCollectionLike() && sourceValue instanceof Collection collection) { @@ -692,7 +707,10 @@ private Object convertValue(Field documentField, Object sourceValue, Object valu return BsonUtils.mapValues(document, (key, val) -> { if (isKeyword(key)) { - return getMappedValue(documentField, val); + MongoConversionContext fieldConversionContext = new MongoConversionContext( + NoPropertyPropertyValueProvider.INSTANCE, property, converter, + conversionContext.getFieldNameAndQueryOperator() + "." + key); + return convertValueWithConversionContext(documentField, val, val, valueConverter, fieldConversionContext); } return val; }); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java index f8d814fee4..e78feba732 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java @@ -26,6 +26,7 @@ * Default {@link EncryptionContext} implementation. * * @author Christoph Strobl + * @author Ross Lawley * @since 4.1 */ class ExplicitEncryptionContext implements EncryptionContext { @@ -66,4 +67,10 @@ public T read(@Nullable Object value, TypeInformation target) { public T write(@Nullable Object value, TypeInformation target) { return conversionContext.write(value, target); } + + @Override + @Nullable + public String getFieldNameAndQueryOperator() { + return conversionContext.getFieldNameAndQueryOperator(); + } } 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 1ce24b25fe..e3fdbe37cf 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,8 +15,14 @@ */ 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 java.util.Collection; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.apache.commons.logging.Log; @@ -31,9 +37,11 @@ import org.springframework.data.mongodb.core.convert.MongoConversionContext; import org.springframework.data.mongodb.core.encryption.Encryption; import org.springframework.data.mongodb.core.encryption.EncryptionContext; +import org.springframework.data.mongodb.core.encryption.EncryptionKey; import org.springframework.data.mongodb.core.encryption.EncryptionKeyResolver; import org.springframework.data.mongodb.core.encryption.EncryptionOptions; import org.springframework.data.mongodb.core.mapping.Encrypted; +import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.util.BsonUtils; import org.springframework.lang.Nullable; @@ -44,11 +52,14 @@ * {@link Encrypted @Encrypted} to provide key and algorithm metadata. * * @author Christoph Strobl + * @author Ross Lawley * @since 4.1 */ public class MongoEncryptionConverter implements EncryptingConverter { private static final Log LOGGER = LogFactory.getLog(MongoEncryptionConverter.class); + private static final String EQUALITY_OPERATOR = "$eq"; + private static final List RANGE_OPERATORS = asList("$gt", "$gte", "$lt", "$lte"); private final Encryption encryption; private final EncryptionKeyResolver keyResolver; @@ -161,8 +172,42 @@ public Object encrypt(Object value, EncryptionContext context) { getProperty(context).getOwner().getName(), getProperty(context).getName())); } - EncryptionOptions encryptionOptions = new EncryptionOptions(annotation.algorithm(), keyResolver.getKey(context)); + boolean encryptExpression = false; + String algorithm = annotation.algorithm(); + EncryptionKey key = keyResolver.getKey(context); + EncryptionOptions encryptionOptions = new EncryptionOptions(algorithm, key); + String fieldNameAndQueryOperator = context.getFieldNameAndQueryOperator(); + + ExplicitEncrypted explicitEncryptedAnnotation = persistentProperty.findAnnotation(ExplicitEncrypted.class); + if (explicitEncryptedAnnotation != null) { + QueryableEncryptionOptions queryableEncryptionOptions = QueryableEncryptionOptions.none(); + String rangeOptions = explicitEncryptedAnnotation.rangeOptions(); + if (!rangeOptions.isEmpty()) { + queryableEncryptionOptions = queryableEncryptionOptions.rangeOptions(Document.parse(rangeOptions)); + } + if (explicitEncryptedAnnotation.contentionFactor() >= 0) { + queryableEncryptionOptions = queryableEncryptionOptions + .contentionFactor(explicitEncryptedAnnotation.contentionFactor()); + } + + boolean isPartOfARangeQuery = algorithm.equalsIgnoreCase(RANGE) && fieldNameAndQueryOperator != null; + if (isPartOfARangeQuery) { + encryptExpression = true; + queryableEncryptionOptions = queryableEncryptionOptions.queryType("range"); + } + encryptionOptions = new EncryptionOptions(algorithm, key, queryableEncryptionOptions); + } + + if (encryptExpression) { + return encryptExpression(fieldNameAndQueryOperator, value, encryptionOptions); + } else { + return encryptValue(value, context, persistentProperty, encryptionOptions); + } + } + + private BsonBinary encryptValue(Object value, EncryptionContext context, MongoPersistentProperty persistentProperty, + EncryptionOptions encryptionOptions) { if (!persistentProperty.isEntity()) { if (persistentProperty.isCollectionLike()) { @@ -187,6 +232,42 @@ public Object encrypt(Object value, EncryptionContext context) { return encryption.encrypt(BsonUtils.simpleToBsonValue(write), encryptionOptions); } + /** + * Encrypts a range query expression. + * + *

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 + * @param encryptionOptions the options + * @return the encrypted range value for use in a range query + */ + private BsonValue encryptExpression(String fieldNameAndQueryOperator, Object value, + EncryptionOptions encryptionOptions) { + BsonValue doc = BsonUtils.simpleToBsonValue(value); + + String fieldName = fieldNameAndQueryOperator; + String queryOperator = EQUALITY_OPERATOR; + + int pos = fieldNameAndQueryOperator.lastIndexOf(".$"); + if (pos > -1) { + fieldName = fieldNameAndQueryOperator.substring(0, pos); + queryOperator = fieldNameAndQueryOperator.substring(pos + 1); + } + + if (!RANGE_OPERATORS.contains(queryOperator)) { + throw new AssertionError(String.format("Not a valid range query. Querying a range encrypted field but the " + + "query operator '%s' for field path '%s' is not a range query.", queryOperator, fieldName)); + } + + BsonDocument encryptExpression = new BsonDocument("$and", + new BsonArray(singletonList(new BsonDocument(fieldName, new BsonDocument(queryOperator, doc))))); + + BsonDocument result = encryption.encryptExpression(encryptExpression, encryptionOptions); + return result.getArray("$and").get(0).asDocument().getDocument(fieldName).getBinary(queryOperator); + } + private BsonValue collectionLikeToBsonValue(Object value, MongoPersistentProperty property, EncryptionContext context) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java index 5645c1e416..16202598f5 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java @@ -15,10 +15,13 @@ */ package org.springframework.data.mongodb.core.encryption; +import org.bson.BsonDocument; + /** * Component responsible for encrypting and decrypting values. * * @author Christoph Strobl + * @author Ross Lawley * @since 4.1 */ public interface Encryption { @@ -40,4 +43,16 @@ public interface Encryption { */ S decrypt(T value); + /** + * Encrypt the given expression. + * + * @param value must not be {@literal null}. + * @param options must not be {@literal null}. + * @return the encrypted expression. + * @since 4.5.0 + */ + default BsonDocument encryptExpression(BsonDocument value, EncryptionOptions options) { + throw new UnsupportedOperationException("Unsupported encryption method"); + } + } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java index 89beaadedb..1643e2f950 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java @@ -25,6 +25,7 @@ * Context to encapsulate encryption for a specific {@link MongoPersistentProperty}. * * @author Christoph Strobl + * @author Ross Lawley * @since 4.1 */ public interface EncryptionContext { @@ -128,4 +129,13 @@ default T write(@Nullable Object value, Class target) { EvaluationContext getEvaluationContext(Object source); + /** + * The field name and field query operator + * + * @return can be {@literal null}. + */ + @Nullable + default String getFieldNameAndQueryOperator() { + return null; + } } 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 fe01cfa8ba..f2ad46e54f 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,6 +15,16 @@ */ package org.springframework.data.mongodb.core.encryption; +import static org.springframework.data.mongodb.util.MongoCompatibilityAdapter.*; + +import java.util.Objects; +import java.util.Optional; + +import com.mongodb.client.model.vault.RangeOptions; +import org.bson.Document; +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.ObjectUtils; @@ -22,20 +32,27 @@ * Options, like the {@link #algorithm()}, to apply when encrypting values. * * @author Christoph Strobl + * @author Ross Lawley * @since 4.1 */ public class EncryptionOptions { private final String algorithm; private final EncryptionKey key; + private final QueryableEncryptionOptions queryableEncryptionOptions; public EncryptionOptions(String algorithm, EncryptionKey key) { + this(algorithm, key, QueryableEncryptionOptions.NONE); + } + 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"); this.key = key; this.algorithm = algorithm; + this.queryableEncryptionOptions = queryableEncryptionOptions; } public EncryptionKey key() { @@ -46,6 +63,10 @@ public String algorithm() { return algorithm; } + public QueryableEncryptionOptions queryableEncryptionOptions() { + return queryableEncryptionOptions; + } + @Override public boolean equals(Object o) { @@ -61,7 +82,11 @@ public boolean equals(Object o) { if (!ObjectUtils.nullSafeEquals(algorithm, that.algorithm)) { return false; } - return ObjectUtils.nullSafeEquals(key, that.key); + if (!ObjectUtils.nullSafeEquals(key, that.key)) { + return false; + } + + return ObjectUtils.nullSafeEquals(queryableEncryptionOptions, that.queryableEncryptionOptions); } @Override @@ -69,11 +94,170 @@ public int hashCode() { int result = ObjectUtils.nullSafeHashCode(algorithm); result = 31 * result + ObjectUtils.nullSafeHashCode(key); + result = 31 * result + ObjectUtils.nullSafeHashCode(queryableEncryptionOptions); return result; } @Override public String toString() { - return "EncryptionOptions{" + "algorithm='" + algorithm + '\'' + ", key=" + key + '}'; + return "EncryptionOptions{" + "algorithm='" + algorithm + '\'' + ", key=" + key + ", queryableEncryptionOptions='" + + queryableEncryptionOptions + "'}"; + } + + /** + * Options, like the {@link #getQueryType()}, to apply when encrypting queryable values. + * + * @author Ross Lawley + */ + public static class QueryableEncryptionOptions { + + private static final QueryableEncryptionOptions NONE = new QueryableEncryptionOptions(null, null, null); + + private final @Nullable String queryType; + private final @Nullable Long contentionFactor; + private final @Nullable Document rangeOptions; + + private QueryableEncryptionOptions(@Nullable String queryType, @Nullable Long contentionFactor, + @Nullable Document rangeOptions) { + this.queryType = queryType; + this.contentionFactor = contentionFactor; + this.rangeOptions = rangeOptions; + } + + /** + * Create an empty {@link QueryableEncryptionOptions}. + * + * @return none {@literal null}. + */ + public static QueryableEncryptionOptions none() { + return NONE; + } + + /** + * Define the {@code queryType} to be used for queryable document encryption. + * + * @param queryType can be {@literal null}. + * @return new instance of {@link QueryableEncryptionOptions}. + */ + public QueryableEncryptionOptions queryType(@Nullable String queryType) { + return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions); + } + + /** + * Define the {@code contentionFactor} to be used for queryable document encryption. + * + * @param contentionFactor can be {@literal null}. + * @return new instance of {@link QueryableEncryptionOptions}. + */ + public QueryableEncryptionOptions contentionFactor(@Nullable Long contentionFactor) { + return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions); + } + + /** + * Define the {@code rangeOptions} to be used for queryable document encryption. + * + * @param rangeOptions can be {@literal null}. + * @return new instance of {@link QueryableEncryptionOptions}. + */ + public QueryableEncryptionOptions rangeOptions(@Nullable Document rangeOptions) { + return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions); + } + + /** + * Get the {@code queryType} to apply. + * + * @return {@link Optional#empty()} if not set. + */ + public Optional getQueryType() { + return Optional.ofNullable(queryType); + } + + /** + * Get the {@code contentionFactor} to apply. + * + * @return {@link Optional#empty()} if not set. + */ + public Optional getContentionFactor() { + return Optional.ofNullable(contentionFactor); + } + + /** + * Get the {@code rangeOptions} to apply. + * + * @return {@link Optional#empty()} if not set. + */ + 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())); + + rangeOptionsAdapter(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); + } + + /** + * @return {@literal true} if no arguments set. + */ + boolean isEmpty() { + return !Optionals.isAnyPresent(getQueryType(), getContentionFactor(), getRangeOptions()); + } + + @Override + public String toString() { + return "QueryableEncryptionOptions{" + "queryType='" + queryType + '\'' + ", contentionFactor=" + contentionFactor + + ", rangeOptions=" + rangeOptions + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + QueryableEncryptionOptions that = (QueryableEncryptionOptions) o; + + if (!ObjectUtils.nullSafeEquals(queryType, that.queryType)) { + return false; + } + + if (!ObjectUtils.nullSafeEquals(contentionFactor, that.contentionFactor)) { + return false; + } + return ObjectUtils.nullSafeEquals(rangeOptions, that.rangeOptions); + } + + @Override + public int hashCode() { + return Objects.hash(queryType, contentionFactor, rangeOptions); + } } } 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 92350ce7d7..4d250fba05 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 @@ -18,6 +18,7 @@ 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.util.Assert; @@ -29,6 +30,7 @@ * {@link ClientEncryption} based {@link Encryption} implementation. * * @author Christoph Strobl + * @author Ross Lawley * @since 4.1 */ public class MongoClientEncryption implements Encryption { @@ -59,7 +61,19 @@ public BsonValue decrypt(BsonBinary value) { @Override public BsonBinary encrypt(BsonValue value, EncryptionOptions options) { + return getClientEncryption().encrypt(value, createEncryptOptions(options)); + } + + @Override + public BsonDocument encryptExpression(BsonDocument value, EncryptionOptions options) { + return getClientEncryption().encryptExpression(value, createEncryptOptions(options)); + } + + public ClientEncryption getClientEncryption() { + return source.get(); + } + private EncryptOptions createEncryptOptions(EncryptionOptions options) { EncryptOptions encryptOptions = new EncryptOptions(options.algorithm()); if (Type.ALT.equals(options.key().type())) { @@ -68,11 +82,10 @@ public BsonBinary encrypt(BsonValue value, EncryptionOptions options) { encryptOptions = encryptOptions.keyId((BsonBinary) options.key().value()); } - return getClientEncryption().encrypt(value, encryptOptions); - } - - public ClientEncryption getClientEncryption() { - return source.get(); + options.queryableEncryptionOptions().getQueryType().map(encryptOptions::queryType); + options.queryableEncryptionOptions().getContentionFactor().map(encryptOptions::contentionFactor); + options.queryableEncryptionOptions().getRangeOptions().map(encryptOptions::rangeOptions); + return encryptOptions; } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java index 5f08e5c787..a8aedce8bc 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java @@ -47,6 +47,7 @@ * * * @author Christoph Strobl + * @author Ross Lawley * @since 4.1 * @see ValueConverter */ @@ -60,7 +61,8 @@ * Define the algorithm to use. *

* A {@literal Deterministic} algorithm ensures that a given input value always encrypts to the same output while a - * {@literal randomized} one will produce different results every time. + * {@literal randomized} one will produce different results every time. A {@literal range} algorithm allows for + * the value to be queried whilst encrypted. *

* Please make sure to use an algorithm that is in line with MongoDB's encryption rules for simple types, complex * objects and arrays as well as the query limitations that come with each of them. @@ -84,6 +86,24 @@ */ String keyAltName() default ""; + /** + * Set the contention factor + *

+ * Only required when using {@literal range} encryption. + * @return the contention factor + */ + long contentionFactor() default -1; + + /** + * Set the {@literal range} options + *

+ * Should be valid extended json representing the range options and including the following values: + * {@code min}, {@code max}, {@code trimFactor} and {@code sparsity}. + * + * @return the json representation of range options + */ + String rangeOptions() default ""; + /** * The {@link EncryptingConverter} type handling the {@literal en-/decryption} of the annotated property. * @@ -91,4 +111,5 @@ */ @AliasFor(annotation = ValueConverter.class, value = "value") Class value() default MongoEncryptionConverter.class; + } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java index f85be98c1f..3541f9c44c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java @@ -34,6 +34,7 @@ import com.mongodb.client.MongoDatabase; import com.mongodb.client.MongoIterable; import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.vault.RangeOptions; import com.mongodb.reactivestreams.client.MapReducePublisher; /** @@ -42,11 +43,13 @@ * This class is for internal use within the framework and should not be used by applications. * * @author Christoph Strobl + * @author Ross Lawley * @since 4.3 */ public class MongoCompatibilityAdapter { private static final String NO_LONGER_SUPPORTED = "%s is no longer supported on Mongo Client 5 or newer"; + private static final String NOT_SUPPORTED_ON_4 = "%s is not supported on Mongo Client 4"; private static final @Nullable Method getStreamFactoryFactory = ReflectionUtils.findMethod(MongoClientSettings.class, "getStreamFactoryFactory"); @@ -54,6 +57,9 @@ public class MongoCompatibilityAdapter { private static final @Nullable Method setBucketSize = ReflectionUtils.findMethod(IndexOptions.class, "bucketSize", Double.class); + private static final @Nullable Method setTrimFactor = ReflectionUtils.findMethod(RangeOptions.class, "setTrimFactor", + Integer.class); + /** * Return a compatibility adapter for {@link MongoClientSettings.Builder}. * @@ -122,6 +128,23 @@ public static MapReduceIterableAdapter mapReduceIterableAdapter(Object iterable) }; } + /** + * Return a compatibility adapter for {@link RangeOptions}. + * + * @param options + * @return + */ + public static RangeOptionsAdapter rangeOptionsAdapter(RangeOptions options) { + return trimFactor -> { + + if (!MongoClientVersion.isVersion5orNewer() || setTrimFactor == null) { + throw new UnsupportedOperationException(NOT_SUPPORTED_ON_4.formatted("RangeOptions.trimFactor")); + } + + ReflectionUtils.invokeMethod(setTrimFactor, options, trimFactor); + }; + } + /** * Return a compatibility adapter for {@code MapReducePublisher}. * @@ -199,6 +222,10 @@ public interface MongoDatabaseAdapterBuilder { MongoDatabaseAdapter forDb(com.mongodb.client.MongoDatabase db); } + public interface RangeOptionsAdapter { + void trimFactor(Integer trimFactor); + } + @SuppressWarnings({ "unchecked", "DataFlowIssue" }) public static class MongoDatabaseAdapter { 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 new file mode 100644 index 0000000000..b0bbbb418f --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java @@ -0,0 +1,373 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.mongodb.core.encryption; + +import static java.util.Arrays.*; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*; +import static org.springframework.data.mongodb.core.query.Criteria.*; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import com.mongodb.AutoEncryptionSettings; +import com.mongodb.ClientEncryptionSettings; +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.MongoNamespace; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.CreateCollectionOptions; +import com.mongodb.client.model.CreateEncryptedCollectionParams; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import com.mongodb.client.vault.ClientEncryption; +import com.mongodb.client.vault.ClientEncryptions; + +import org.assertj.core.api.Assumptions; +import org.bson.BsonArray; +import org.bson.BsonBinary; +import org.bson.BsonDocument; +import org.bson.BsonInt32; +import org.bson.BsonInt64; +import org.bson.BsonNull; +import org.bson.BsonString; +import org.bson.BsonValue; +import org.bson.Document; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.DisposableBean; +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.mongodb.config.AbstractMongoClientConfiguration; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions.MongoConverterConfigurationAdapter; +import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter; +import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted; +import org.springframework.data.mongodb.test.util.EnableIfMongoServerVersion; +import org.springframework.data.mongodb.test.util.EnableIfReplicaSetAvailable; +import org.springframework.data.mongodb.test.util.MongoClientExtension; +import org.springframework.data.mongodb.util.MongoClientVersion; +import org.springframework.data.util.Lazy; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * @author Ross Lawley + */ +@ExtendWith({ MongoClientExtension.class, SpringExtension.class }) +@EnableIfMongoServerVersion(isGreaterThanEqual = "8.0") +@EnableIfReplicaSetAvailable +@ContextConfiguration(classes = RangeEncryptionTests.EncryptionConfig.class) +class RangeEncryptionTests { + + @Autowired MongoTemplate template; + + @BeforeEach + void setUp() { + Assumptions.assumeThat(MongoClientVersion.isVersion5orNewer()).isTrue(); + } + + @AfterEach + void tearDown() { + template.getDb().getCollection("test").deleteMany(new BsonDocument()); + } + + @Test + void canGreaterThanEqualMatchRangeEncryptedField() { + Person source = createPerson(); + template.insert(source); + + Person loaded = template.query(Person.class).matching(where("encryptedInt").gte(source.encryptedInt)).firstValue(); + assertThat(loaded).isEqualTo(source); + } + + @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); + + Person loaded = template.query(Person.class).matching(where("encryptedLong").lte(1001L).gte(1001L)).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.encryptedInt = 101; + source.encryptedLong = 1001L; + return source; + } + + protected static class EncryptionConfig extends AbstractMongoClientConfiguration { + + private static final String LOCAL_KMS_PROVIDER = "local"; + + private static final Lazy>> LAZY_KMS_PROVIDERS = Lazy.of(() -> { + byte[] localMasterKey = new byte[96]; + new SecureRandom().nextBytes(localMasterKey); + return Map.of(LOCAL_KMS_PROVIDER, Map.of("key", localMasterKey)); + }); + + @Autowired ApplicationContext applicationContext; + + @Override + protected String getDatabaseName() { + return "qe-test"; + } + + @Bean + public MongoClient mongoClient() { + return super.mongoClient(); + } + + @Override + protected void configureConverters(MongoConverterConfigurationAdapter converterConfigurationAdapter) { + converterConfigurationAdapter + .registerPropertyValueConverterFactory(PropertyValueConverterFactory.beanFactoryAware(applicationContext)) + .useNativeDriverJavaTimeCodecs(); + } + + @Bean + MongoEncryptionConverter encryptingConverter(MongoClientEncryption mongoClientEncryption) { + Lazy> lazyDataKeyMap = Lazy.of(() -> { + try (MongoClient client = mongoClient()) { + MongoDatabase database = client.getDatabase(getDatabaseName()); + database.getCollection("test").drop(); + + ClientEncryption clientEncryption = mongoClientEncryption.getClientEncryption(); + BsonDocument encryptedFields = new BsonDocument().append("fields", + new BsonArray(asList( + new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedInt")) + .append("bsonType", new BsonString("int")) + .append("queries", + new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) + .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) + .append("min", new BsonInt32(0)).append("max", new BsonInt32(200))), + new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedLong")) + .append("bsonType", new BsonString("long")).append("queries", + new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) + .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) + .append("min", new BsonInt64(1000)).append("max", new BsonInt64(9999)))))); + + BsonDocument local = clientEncryption.createEncryptedCollection(database, "test", + new CreateCollectionOptions().encryptedFields(encryptedFields), + new CreateEncryptedCollectionParams(LOCAL_KMS_PROVIDER)); + + return local.getArray("fields").stream().map(BsonValue::asDocument).collect( + Collectors.toMap(field -> field.getString("path").getValue(), field -> field.getBinary("keyId"))); + } + }); + return new MongoEncryptionConverter(mongoClientEncryption, EncryptionKeyResolver + .annotated((ctx) -> EncryptionKey.keyId(lazyDataKeyMap.get().get(ctx.getProperty().getFieldName())))); + } + + @Bean + CachingMongoClientEncryption clientEncryption(ClientEncryptionSettings encryptionSettings) { + return new CachingMongoClientEncryption(() -> ClientEncryptions.create(encryptionSettings)); + } + + @Override + protected void configureClientSettings(MongoClientSettings.Builder builder) { + try (MongoClient client = MongoClients.create()) { + ClientEncryptionSettings clientEncryptionSettings = encryptionSettings(client); + + builder.autoEncryptionSettings(AutoEncryptionSettings.builder() // + .kmsProviders(clientEncryptionSettings.getKmsProviders()) // + .keyVaultNamespace(clientEncryptionSettings.getKeyVaultNamespace()) // + .bypassQueryAnalysis(true).build()); + } + } + + @Bean + ClientEncryptionSettings encryptionSettings(MongoClient mongoClient) { + MongoNamespace keyVaultNamespace = new MongoNamespace("encryption.testKeyVault"); + MongoCollection keyVaultCollection = mongoClient.getDatabase(keyVaultNamespace.getDatabaseName()) + .getCollection(keyVaultNamespace.getCollectionName()); + keyVaultCollection.drop(); + // Ensure that two data keys cannot share the same keyAltName. + keyVaultCollection.createIndex(Indexes.ascending("keyAltNames"), + new IndexOptions().unique(true).partialFilterExpression(Filters.exists("keyAltNames"))); + + mongoClient.getDatabase(getDatabaseName()).getCollection("test").drop(); // Clear old data + + // Create the ClientEncryption instance + return ClientEncryptionSettings.builder() // + .keyVaultMongoClientSettings( + MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://localhost")).build()) // + .keyVaultNamespace(keyVaultNamespace.getFullName()) // + .kmsProviders(LAZY_KMS_PROVIDERS.get()) // + .build(); + } + } + + static class CachingMongoClientEncryption extends MongoClientEncryption implements DisposableBean { + + static final AtomicReference cache = new AtomicReference<>(); + + CachingMongoClientEncryption(Supplier source) { + super(() -> { + ClientEncryption clientEncryption = cache.get(); + if (clientEncryption == null) { + clientEncryption = source.get(); + cache.set(clientEncryption); + } + + return clientEncryption; + }); + } + + @Override + public void destroy() { + ClientEncryption clientEncryption = cache.get(); + if (clientEncryption != null) { + clientEncryption.close(); + cache.set(null); + } + } + } + + @org.springframework.data.mongodb.core.mapping.Document("test") + static class Person { + + String id; + String name; + + @ExplicitEncrypted(algorithm = RANGE, contentionFactor = 0L, + rangeOptions = "{\"min\": 0, \"max\": 200, \"trimFactor\": 1, \"sparsity\": 1}") Integer encryptedInt; + @ExplicitEncrypted(algorithm = RANGE, contentionFactor = 0L, + rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") Long encryptedLong; + + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getEncryptedInt() { + return this.encryptedInt; + } + + public void setEncryptedInt(Integer encryptedInt) { + this.encryptedInt = encryptedInt; + } + + public Long getEncryptedLong() { + return this.encryptedLong; + } + + public void setEncryptedLong(Long encryptedLong) { + this.encryptedLong = encryptedLong; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + Person person = (Person) o; + return Objects.equals(id, person.id) && Objects.equals(name, person.name) + && Objects.equals(encryptedInt, person.encryptedInt) && Objects.equals(encryptedLong, person.encryptedLong); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(id); + result = 31 * result + Objects.hashCode(name); + result = 31 * result + Objects.hashCode(encryptedInt); + result = 31 * result + Objects.hashCode(encryptedLong); + return result; + } + + @Override + public String toString() { + return "Person{" + "id='" + id + '\'' + ", name='" + name + '\'' + ", encryptedInt=" + encryptedInt + + ", encryptedLong=" + encryptedLong + '}'; + } + } + +} From 681cc2db0e8d8e592a6fd2b37fe99bc5636a8450 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 27 Jan 2025 14:48:37 +0000 Subject: [PATCH 2/3] Remove MongoClient 4.x check / support --- .../core/encryption/EncryptionOptions.java | 4 +--- .../mongodb/util/MongoCompatibilityAdapter.java | 17 ----------------- .../core/encryption/RangeEncryptionTests.java | 8 -------- 3 files changed, 1 insertion(+), 28 deletions(-) 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 f2ad46e54f..89697fe0f6 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,8 +15,6 @@ */ package org.springframework.data.mongodb.core.encryption; -import static org.springframework.data.mongodb.util.MongoCompatibilityAdapter.*; - import java.util.Objects; import java.util.Optional; @@ -203,7 +201,7 @@ public Optional getRangeOptions() { Assert.isInstanceOf(Integer.class, trimFactor, () -> String .format("Expected to find a %s but it turned out to be %s.", Integer.class, trimFactor.getClass())); - rangeOptionsAdapter(encryptionRangeOptions).trimFactor((Integer) trimFactor); + encryptionRangeOptions.trimFactor((Integer) trimFactor); } if (rangeOptions.containsKey("sparsity")) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java index 3541f9c44c..b17b9f1963 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java @@ -128,23 +128,6 @@ public static MapReduceIterableAdapter mapReduceIterableAdapter(Object iterable) }; } - /** - * Return a compatibility adapter for {@link RangeOptions}. - * - * @param options - * @return - */ - public static RangeOptionsAdapter rangeOptionsAdapter(RangeOptions options) { - return trimFactor -> { - - if (!MongoClientVersion.isVersion5orNewer() || setTrimFactor == null) { - throw new UnsupportedOperationException(NOT_SUPPORTED_ON_4.formatted("RangeOptions.trimFactor")); - } - - ReflectionUtils.invokeMethod(setTrimFactor, options, trimFactor); - }; - } - /** * Return a compatibility adapter for {@code MapReducePublisher}. * 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 b0bbbb418f..2c5e3abc6b 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 @@ -44,7 +44,6 @@ import com.mongodb.client.vault.ClientEncryption; import com.mongodb.client.vault.ClientEncryptions; -import org.assertj.core.api.Assumptions; import org.bson.BsonArray; import org.bson.BsonBinary; import org.bson.BsonDocument; @@ -55,7 +54,6 @@ import org.bson.BsonValue; import org.bson.Document; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.DisposableBean; @@ -71,7 +69,6 @@ import org.springframework.data.mongodb.test.util.EnableIfMongoServerVersion; import org.springframework.data.mongodb.test.util.EnableIfReplicaSetAvailable; import org.springframework.data.mongodb.test.util.MongoClientExtension; -import org.springframework.data.mongodb.util.MongoClientVersion; import org.springframework.data.util.Lazy; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -87,11 +84,6 @@ class RangeEncryptionTests { @Autowired MongoTemplate template; - @BeforeEach - void setUp() { - Assumptions.assumeThat(MongoClientVersion.isVersion5orNewer()).isTrue(); - } - @AfterEach void tearDown() { template.getDb().getCollection("test").deleteMany(new BsonDocument()); From ba26fa68c2e63b20f81f82e40c6534c70751265b Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Tue, 28 Jan 2025 10:59:39 +0000 Subject: [PATCH 3/3] Javadoc fix --- .../data/mongodb/core/encryption/EncryptionOptions.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 89697fe0f6..5affbeddb1 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 @@ -20,6 +20,7 @@ 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; @@ -125,7 +126,7 @@ private QueryableEncryptionOptions(@Nullable String queryType, @Nullable Long co /** * Create an empty {@link QueryableEncryptionOptions}. * - * @return none {@literal null}. + * @return unmodifiable {@link QueryableEncryptionOptions} instance. */ public static QueryableEncryptionOptions none() { return NONE;