Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding queryable encryption range support #4885

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is the correct version?

*/
public CollectionOptions encryptedFields(@Nullable Bson encryptedFields) {
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
}

/**
@@ -392,12 +408,22 @@ public Optional<CollectionChangeStreamOptions> getChangeStreamOptions() {
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
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;
}

Original file line number Diff line number Diff line change
@@ -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";

}
Original file line number Diff line number Diff line change
@@ -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;
}

Original file line number Diff line number Diff line change
@@ -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<MongoPersistentProperty> {

private final PropertyValueProvider<MongoPersistentProperty> 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<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) {
this(accessor, persistentProperty, mongoConverter, null);
this(accessor, persistentProperty, mongoConverter, null, null);
}

public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
@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.persistentProperty = persistentProperty;
this.mongoConverter = mongoConverter;
this.spELContext = spELContext;
this.fieldNameAndQueryOperator = fieldNameAndQueryOperator;
}

@Override
@@ -84,4 +99,9 @@ public <T> T read(@Nullable Object value, TypeInformation<T> target) {
public SpELContext getSpELContext() {
return spELContext;
}

@Nullable
public String getFieldNameAndQueryOperator() {
return fieldNameAndQueryOperator;
}
}
Original file line number Diff line number Diff line change
@@ -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<Object, Object, ValueConversionContext<MongoPersistentProperty>> 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<Object, Object, ValueConversionContext<MongoPersistentProperty>> 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;
});
Original file line number Diff line number Diff line change
@@ -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> T read(@Nullable Object value, TypeInformation<T> target) {
public <T> T write(@Nullable Object value, TypeInformation<T> target) {
return conversionContext.write(value, target);
}

@Override
@Nullable
public String getFieldNameAndQueryOperator() {
return conversionContext.getFieldNameAndQueryOperator();
}
}
Original file line number Diff line number Diff line change
@@ -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<Object, Object> {

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 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.
*
* <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,
EncryptionContext context) {

Original file line number Diff line number Diff line change
@@ -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<S, T> {
@@ -40,4 +43,16 @@ public interface Encryption<S, T> {
*/
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");
}

}
Original file line number Diff line number Diff line change
@@ -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> T write(@Nullable Object value, Class<T> target) {

EvaluationContext getEvaluationContext(Object source);

/**
* The field name and field query operator
*
* @return can be {@literal null}.
*/
@Nullable
default String getFieldNameAndQueryOperator() {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -15,27 +15,43 @@
*/
package org.springframework.data.mongodb.core.encryption;

import java.util.Objects;
import java.util.Optional;

import com.mongodb.client.model.vault.RangeOptions;
import org.bson.Document;
import org.springframework.data.mongodb.core.FindAndReplaceOptions;
import org.springframework.data.mongodb.util.BsonUtils;
import org.springframework.data.util.Optionals;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;

/**
* 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 +62,10 @@ public String algorithm() {
return algorithm;
}

public QueryableEncryptionOptions queryableEncryptionOptions() {
return queryableEncryptionOptions;
}

@Override
public boolean equals(Object o) {

@@ -61,19 +81,182 @@ 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
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 unmodifiable {@link QueryableEncryptionOptions} instance.
*/
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()));

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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<BsonValue, BsonBinary> {
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -47,6 +47,7 @@
* </pre>
*
* @author Christoph Strobl
* @author Ross Lawley
* @since 4.1
* @see ValueConverter
*/
@@ -60,7 +61,8 @@
* Define the algorithm to use.
* <p>
* 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>
* 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,11 +86,30 @@
*/
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.
*
* @return the configured {@link EncryptingConverter}. A {@link MongoEncryptionConverter} by default.
*/
@AliasFor(annotation = ValueConverter.class, value = "value")
Class<? extends PropertyValueConverter> value() default MongoEncryptionConverter.class;

}
Original file line number Diff line number Diff line change
@@ -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,18 +43,23 @@
* 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");

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}.
*
@@ -199,6 +205,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 {

Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
/*
* 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.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.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.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;

@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(() -> {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lazily constructed the master key - so does this only once.

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",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added multiple fields as:

a) its non trivial / not obvious how to do so.
b) needed to ensure testing worked as expected

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());
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bypassQueryAnalysis is required to be true.

}
}

@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 + '}';
}
}

}