Browse Source

Adding queryable encryption range support

Supports range style queries for encrypted fields
issue/4185-light
Ross Lawley 11 months ago committed by Christoph Strobl
parent
commit
720aff2649
No known key found for this signature in database
GPG Key ID: E6054036D0C37A4B
  1. 56
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java
  2. 2
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java
  3. 2
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java
  4. 24
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java
  5. 22
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java
  6. 7
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java
  7. 83
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java
  8. 15
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java
  9. 10
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java
  10. 188
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java
  11. 23
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java
  12. 23
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java
  13. 27
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java
  14. 373
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java

56
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java

@ -19,6 +19,7 @@ import java.time.Duration;
import java.util.Optional; import java.util.Optional;
import java.util.function.Function; import java.util.function.Function;
import org.bson.conversions.Bson;
import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema; import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
@ -41,6 +42,7 @@ import com.mongodb.client.model.ValidationLevel;
* @author Mark Paluch * @author Mark Paluch
* @author Andreas Zink * @author Andreas Zink
* @author Ben Foster * @author Ben Foster
* @author Ross Lawley
*/ */
public class CollectionOptions { public class CollectionOptions {
@ -51,10 +53,11 @@ public class CollectionOptions {
private ValidationOptions validationOptions; private ValidationOptions validationOptions;
private @Nullable TimeSeriesOptions timeSeriesOptions; private @Nullable TimeSeriesOptions timeSeriesOptions;
private @Nullable CollectionChangeStreamOptions changeStreamOptions; private @Nullable CollectionChangeStreamOptions changeStreamOptions;
private @Nullable Bson encryptedFields;
private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nullable Boolean capped, private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nullable Boolean capped,
@Nullable Collation collation, ValidationOptions validationOptions, @Nullable TimeSeriesOptions timeSeriesOptions, @Nullable Collation collation, ValidationOptions validationOptions, @Nullable TimeSeriesOptions timeSeriesOptions,
@Nullable CollectionChangeStreamOptions changeStreamOptions) { @Nullable CollectionChangeStreamOptions changeStreamOptions, @Nullable Bson encryptedFields) {
this.maxDocuments = maxDocuments; this.maxDocuments = maxDocuments;
this.size = size; this.size = size;
@ -63,6 +66,7 @@ public class CollectionOptions {
this.validationOptions = validationOptions; this.validationOptions = validationOptions;
this.timeSeriesOptions = timeSeriesOptions; this.timeSeriesOptions = timeSeriesOptions;
this.changeStreamOptions = changeStreamOptions; this.changeStreamOptions = changeStreamOptions;
this.encryptedFields = encryptedFields;
} }
/** /**
@ -76,7 +80,7 @@ public class CollectionOptions {
Assert.notNull(collation, "Collation must not be null"); 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 class CollectionOptions {
* @since 2.0 * @since 2.0
*/ */
public static CollectionOptions empty() { 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 class CollectionOptions {
*/ */
public CollectionOptions capped() { public CollectionOptions capped() {
return new CollectionOptions(size, maxDocuments, true, collation, validationOptions, timeSeriesOptions, return new CollectionOptions(size, maxDocuments, true, collation, validationOptions, timeSeriesOptions,
changeStreamOptions); changeStreamOptions, encryptedFields);
} }
/** /**
@ -148,7 +152,7 @@ public class CollectionOptions {
*/ */
public CollectionOptions maxDocuments(long maxDocuments) { public CollectionOptions maxDocuments(long maxDocuments) {
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions); changeStreamOptions, encryptedFields);
} }
/** /**
@ -160,7 +164,7 @@ public class CollectionOptions {
*/ */
public CollectionOptions size(long size) { public CollectionOptions size(long size) {
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions); changeStreamOptions, encryptedFields);
} }
/** /**
@ -172,7 +176,7 @@ public class CollectionOptions {
*/ */
public CollectionOptions collation(@Nullable Collation collation) { public CollectionOptions collation(@Nullable Collation collation) {
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions); changeStreamOptions, encryptedFields);
} }
/** /**
@ -293,7 +297,7 @@ public class CollectionOptions {
Assert.notNull(validationOptions, "ValidationOptions must not be null"); Assert.notNull(validationOptions, "ValidationOptions must not be null");
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions); changeStreamOptions, encryptedFields);
} }
/** /**
@ -307,7 +311,7 @@ public class CollectionOptions {
Assert.notNull(timeSeriesOptions, "TimeSeriesOptions must not be null"); Assert.notNull(timeSeriesOptions, "TimeSeriesOptions must not be null");
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions); changeStreamOptions, encryptedFields);
} }
/** /**
@ -321,7 +325,19 @@ public class CollectionOptions {
Assert.notNull(changeStreamOptions, "ChangeStreamOptions must not be null"); Assert.notNull(changeStreamOptions, "ChangeStreamOptions must not be null");
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions, 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 class CollectionOptions {
return Optional.ofNullable(changeStreamOptions); return Optional.ofNullable(changeStreamOptions);
} }
/**
* Get the {@code encryptedFields} if available.
*
* @return {@link Optional#empty()} if not specified.
* @since 4.5.0
*/
public Optional<Bson> getEncryptedFields() {
return Optional.ofNullable(encryptedFields);
}
@Override @Override
public String toString() { public String toString() {
return "CollectionOptions{" + "maxDocuments=" + maxDocuments + ", size=" + size + ", capped=" + capped return "CollectionOptions{" + "maxDocuments=" + maxDocuments + ", size=" + size + ", capped=" + capped
+ ", collation=" + collation + ", validationOptions=" + validationOptions + ", timeSeriesOptions=" + ", collation=" + collation + ", validationOptions=" + validationOptions + ", timeSeriesOptions="
+ timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", disableValidation=" + timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", encryptedFields=" + encryptedFields
+ disableValidation() + ", strictValidation=" + strictValidation() + ", moderateValidation=" + ", disableValidation=" + disableValidation() + ", strictValidation=" + strictValidation() + ", moderateValidation="
+ moderateValidation() + ", warnOnValidationError=" + warnOnValidationError() + ", failOnValidationError=" + moderateValidation() + ", warnOnValidationError=" + warnOnValidationError() + ", failOnValidationError="
+ failOnValidationError() + '}'; + failOnValidationError() + '}';
} }
@ -431,7 +457,10 @@ public class CollectionOptions {
if (!ObjectUtils.nullSafeEquals(timeSeriesOptions, that.timeSeriesOptions)) { if (!ObjectUtils.nullSafeEquals(timeSeriesOptions, that.timeSeriesOptions)) {
return false; return false;
} }
return ObjectUtils.nullSafeEquals(changeStreamOptions, that.changeStreamOptions); if (!ObjectUtils.nullSafeEquals(changeStreamOptions, that.changeStreamOptions)) {
return false;
}
return ObjectUtils.nullSafeEquals(encryptedFields, that.encryptedFields);
} }
@Override @Override
@ -443,6 +472,7 @@ public class CollectionOptions {
result = 31 * result + ObjectUtils.nullSafeHashCode(validationOptions); result = 31 * result + ObjectUtils.nullSafeHashCode(validationOptions);
result = 31 * result + ObjectUtils.nullSafeHashCode(timeSeriesOptions); result = 31 * result + ObjectUtils.nullSafeHashCode(timeSeriesOptions);
result = 31 * result + ObjectUtils.nullSafeHashCode(changeStreamOptions); result = 31 * result + ObjectUtils.nullSafeHashCode(changeStreamOptions);
result = 31 * result + ObjectUtils.nullSafeHashCode(encryptedFields);
return result; return result;
} }

