Adding queryable encryption range support
Supports range style queries for encrypted fields Closes: #4185 Original Pull Request: #4885
This commit is contained in:
committed by
Christoph Strobl
parent
1b629bc887
commit
9b7a4e5a37
@@ -19,6 +19,7 @@ import java.time.Duration;
|
||||
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 @@ import com.mongodb.client.model.ValidationLevel;
|
||||
* @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 @@ public class CollectionOptions {
|
||||
this.validationOptions = validationOptions;
|
||||
this.timeSeriesOptions = timeSeriesOptions;
|
||||
this.changeStreamOptions = changeStreamOptions;
|
||||
this.encryptedFields = encryptedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +80,7 @@ public class CollectionOptions {
|
||||
|
||||
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
|
||||
*/
|
||||
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() {
|
||||
return new CollectionOptions(size, maxDocuments, true, collation, validationOptions, timeSeriesOptions,
|
||||
changeStreamOptions);
|
||||
changeStreamOptions, encryptedFields);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +152,7 @@ public class CollectionOptions {
|
||||
*/
|
||||
public CollectionOptions maxDocuments(long maxDocuments) {
|
||||
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
|
||||
changeStreamOptions);
|
||||
changeStreamOptions, encryptedFields);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,7 +164,7 @@ public class CollectionOptions {
|
||||
*/
|
||||
public CollectionOptions size(long size) {
|
||||
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) {
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 class CollectionOptions {
|
||||
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 class CollectionOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ package org.springframework.data.mongodb.core;
|
||||
* 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";
|
||||
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ import com.mongodb.client.model.ValidationOptions;
|
||||
* @author Mark Paluch
|
||||
* @author Christoph Strobl
|
||||
* @author Ben Foster
|
||||
* @author Ross Lawley
|
||||
* @since 2.1
|
||||
* @see MongoTemplate
|
||||
* @see ReactiveMongoTemplate
|
||||
@@ -378,6 +379,7 @@ class EntityOperations {
|
||||
collectionOptions.getChangeStreamOptions().ifPresent(it -> result
|
||||
.changeStreamPreAndPostImagesOptions(new ChangeStreamPreAndPostImagesOptions(it.getPreAndPostImages())));
|
||||
|
||||
collectionOptions.getEncryptedFields().ifPresent(result::encryptedFields);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,29 +28,44 @@ import org.springframework.lang.Nullable;
|
||||
* {@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 class MongoConversionContext implements ValueConversionContext<MongoPersi
|
||||
public SpELContext getSpELContext() {
|
||||
return spELContext;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getFieldNameAndQueryOperator() {
|
||||
return fieldNameAndQueryOperator;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ import com.mongodb.DBRef;
|
||||
* @author David Julia
|
||||
* @author Divya Srivastava
|
||||
* @author Gyungrai Wang
|
||||
* @author Ross Lawley
|
||||
*/
|
||||
public class QueryMapper {
|
||||
|
||||
@@ -670,9 +671,23 @@ public class QueryMapper {
|
||||
PropertyValueConverter<Object, Object, ValueConversionContext<MongoPersistentProperty>> valueConverter) {
|
||||
|
||||
MongoPersistentProperty property = documentField.getProperty();
|
||||
MongoConversionContext conversionContext = new MongoConversionContext(NoPropertyPropertyValueProvider.INSTANCE,
|
||||
property, converter);
|
||||
|
||||
String fieldNameAndQueryOperator = property != null && !property.getFieldName().equals(documentField.name)
|
||||
? property.getFieldName() + "." + documentField.name
|
||||
: documentField.name;
|
||||
|
||||
MongoConversionContext conversionContext = new MongoConversionContext(NoPropertyPropertyValueProvider.INSTANCE,
|
||||
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 @@ public class QueryMapper {
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.springframework.lang.Nullable;
|
||||
* Default {@link EncryptionContext} implementation.
|
||||
*
|
||||
* @author Christoph Strobl
|
||||
* @author Ross Lawley
|
||||
* @since 4.1
|
||||
*/
|
||||
class ExplicitEncryptionContext implements EncryptionContext {
|
||||
@@ -66,4 +67,10 @@ class ExplicitEncryptionContext implements EncryptionContext {
|
||||
public <T> T write(@Nullable Object value, TypeInformation<T> target) {
|
||||
return conversionContext.write(value, target);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public String getFieldNameAndQueryOperator() {
|
||||
return conversionContext.getFieldNameAndQueryOperator();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.core.CollectionFactory;
|
||||
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 @@ import org.springframework.util.ObjectUtils;
|
||||
* {@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 class MongoEncryptionConverter implements EncryptingConverter<Object, Obj
|
||||
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 class MongoEncryptionConverter implements EncryptingConverter<Object, Obj
|
||||
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) {
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.springframework.lang.Nullable;
|
||||
* 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 @@ public interface EncryptionContext {
|
||||
|
||||
EvaluationContext getEvaluationContext(Object source);
|
||||
|
||||
/**
|
||||
* The field name and field query operator
|
||||
*
|
||||
* @return can be {@literal null}.
|
||||
*/
|
||||
@Nullable
|
||||
default String getFieldNameAndQueryOperator() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -22,20 +31,27 @@ 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 class EncryptionOptions {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public QueryableEncryptionOptions queryableEncryptionOptions() {
|
||||
return queryableEncryptionOptions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
|
||||
@@ -61,7 +81,11 @@ public class EncryptionOptions {
|
||||
if (!ObjectUtils.nullSafeEquals(algorithm, that.algorithm)) {
|
||||
return false;
|
||||
}
|
||||
return ObjectUtils.nullSafeEquals(key, that.key);
|
||||
if (!ObjectUtils.nullSafeEquals(key, that.key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ObjectUtils.nullSafeEquals(queryableEncryptionOptions, that.queryableEncryptionOptions);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -69,11 +93,170 @@ public class EncryptionOptions {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.springframework.data.mongodb.core.encryption;
|
||||
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 @@ import com.mongodb.client.vault.ClientEncryption;
|
||||
* {@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 class MongoClientEncryption implements Encryption<BsonValue, BsonBinary>
|
||||
|
||||
@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 class MongoClientEncryption implements Encryption<BsonValue, BsonBinary>
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionC
|
||||
* </pre>
|
||||
*
|
||||
* @author Christoph Strobl
|
||||
* @author Ross Lawley
|
||||
* @since 4.1
|
||||
* @see ValueConverter
|
||||
*/
|
||||
@@ -60,7 +61,8 @@ public @interface ExplicitEncrypted {
|
||||
* 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,6 +86,24 @@ public @interface ExplicitEncrypted {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -91,4 +111,5 @@ public @interface ExplicitEncrypted {
|
||||
*/
|
||||
@AliasFor(annotation = ValueConverter.class, value = "value")
|
||||
Class<? extends PropertyValueConverter> value() default MongoEncryptionConverter.class;
|
||||
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.mongodb.client.MapReduceIterable;
|
||||
import com.mongodb.client.MongoDatabase;
|
||||
import com.mongodb.client.MongoIterable;
|
||||
import com.mongodb.client.model.IndexOptions;
|
||||
import com.mongodb.client.model.vault.RangeOptions;
|
||||
import com.mongodb.reactivestreams.client.MapReducePublisher;
|
||||
|
||||
/**
|
||||
@@ -42,11 +43,13 @@ import com.mongodb.reactivestreams.client.MapReducePublisher;
|
||||
* This class is for internal use within the framework and should not be used by applications.
|
||||
*
|
||||
* @author Christoph Strobl
|
||||
* @author Ross Lawley
|
||||
* @since 4.3
|
||||
*/
|
||||
public class MongoCompatibilityAdapter {
|
||||
|
||||
private static final String NO_LONGER_SUPPORTED = "%s is no longer supported on Mongo Client 5 or newer";
|
||||
private static final String NOT_SUPPORTED_ON_4 = "%s is not supported on Mongo Client 4";
|
||||
|
||||
private static final @Nullable Method getStreamFactoryFactory = ReflectionUtils.findMethod(MongoClientSettings.class,
|
||||
"getStreamFactoryFactory");
|
||||
@@ -54,6 +57,9 @@ public class MongoCompatibilityAdapter {
|
||||
private static final @Nullable Method setBucketSize = ReflectionUtils.findMethod(IndexOptions.class, "bucketSize",
|
||||
Double.class);
|
||||
|
||||
private static final @Nullable Method setTrimFactor = ReflectionUtils.findMethod(RangeOptions.class, "setTrimFactor",
|
||||
Integer.class);
|
||||
|
||||
/**
|
||||
* Return a compatibility adapter for {@link MongoClientSettings.Builder}.
|
||||
*
|
||||
@@ -199,6 +205,10 @@ public class MongoCompatibilityAdapter {
|
||||
MongoDatabaseAdapter forDb(com.mongodb.client.MongoDatabase db);
|
||||
}
|
||||
|
||||
public interface RangeOptionsAdapter {
|
||||
void trimFactor(Integer trimFactor);
|
||||
}
|
||||
|
||||
@SuppressWarnings({ "unchecked", "DataFlowIssue" })
|
||||
public static class MongoDatabaseAdapter {
|
||||
|
||||
|
||||
@@ -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(() -> {
|
||||
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 + '}';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user