2
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java

@ -19,11 +19,13 @@ package org.springframework.data.mongodb.core;
* Encryption algorithms supported by MongoDB Client Side Field Level Encryption. * Encryption algorithms supported by MongoDB Client Side Field Level Encryption.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 3.3 * @since 3.3
*/ */
public final class EncryptionAlgorithms { 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_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 AEAD_AES_256_CBC_HMAC_SHA_512_Random = "AEAD_AES_256_CBC_HMAC_SHA_512-Random";
public static final String RANGE = "Range";
} }

2
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java

@ -83,6 +83,7 @@ import com.mongodb.client.model.ValidationOptions;
* @author Mark Paluch * @author Mark Paluch
* @author Christoph Strobl * @author Christoph Strobl
* @author Ben Foster * @author Ben Foster
* @author Ross Lawley
* @since 2.1 * @since 2.1
* @see MongoTemplate * @see MongoTemplate
* @see ReactiveMongoTemplate * @see ReactiveMongoTemplate
@ -378,6 +379,7 @@ class EntityOperations {
collectionOptions.getChangeStreamOptions().ifPresent(it -> result collectionOptions.getChangeStreamOptions().ifPresent(it -> result
.changeStreamPreAndPostImagesOptions(new ChangeStreamPreAndPostImagesOptions(it.getPreAndPostImages()))); .changeStreamPreAndPostImagesOptions(new ChangeStreamPreAndPostImagesOptions(it.getPreAndPostImages())));
collectionOptions.getEncryptedFields().ifPresent(result::encryptedFields);
return result; return result;
} }

24
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java

@ -28,29 +28,44 @@ import org.springframework.lang.Nullable;
* {@link ValueConversionContext} that allows to delegate read/write to an underlying {@link MongoConverter}. * {@link ValueConversionContext} that allows to delegate read/write to an underlying {@link MongoConverter}.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 3.4 * @since 3.4
*/ */
public class MongoConversionContext implements ValueConversionContext<MongoPersistentProperty> { public class MongoConversionContext implements ValueConversionContext<MongoPersistentProperty> {
private final PropertyValueProvider<MongoPersistentProperty> accessor; // TODO: generics private final PropertyValueProvider<MongoPersistentProperty> accessor; // TODO: generics
private final @Nullable MongoPersistentProperty persistentProperty;
private final MongoConverter mongoConverter; private final MongoConverter mongoConverter;
@Nullable private final MongoPersistentProperty persistentProperty;
@Nullable private final SpELContext spELContext; @Nullable private final SpELContext spELContext;
@Nullable private final String fieldNameAndQueryOperator;
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor, public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) { @Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) {
this(accessor, persistentProperty, mongoConverter, null); this(accessor, persistentProperty, mongoConverter, null, null);
} }
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor, public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter, @Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
@Nullable SpELContext spELContext) { @Nullable SpELContext spELContext) {
this(accessor, persistentProperty, mongoConverter, spELContext, null);
}
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
@Nullable String fieldNameAndQueryOperator) {
this(accessor, persistentProperty, mongoConverter, null, fieldNameAndQueryOperator);
}
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
@Nullable SpELContext spELContext, @Nullable String fieldNameAndQueryOperator) {
this.accessor = accessor; this.accessor = accessor;
this.persistentProperty = persistentProperty; this.persistentProperty = persistentProperty;
this.mongoConverter = mongoConverter; this.mongoConverter = mongoConverter;
this.spELContext = spELContext; this.spELContext = spELContext;
this.fieldNameAndQueryOperator = fieldNameAndQueryOperator;
} }
@Override @Override
@ -84,4 +99,9 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi
public SpELContext getSpELContext() { public SpELContext getSpELContext() {
return spELContext; return spELContext;
} }
@Nullable
public String getFieldNameAndQueryOperator() {
return fieldNameAndQueryOperator;
}
} }

22
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java

@ -88,6 +88,7 @@ import com.mongodb.DBRef;
* @author David Julia * @author David Julia
* @author Divya Srivastava * @author Divya Srivastava
* @author Gyungrai Wang * @author Gyungrai Wang
* @author Ross Lawley
*/ */
public class QueryMapper { public class QueryMapper {
@ -670,9 +671,23 @@ public class QueryMapper {
PropertyValueConverter<Object, Object, ValueConversionContext<MongoPersistentProperty>> valueConverter) { PropertyValueConverter<Object, Object, ValueConversionContext<MongoPersistentProperty>> valueConverter) {
MongoPersistentProperty property = documentField.getProperty(); 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, 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<Object, Object, ValueConversionContext<MongoPersistentProperty>> valueConverter,
MongoConversionContext conversionContext) {
MongoPersistentProperty property = documentField.getProperty();
/* might be an $in clause with multiple entries */ /* might be an $in clause with multiple entries */
if (property != null && !property.isCollectionLike() && sourceValue instanceof Collection<?> collection) { if (property != null && !property.isCollectionLike() && sourceValue instanceof Collection<?> collection) {
@ -692,7 +707,10 @@ public class QueryMapper {
return BsonUtils.mapValues(document, (key, val) -> { return BsonUtils.mapValues(document, (key, val) -> {
if (isKeyword(key)) { 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; return val;
}); });

7
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java

@ -26,6 +26,7 @@ import org.springframework.lang.Nullable;
* Default {@link EncryptionContext} implementation. * Default {@link EncryptionContext} implementation.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 4.1 * @since 4.1
*/ */
class ExplicitEncryptionContext implements EncryptionContext { class ExplicitEncryptionContext implements EncryptionContext {
@ -66,4 +67,10 @@ class ExplicitEncryptionContext implements EncryptionContext {
public <T> T write(@Nullable Object value, TypeInformation<T> target) { public <T> T write(@Nullable Object value, TypeInformation<T> target) {
return conversionContext.write(value, target); return conversionContext.write(value, target);
} }
@Override
@Nullable
public String getFieldNameAndQueryOperator() {
return conversionContext.getFieldNameAndQueryOperator();
}
} }

83
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; 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.Collection;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.apache.commons.logging.Log; import org.apache.commons.logging.Log;
@ -31,9 +37,11 @@ import org.springframework.core.CollectionFactory;
import org.springframework.data.mongodb.core.convert.MongoConversionContext; import org.springframework.data.mongodb.core.convert.MongoConversionContext;
import org.springframework.data.mongodb.core.encryption.Encryption; import org.springframework.data.mongodb.core.encryption.Encryption;
import org.springframework.data.mongodb.core.encryption.EncryptionContext; 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.EncryptionKeyResolver;
import org.springframework.data.mongodb.core.encryption.EncryptionOptions; import org.springframework.data.mongodb.core.encryption.EncryptionOptions;
import org.springframework.data.mongodb.core.mapping.Encrypted; 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.core.mapping.MongoPersistentProperty;
import org.springframework.data.mongodb.util.BsonUtils; import org.springframework.data.mongodb.util.BsonUtils;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
@ -44,11 +52,14 @@ import org.springframework.util.ObjectUtils;
* {@link Encrypted @Encrypted} to provide key and algorithm metadata. * {@link Encrypted @Encrypted} to provide key and algorithm metadata.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 4.1 * @since 4.1
*/ */
public class MongoEncryptionConverter implements EncryptingConverter<Object, Object> { public class MongoEncryptionConverter implements EncryptingConverter<Object, Object> {
private static final Log LOGGER = LogFactory.getLog(MongoEncryptionConverter.class); private static final Log LOGGER = LogFactory.getLog(MongoEncryptionConverter.class);
private static final String EQUALITY_OPERATOR = "$eq";
private static final List<String> RANGE_OPERATORS = asList("$gt", "$gte", "$lt", "$lte");
private final Encryption<BsonValue, BsonBinary> encryption; private final Encryption<BsonValue, BsonBinary> encryption;
private final EncryptionKeyResolver keyResolver; private final EncryptionKeyResolver keyResolver;
@ -161,8 +172,42 @@ public class MongoEncryptionConverter implements EncryptingConverter<Object, Obj
getProperty(context).getOwner().getName(), getProperty(context).getName())); 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.isEntity()) {
if (persistentProperty.isCollectionLike()) { if (persistentProperty.isCollectionLike()) {
@ -187,6 +232,42 @@ public class MongoEncryptionConverter implements EncryptingConverter<Object, Obj
return encryption.encrypt(BsonUtils.simpleToBsonValue(write), encryptionOptions); return encryption.encrypt(BsonUtils.simpleToBsonValue(write), encryptionOptions);
} }
/**
* Encrypts a range query expression.
*
* <p>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, private BsonValue collectionLikeToBsonValue(Object value, MongoPersistentProperty property,
EncryptionContext context) { EncryptionContext context) {

15
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; package org.springframework.data.mongodb.core.encryption;
import org.bson.BsonDocument;
/** /**
* Component responsible for encrypting and decrypting values. * Component responsible for encrypting and decrypting values.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 4.1 * @since 4.1
*/ */
public interface Encryption<S, T> { public interface Encryption<S, T> {
@ -40,4 +43,16 @@ public interface Encryption<S, T> {
*/ */
S decrypt(T value); 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");
}
} }

10
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java

@ -25,6 +25,7 @@ import org.springframework.lang.Nullable;
* Context to encapsulate encryption for a specific {@link MongoPersistentProperty}. * Context to encapsulate encryption for a specific {@link MongoPersistentProperty}.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 4.1 * @since 4.1
*/ */
public interface EncryptionContext { public interface EncryptionContext {
@ -128,4 +129,13 @@ public interface EncryptionContext {
EvaluationContext getEvaluationContext(Object source); EvaluationContext getEvaluationContext(Object source);
/**
* The field name and field query operator
*
* @return can be {@literal null}.
*/
@Nullable
default String getFieldNameAndQueryOperator() {
return null;
}
} }

188
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; 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.Assert;
import org.springframework.util.ObjectUtils; import org.springframework.util.ObjectUtils;
@ -22,20 +32,27 @@ import org.springframework.util.ObjectUtils;
* Options, like the {@link #algorithm()}, to apply when encrypting values. * Options, like the {@link #algorithm()}, to apply when encrypting values.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 4.1 * @since 4.1
*/ */
public class EncryptionOptions { public class EncryptionOptions {
private final String algorithm; private final String algorithm;
private final EncryptionKey key; private final EncryptionKey key;
private final QueryableEncryptionOptions queryableEncryptionOptions;
public EncryptionOptions(String algorithm, EncryptionKey key) { 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.hasText(algorithm, "Algorithm must not be empty");
Assert.notNull(key, "EncryptionKey must not be empty"); Assert.notNull(key, "EncryptionKey must not be empty");
Assert.notNull(key, "QueryableEncryptionOptions must not be empty");
this.key = key; this.key = key;
this.algorithm = algorithm; this.algorithm = algorithm;
this.queryableEncryptionOptions = queryableEncryptionOptions;
} }
public EncryptionKey key() { public EncryptionKey key() {
@ -46,6 +63,10 @@ public class EncryptionOptions {
return algorithm; return algorithm;
} }
public QueryableEncryptionOptions queryableEncryptionOptions() {
return queryableEncryptionOptions;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
@ -61,7 +82,11 @@ public class EncryptionOptions {
if (!ObjectUtils.nullSafeEquals(algorithm, that.algorithm)) { if (!ObjectUtils.nullSafeEquals(algorithm, that.algorithm)) {
return false; return false;
} }
return ObjectUtils.nullSafeEquals(key, that.key); if (!ObjectUtils.nullSafeEquals(key, that.key)) {
return false;
}
return ObjectUtils.nullSafeEquals(queryableEncryptionOptions, that.queryableEncryptionOptions);
} }
@Override @Override
@ -69,11 +94,170 @@ public class EncryptionOptions {
int result = ObjectUtils.nullSafeHashCode(algorithm); int result = ObjectUtils.nullSafeHashCode(algorithm);
result = 31 * result + ObjectUtils.nullSafeHashCode(key); result = 31 * result + ObjectUtils.nullSafeHashCode(key);
result = 31 * result + ObjectUtils.nullSafeHashCode(queryableEncryptionOptions);
return result; return result;
} }
@Override @Override
public String toString() { 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<String> getQueryType() {
return Optional.ofNullable(queryType);
}
/**
* Get the {@code contentionFactor} to apply.
*
* @return {@link Optional#empty()} if not set.
*/
public Optional<Long> getContentionFactor() {
return Optional.ofNullable(contentionFactor);
}
/**
* Get the {@code rangeOptions} to apply.
*
* @return {@link Optional#empty()} if not set.
*/
public Optional<RangeOptions> 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);
}
} }
} }

23
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java

@ -18,6 +18,7 @@ package org.springframework.data.mongodb.core.encryption;
import java.util.function.Supplier; import java.util.function.Supplier;
import org.bson.BsonBinary; import org.bson.BsonBinary;
import org.bson.BsonDocument;
import org.bson.BsonValue; import org.bson.BsonValue;
import org.springframework.data.mongodb.core.encryption.EncryptionKey.Type; import org.springframework.data.mongodb.core.encryption.EncryptionKey.Type;
import org.springframework.util.Assert; import org.springframework.util.Assert;
@ -29,6 +30,7 @@ import com.mongodb.client.vault.ClientEncryption;
* {@link ClientEncryption} based {@link Encryption} implementation. * {@link ClientEncryption} based {@link Encryption} implementation.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 4.1 * @since 4.1
*/ */
public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary> { public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary> {
@ -59,7 +61,19 @@ public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary>
@Override @Override
public BsonBinary encrypt(BsonValue value, EncryptionOptions options) { 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()); EncryptOptions encryptOptions = new EncryptOptions(options.algorithm());
if (Type.ALT.equals(options.key().type())) { if (Type.ALT.equals(options.key().type())) {
@ -68,11 +82,10 @@ public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary>
encryptOptions = encryptOptions.keyId((BsonBinary) options.key().value()); encryptOptions = encryptOptions.keyId((BsonBinary) options.key().value());
} }
return getClientEncryption().encrypt(value, encryptOptions); options.queryableEncryptionOptions().getQueryType().map(encryptOptions::queryType);
} options.queryableEncryptionOptions().getContentionFactor().map(encryptOptions::contentionFactor);
options.queryableEncryptionOptions().getRangeOptions().map(encryptOptions::rangeOptions);
public ClientEncryption getClientEncryption() { return encryptOptions;
return source.get();
} }
} }

23
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java

@ -47,6 +47,7 @@ import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionC
* </pre> * </pre>
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 4.1 * @since 4.1
* @see ValueConverter * @see ValueConverter
*/ */
@ -60,7 +61,8 @@ public @interface ExplicitEncrypted {
* Define the algorithm to use. * Define the algorithm to use.
* <p> * <p>
* A {@literal Deterministic} algorithm ensures that a given input value always encrypts to the same output while a * 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.
* <p> * <p>
* Please make sure to use an algorithm that is in line with MongoDB's encryption rules for simple types, complex * 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. * objects and arrays as well as the query limitations that come with each of them.
@ -84,6 +86,24 @@ public @interface ExplicitEncrypted {
*/ */
String keyAltName() default ""; String keyAltName() default "";
/**
* Set the contention factor
* <p>
* Only required when using {@literal range} encryption.
* @return the contention factor
*/
long contentionFactor() default -1;
/**
* Set the {@literal range} options
* <p>
* 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. * The {@link EncryptingConverter} type handling the {@literal en-/decryption} of the annotated property.
* *
@ -91,4 +111,5 @@ public @interface ExplicitEncrypted {
*/ */
@AliasFor(annotation = ValueConverter.class, value = "value") @AliasFor(annotation = ValueConverter.class, value = "value")
Class<? extends PropertyValueConverter> value() default MongoEncryptionConverter.class; Class<? extends PropertyValueConverter> value() default MongoEncryptionConverter.class;
} }

27
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java

@ -34,6 +34,7 @@ import com.mongodb.client.MapReduceIterable;
import com.mongodb.client.MongoDatabase; import com.mongodb.client.MongoDatabase;
import com.mongodb.client.MongoIterable; import com.mongodb.client.MongoIterable;
import com.mongodb.client.model.IndexOptions; import com.mongodb.client.model.IndexOptions;
import com.mongodb.client.model.vault.RangeOptions;
import com.mongodb.reactivestreams.client.MapReducePublisher; import com.mongodb.reactivestreams.client.MapReducePublisher;
/** /**
@ -42,11 +43,13 @@ import com.mongodb.reactivestreams.client.MapReducePublisher;
* This class is for internal use within the framework and should not be used by applications. * This class is for internal use within the framework and should not be used by applications.
* *
* @author Christoph Strobl * @author Christoph Strobl
* @author Ross Lawley
* @since 4.3 * @since 4.3
*/ */
public class MongoCompatibilityAdapter { 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 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, private static final @Nullable Method getStreamFactoryFactory = ReflectionUtils.findMethod(MongoClientSettings.class,
"getStreamFactoryFactory"); "getStreamFactoryFactory");
@ -54,6 +57,9 @@ public class MongoCompatibilityAdapter {
private static final @Nullable Method setBucketSize = ReflectionUtils.findMethod(IndexOptions.class, "bucketSize", private static final @Nullable Method setBucketSize = ReflectionUtils.findMethod(IndexOptions.class, "bucketSize",
Double.class); Double.class);
private static final @Nullable Method setTrimFactor = ReflectionUtils.findMethod(RangeOptions.class, "setTrimFactor",
Integer.class);
/** /**
* Return a compatibility adapter for {@link MongoClientSettings.Builder}. * Return a compatibility adapter for {@link MongoClientSettings.Builder}.
* *
@ -122,6 +128,23 @@ public class MongoCompatibilityAdapter {
}; };
} }
/**
* 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}. * Return a compatibility adapter for {@code MapReducePublisher}.
* *
@ -199,6 +222,10 @@ public class MongoCompatibilityAdapter {
MongoDatabaseAdapter forDb(com.mongodb.client.MongoDatabase db); MongoDatabaseAdapter forDb(com.mongodb.client.MongoDatabase db);
} }
public interface RangeOptionsAdapter {
void trimFactor(Integer trimFactor);
}
@SuppressWarnings({ "unchecked", "DataFlowIssue" }) @SuppressWarnings({ "unchecked", "DataFlowIssue" })
public static class MongoDatabaseAdapter { public static class MongoDatabaseAdapter {

373
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<Map<String, Map<String, Object>>> 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<Map<String, BsonBinary>> 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<Document> 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<ClientEncryption> cache = new AtomicReference<>();
CachingMongoClientEncryption(Supplier<ClientEncryption> 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 + '}';
}
}
}
Loading…
Cancel
Save