Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 156274e

Browse files
rozzachristophstrobl
authored andcommittedApr 14, 2025
Adding queryable encryption range support
Supports range style queries for encrypted fields Closes: #4181 Original Pull Request: #4885
1 parent 1b629bc commit 156274e

File tree

14 files changed

+803
-26
lines changed

14 files changed

+803
-26
lines changed
 

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

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.Optional;
2020
import java.util.function.Function;
2121

22+
import org.bson.conversions.Bson;
2223
import org.springframework.data.mongodb.core.mapping.Field;
2324
import org.springframework.data.mongodb.core.query.Collation;
2425
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
@@ -41,6 +42,7 @@
4142
* @author Mark Paluch
4243
* @author Andreas Zink
4344
* @author Ben Foster
45+
* @author Ross Lawley
4446
*/
4547
public class CollectionOptions {
4648

@@ -51,10 +53,11 @@ public class CollectionOptions {
5153
private ValidationOptions validationOptions;
5254
private @Nullable TimeSeriesOptions timeSeriesOptions;
5355
private @Nullable CollectionChangeStreamOptions changeStreamOptions;
56+
private @Nullable Bson encryptedFields;
5457

5558
private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nullable Boolean capped,
5659
@Nullable Collation collation, ValidationOptions validationOptions, @Nullable TimeSeriesOptions timeSeriesOptions,
57-
@Nullable CollectionChangeStreamOptions changeStreamOptions) {
60+
@Nullable CollectionChangeStreamOptions changeStreamOptions, @Nullable Bson encryptedFields) {
5861

5962
this.maxDocuments = maxDocuments;
6063
this.size = size;
@@ -63,6 +66,7 @@ private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nul
6366
this.validationOptions = validationOptions;
6467
this.timeSeriesOptions = timeSeriesOptions;
6568
this.changeStreamOptions = changeStreamOptions;
69+
this.encryptedFields = encryptedFields;
6670
}
6771

6872
/**
@@ -76,7 +80,7 @@ public static CollectionOptions just(Collation collation) {
7680

7781
Assert.notNull(collation, "Collation must not be null");
7882

79-
return new CollectionOptions(null, null, null, collation, ValidationOptions.none(), null, null);
83+
return new CollectionOptions(null, null, null, collation, ValidationOptions.none(), null, null, null);
8084
}
8185

8286
/**
@@ -86,7 +90,7 @@ public static CollectionOptions just(Collation collation) {
8690
* @since 2.0
8791
*/
8892
public static CollectionOptions empty() {
89-
return new CollectionOptions(null, null, null, null, ValidationOptions.none(), null, null);
93+
return new CollectionOptions(null, null, null, null, ValidationOptions.none(), null, null, null);
9094
}
9195

9296
/**
@@ -136,7 +140,7 @@ public static CollectionOptions emitChangedRevisions() {
136140
*/
137141
public CollectionOptions capped() {
138142
return new CollectionOptions(size, maxDocuments, true, collation, validationOptions, timeSeriesOptions,
139-
changeStreamOptions);
143+
changeStreamOptions, encryptedFields);
140144
}
141145

142146
/**
@@ -148,7 +152,7 @@ public CollectionOptions capped() {
148152
*/
149153
public CollectionOptions maxDocuments(long maxDocuments) {
150154
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
151-
changeStreamOptions);
155+
changeStreamOptions, encryptedFields);
152156
}
153157

154158
/**
@@ -160,7 +164,7 @@ public CollectionOptions maxDocuments(long maxDocuments) {
160164
*/
161165
public CollectionOptions size(long size) {
162166
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
163-
changeStreamOptions);
167+
changeStreamOptions, encryptedFields);
164168
}
165169

166170
/**
@@ -172,7 +176,7 @@ public CollectionOptions size(long size) {
172176
*/
173177
public CollectionOptions collation(@Nullable Collation collation) {
174178
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
175-
changeStreamOptions);
179+
changeStreamOptions, encryptedFields);
176180
}
177181

178182
/**
@@ -293,7 +297,7 @@ public CollectionOptions validation(ValidationOptions validationOptions) {
293297

294298
Assert.notNull(validationOptions, "ValidationOptions must not be null");
295299
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
296-
changeStreamOptions);
300+
changeStreamOptions, encryptedFields);
297301
}
298302

299303
/**
@@ -307,7 +311,7 @@ public CollectionOptions timeSeries(TimeSeriesOptions timeSeriesOptions) {
307311

308312
Assert.notNull(timeSeriesOptions, "TimeSeriesOptions must not be null");
309313
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
310-
changeStreamOptions);
314+
changeStreamOptions, encryptedFields);
311315
}
312316

313317
/**
@@ -321,7 +325,19 @@ public CollectionOptions changeStream(CollectionChangeStreamOptions changeStream
321325

322326
Assert.notNull(changeStreamOptions, "ChangeStreamOptions must not be null");
323327
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
324-
changeStreamOptions);
328+
changeStreamOptions, encryptedFields);
329+
}
330+
331+
/**
332+
* Create new {@link CollectionOptions} with the given {@code encryptedFields}.
333+
*
334+
* @param encryptedFields can be null
335+
* @return new instance of {@link CollectionOptions}.
336+
* @since 4.5.0
337+
*/
338+
public CollectionOptions encryptedFields(@Nullable Bson encryptedFields) {
339+
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
340+
changeStreamOptions, encryptedFields);
325341
}
326342

327343
/**
@@ -392,12 +408,22 @@ public Optional<CollectionChangeStreamOptions> getChangeStreamOptions() {
392408
return Optional.ofNullable(changeStreamOptions);
393409
}
394410

411+
/**
412+
* Get the {@code encryptedFields} if available.
413+
*
414+
* @return {@link Optional#empty()} if not specified.
415+
* @since 4.5.0
416+
*/
417+
public Optional<Bson> getEncryptedFields() {
418+
return Optional.ofNullable(encryptedFields);
419+
}
420+
395421
@Override
396422
public String toString() {
397423
return "CollectionOptions{" + "maxDocuments=" + maxDocuments + ", size=" + size + ", capped=" + capped
398424
+ ", collation=" + collation + ", validationOptions=" + validationOptions + ", timeSeriesOptions="
399-
+ timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", disableValidation="
400-
+ disableValidation() + ", strictValidation=" + strictValidation() + ", moderateValidation="
425+
+ timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", encryptedFields=" + encryptedFields
426+
+ ", disableValidation=" + disableValidation() + ", strictValidation=" + strictValidation() + ", moderateValidation="
401427
+ moderateValidation() + ", warnOnValidationError=" + warnOnValidationError() + ", failOnValidationError="
402428
+ failOnValidationError() + '}';
403429
}
@@ -431,7 +457,10 @@ public boolean equals(@Nullable Object o) {
431457
if (!ObjectUtils.nullSafeEquals(timeSeriesOptions, that.timeSeriesOptions)) {
432458
return false;
433459
}
434-
return ObjectUtils.nullSafeEquals(changeStreamOptions, that.changeStreamOptions);
460+
if (!ObjectUtils.nullSafeEquals(changeStreamOptions, that.changeStreamOptions)) {
461+
return false;
462+
}
463+
return ObjectUtils.nullSafeEquals(encryptedFields, that.encryptedFields);
435464
}
436465

437466
@Override
@@ -443,6 +472,7 @@ public int hashCode() {
443472
result = 31 * result + ObjectUtils.nullSafeHashCode(validationOptions);
444473
result = 31 * result + ObjectUtils.nullSafeHashCode(timeSeriesOptions);
445474
result = 31 * result + ObjectUtils.nullSafeHashCode(changeStreamOptions);
475+
result = 31 * result + ObjectUtils.nullSafeHashCode(encryptedFields);
446476
return result;
447477
}
448478

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
* Encryption algorithms supported by MongoDB Client Side Field Level Encryption.
2020
*
2121
* @author Christoph Strobl
22+
* @author Ross Lawley
2223
* @since 3.3
2324
*/
2425
public final class EncryptionAlgorithms {
2526

2627
public static final String AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic";
2728
public static final String AEAD_AES_256_CBC_HMAC_SHA_512_Random = "AEAD_AES_256_CBC_HMAC_SHA_512-Random";
29+
public static final String RANGE = "Range";
2830

2931
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
* @author Mark Paluch
8484
* @author Christoph Strobl
8585
* @author Ben Foster
86+
* @author Ross Lawley
8687
* @since 2.1
8788
* @see MongoTemplate
8889
* @see ReactiveMongoTemplate
@@ -378,6 +379,7 @@ public CreateCollectionOptions convertToCreateCollectionOptions(@Nullable Collec
378379
collectionOptions.getChangeStreamOptions().ifPresent(it -> result
379380
.changeStreamPreAndPostImagesOptions(new ChangeStreamPreAndPostImagesOptions(it.getPreAndPostImages())));
380381

382+
collectionOptions.getEncryptedFields().ifPresent(result::encryptedFields);
381383
return result;
382384
}
383385

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,29 +28,44 @@
2828
* {@link ValueConversionContext} that allows to delegate read/write to an underlying {@link MongoConverter}.
2929
*
3030
* @author Christoph Strobl
31+
* @author Ross Lawley
3132
* @since 3.4
3233
*/
3334
public class MongoConversionContext implements ValueConversionContext<MongoPersistentProperty> {
3435

3536
private final PropertyValueProvider<MongoPersistentProperty> accessor; // TODO: generics
36-
private final @Nullable MongoPersistentProperty persistentProperty;
3737
private final MongoConverter mongoConverter;
3838

39+
@Nullable private final MongoPersistentProperty persistentProperty;
3940
@Nullable private final SpELContext spELContext;
41+
@Nullable private final String fieldNameAndQueryOperator;
4042

4143
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
4244
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) {
43-
this(accessor, persistentProperty, mongoConverter, null);
45+
this(accessor, persistentProperty, mongoConverter, null, null);
4446
}
4547

4648
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
4749
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
4850
@Nullable SpELContext spELContext) {
51+
this(accessor, persistentProperty, mongoConverter, spELContext, null);
52+
}
53+
54+
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
55+
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
56+
@Nullable String fieldNameAndQueryOperator) {
57+
this(accessor, persistentProperty, mongoConverter, null, fieldNameAndQueryOperator);
58+
}
59+
60+
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
61+
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
62+
@Nullable SpELContext spELContext, @Nullable String fieldNameAndQueryOperator) {
4963

5064
this.accessor = accessor;
5165
this.persistentProperty = persistentProperty;
5266
this.mongoConverter = mongoConverter;
5367
this.spELContext = spELContext;
68+
this.fieldNameAndQueryOperator = fieldNameAndQueryOperator;
5469
}
5570

5671
@Override
@@ -84,4 +99,9 @@ public <T> T read(@Nullable Object value, TypeInformation<T> target) {
8499
public SpELContext getSpELContext() {
85100
return spELContext;
86101
}
102+
103+
@Nullable
104+
public String getFieldNameAndQueryOperator() {
105+
return fieldNameAndQueryOperator;
106+
}
87107
}

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
* @author David Julia
8989
* @author Divya Srivastava
9090
* @author Gyungrai Wang
91+
* @author Ross Lawley
9192
*/
9293
public class QueryMapper {
9394

@@ -670,9 +671,23 @@ private Object convertValue(Field documentField, Object sourceValue, Object valu
670671
PropertyValueConverter<Object, Object, ValueConversionContext<MongoPersistentProperty>> valueConverter) {
671672

672673
MongoPersistentProperty property = documentField.getProperty();
674+
675+
String fieldNameAndQueryOperator = property != null && !property.getFieldName().equals(documentField.name)
676+
? property.getFieldName() + "." + documentField.name
677+
: documentField.name;
678+
673679
MongoConversionContext conversionContext = new MongoConversionContext(NoPropertyPropertyValueProvider.INSTANCE,
674-
property, converter);
680+
property, converter, fieldNameAndQueryOperator);
681+
682+
return convertValueWithConversionContext(documentField, sourceValue, value, valueConverter, conversionContext);
683+
}
675684

685+
@Nullable
686+
private Object convertValueWithConversionContext(Field documentField, Object sourceValue, Object value,
687+
PropertyValueConverter<Object, Object, ValueConversionContext<MongoPersistentProperty>> valueConverter,
688+
MongoConversionContext conversionContext) {
689+
690+
MongoPersistentProperty property = documentField.getProperty();
676691
/* might be an $in clause with multiple entries */
677692
if (property != null && !property.isCollectionLike() && sourceValue instanceof Collection<?> collection) {
678693

@@ -692,7 +707,10 @@ private Object convertValue(Field documentField, Object sourceValue, Object valu
692707

693708
return BsonUtils.mapValues(document, (key, val) -> {
694709
if (isKeyword(key)) {
695-
return getMappedValue(documentField, val);
710+
MongoConversionContext fieldConversionContext = new MongoConversionContext(
711+
NoPropertyPropertyValueProvider.INSTANCE, property, converter,
712+
conversionContext.getFieldNameAndQueryOperator() + "." + key);
713+
return convertValueWithConversionContext(documentField, val, val, valueConverter, fieldConversionContext);
696714
}
697715
return val;
698716
});

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
* Default {@link EncryptionContext} implementation.
2727
*
2828
* @author Christoph Strobl
29+
* @author Ross Lawley
2930
* @since 4.1
3031
*/
3132
class ExplicitEncryptionContext implements EncryptionContext {
@@ -66,4 +67,10 @@ public <T> T read(@Nullable Object value, TypeInformation<T> target) {
6667
public <T> T write(@Nullable Object value, TypeInformation<T> target) {
6768
return conversionContext.write(value, target);
6869
}
70+
71+
@Override
72+
@Nullable
73+
public String getFieldNameAndQueryOperator() {
74+
return conversionContext.getFieldNameAndQueryOperator();
75+
}
6976
}

‎spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@
1515
*/
1616
package org.springframework.data.mongodb.core.convert.encryption;
1717

18+
import static java.util.Arrays.*;
19+
import static java.util.Collections.*;
20+
import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*;
21+
import static org.springframework.data.mongodb.core.encryption.EncryptionOptions.*;
22+
1823
import java.util.Collection;
1924
import java.util.LinkedHashMap;
25+
import java.util.List;
2026
import java.util.Map;
2127

2228
import org.apache.commons.logging.Log;
@@ -31,9 +37,11 @@
3137
import org.springframework.data.mongodb.core.convert.MongoConversionContext;
3238
import org.springframework.data.mongodb.core.encryption.Encryption;
3339
import org.springframework.data.mongodb.core.encryption.EncryptionContext;
40+
import org.springframework.data.mongodb.core.encryption.EncryptionKey;
3441
import org.springframework.data.mongodb.core.encryption.EncryptionKeyResolver;
3542
import org.springframework.data.mongodb.core.encryption.EncryptionOptions;
3643
import org.springframework.data.mongodb.core.mapping.Encrypted;
44+
import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted;
3745
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
3846
import org.springframework.data.mongodb.util.BsonUtils;
3947
import org.springframework.lang.Nullable;
@@ -44,11 +52,14 @@
4452
* {@link Encrypted @Encrypted} to provide key and algorithm metadata.
4553
*
4654
* @author Christoph Strobl
55+
* @author Ross Lawley
4756
* @since 4.1
4857
*/
4958
public class MongoEncryptionConverter implements EncryptingConverter<Object, Object> {
5059

5160
private static final Log LOGGER = LogFactory.getLog(MongoEncryptionConverter.class);
61+
private static final String EQUALITY_OPERATOR = "$eq";
62+
private static final List<String> RANGE_OPERATORS = asList("$gt", "$gte", "$lt", "$lte");
5263

5364
private final Encryption<BsonValue, BsonBinary> encryption;
5465
private final EncryptionKeyResolver keyResolver;
@@ -161,8 +172,42 @@ public Object encrypt(Object value, EncryptionContext context) {
161172
getProperty(context).getOwner().getName(), getProperty(context).getName()));
162173
}
163174

164-
EncryptionOptions encryptionOptions = new EncryptionOptions(annotation.algorithm(), keyResolver.getKey(context));
175+
boolean encryptExpression = false;
176+
String algorithm = annotation.algorithm();
177+
EncryptionKey key = keyResolver.getKey(context);
178+
EncryptionOptions encryptionOptions = new EncryptionOptions(algorithm, key);
179+
String fieldNameAndQueryOperator = context.getFieldNameAndQueryOperator();
180+
181+
ExplicitEncrypted explicitEncryptedAnnotation = persistentProperty.findAnnotation(ExplicitEncrypted.class);
182+
if (explicitEncryptedAnnotation != null) {
183+
QueryableEncryptionOptions queryableEncryptionOptions = QueryableEncryptionOptions.none();
184+
String rangeOptions = explicitEncryptedAnnotation.rangeOptions();
185+
if (!rangeOptions.isEmpty()) {
186+
queryableEncryptionOptions = queryableEncryptionOptions.rangeOptions(Document.parse(rangeOptions));
187+
}
165188

189+
if (explicitEncryptedAnnotation.contentionFactor() >= 0) {
190+
queryableEncryptionOptions = queryableEncryptionOptions
191+
.contentionFactor(explicitEncryptedAnnotation.contentionFactor());
192+
}
193+
194+
boolean isPartOfARangeQuery = algorithm.equalsIgnoreCase(RANGE) && fieldNameAndQueryOperator != null;
195+
if (isPartOfARangeQuery) {
196+
encryptExpression = true;
197+
queryableEncryptionOptions = queryableEncryptionOptions.queryType("range");
198+
}
199+
encryptionOptions = new EncryptionOptions(algorithm, key, queryableEncryptionOptions);
200+
}
201+
202+
if (encryptExpression) {
203+
return encryptExpression(fieldNameAndQueryOperator, value, encryptionOptions);
204+
} else {
205+
return encryptValue(value, context, persistentProperty, encryptionOptions);
206+
}
207+
}
208+
209+
private BsonBinary encryptValue(Object value, EncryptionContext context, MongoPersistentProperty persistentProperty,
210+
EncryptionOptions encryptionOptions) {
166211
if (!persistentProperty.isEntity()) {
167212

168213
if (persistentProperty.isCollectionLike()) {
@@ -187,6 +232,42 @@ public Object encrypt(Object value, EncryptionContext context) {
187232
return encryption.encrypt(BsonUtils.simpleToBsonValue(write), encryptionOptions);
188233
}
189234

235+
/**
236+
* Encrypts a range query expression.
237+
*
238+
* <p>The mongodb-crypt {@code encryptExpression} has strict formatting requirements so this method
239+
* ensures these requirements are met and then picks out and returns just the value for use with a range query.
240+
*
241+
* @param fieldNameAndQueryOperator field name and query operator
242+
* @param value the value of the expression to be encrypted
243+
* @param encryptionOptions the options
244+
* @return the encrypted range value for use in a range query
245+
*/
246+
private BsonValue encryptExpression(String fieldNameAndQueryOperator, Object value,
247+
EncryptionOptions encryptionOptions) {
248+
BsonValue doc = BsonUtils.simpleToBsonValue(value);
249+
250+
String fieldName = fieldNameAndQueryOperator;
251+
String queryOperator = EQUALITY_OPERATOR;
252+
253+
int pos = fieldNameAndQueryOperator.lastIndexOf(".$");
254+
if (pos > -1) {
255+
fieldName = fieldNameAndQueryOperator.substring(0, pos);
256+
queryOperator = fieldNameAndQueryOperator.substring(pos + 1);
257+
}
258+
259+
if (!RANGE_OPERATORS.contains(queryOperator)) {
260+
throw new AssertionError(String.format("Not a valid range query. Querying a range encrypted field but the "
261+
+ "query operator '%s' for field path '%s' is not a range query.", queryOperator, fieldName));
262+
}
263+
264+
BsonDocument encryptExpression = new BsonDocument("$and",
265+
new BsonArray(singletonList(new BsonDocument(fieldName, new BsonDocument(queryOperator, doc)))));
266+
267+
BsonDocument result = encryption.encryptExpression(encryptExpression, encryptionOptions);
268+
return result.getArray("$and").get(0).asDocument().getDocument(fieldName).getBinary(queryOperator);
269+
}
270+
190271
private BsonValue collectionLikeToBsonValue(Object value, MongoPersistentProperty property,
191272
EncryptionContext context) {
192273

‎spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
*/
1616
package org.springframework.data.mongodb.core.encryption;
1717

18+
import org.bson.BsonDocument;
19+
1820
/**
1921
* Component responsible for encrypting and decrypting values.
2022
*
2123
* @author Christoph Strobl
24+
* @author Ross Lawley
2225
* @since 4.1
2326
*/
2427
public interface Encryption<S, T> {
@@ -40,4 +43,16 @@ public interface Encryption<S, T> {
4043
*/
4144
S decrypt(T value);
4245

46+
/**
47+
* Encrypt the given expression.
48+
*
49+
* @param value must not be {@literal null}.
50+
* @param options must not be {@literal null}.
51+
* @return the encrypted expression.
52+
* @since 4.5.0
53+
*/
54+
default BsonDocument encryptExpression(BsonDocument value, EncryptionOptions options) {
55+
throw new UnsupportedOperationException("Unsupported encryption method");
56+
}
57+
4358
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
* Context to encapsulate encryption for a specific {@link MongoPersistentProperty}.
2626
*
2727
* @author Christoph Strobl
28+
* @author Ross Lawley
2829
* @since 4.1
2930
*/
3031
public interface EncryptionContext {
@@ -128,4 +129,13 @@ default <T> T write(@Nullable Object value, Class<T> target) {
128129

129130
EvaluationContext getEvaluationContext(Object source);
130131

132+
/**
133+
* The field name and field query operator
134+
*
135+
* @return can be {@literal null}.
136+
*/
137+
@Nullable
138+
default String getFieldNameAndQueryOperator() {
139+
return null;
140+
}
131141
}

‎spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java‎

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,43 @@
1515
*/
1616
package org.springframework.data.mongodb.core.encryption;
1717

18+
import java.util.Objects;
19+
import java.util.Optional;
20+
21+
import com.mongodb.client.model.vault.RangeOptions;
22+
import org.bson.Document;
23+
import org.springframework.data.mongodb.core.FindAndReplaceOptions;
24+
import org.springframework.data.mongodb.util.BsonUtils;
25+
import org.springframework.data.util.Optionals;
26+
import org.springframework.lang.Nullable;
1827
import org.springframework.util.Assert;
1928
import org.springframework.util.ObjectUtils;
2029

2130
/**
2231
* Options, like the {@link #algorithm()}, to apply when encrypting values.
2332
*
2433
* @author Christoph Strobl
34+
* @author Ross Lawley
2535
* @since 4.1
2636
*/
2737
public class EncryptionOptions {
2838

2939
private final String algorithm;
3040
private final EncryptionKey key;
41+
private final QueryableEncryptionOptions queryableEncryptionOptions;
3142

3243
public EncryptionOptions(String algorithm, EncryptionKey key) {
44+
this(algorithm, key, QueryableEncryptionOptions.NONE);
45+
}
3346

47+
public EncryptionOptions(String algorithm, EncryptionKey key, QueryableEncryptionOptions queryableEncryptionOptions) {
3448
Assert.hasText(algorithm, "Algorithm must not be empty");
3549
Assert.notNull(key, "EncryptionKey must not be empty");
50+
Assert.notNull(key, "QueryableEncryptionOptions must not be empty");
3651

3752
this.key = key;
3853
this.algorithm = algorithm;
54+
this.queryableEncryptionOptions = queryableEncryptionOptions;
3955
}
4056

4157
public EncryptionKey key() {
@@ -46,6 +62,10 @@ public String algorithm() {
4662
return algorithm;
4763
}
4864

65+
public QueryableEncryptionOptions queryableEncryptionOptions() {
66+
return queryableEncryptionOptions;
67+
}
68+
4969
@Override
5070
public boolean equals(Object o) {
5171

@@ -61,19 +81,182 @@ public boolean equals(Object o) {
6181
if (!ObjectUtils.nullSafeEquals(algorithm, that.algorithm)) {
6282
return false;
6383
}
64-
return ObjectUtils.nullSafeEquals(key, that.key);
84+
if (!ObjectUtils.nullSafeEquals(key, that.key)) {
85+
return false;
86+
}
87+
88+
return ObjectUtils.nullSafeEquals(queryableEncryptionOptions, that.queryableEncryptionOptions);
6589
}
6690

6791
@Override
6892
public int hashCode() {
6993

7094
int result = ObjectUtils.nullSafeHashCode(algorithm);
7195
result = 31 * result + ObjectUtils.nullSafeHashCode(key);
96+
result = 31 * result + ObjectUtils.nullSafeHashCode(queryableEncryptionOptions);
7297
return result;
7398
}
7499

75100
@Override
76101
public String toString() {
77-
return "EncryptionOptions{" + "algorithm='" + algorithm + '\'' + ", key=" + key + '}';
102+
return "EncryptionOptions{" + "algorithm='" + algorithm + '\'' + ", key=" + key + ", queryableEncryptionOptions='"
103+
+ queryableEncryptionOptions + "'}";
104+
}
105+
106+
/**
107+
* Options, like the {@link #getQueryType()}, to apply when encrypting queryable values.
108+
*
109+
* @author Ross Lawley
110+
*/
111+
public static class QueryableEncryptionOptions {
112+
113+
private static final QueryableEncryptionOptions NONE = new QueryableEncryptionOptions(null, null, null);
114+
115+
private final @Nullable String queryType;
116+
private final @Nullable Long contentionFactor;
117+
private final @Nullable Document rangeOptions;
118+
119+
private QueryableEncryptionOptions(@Nullable String queryType, @Nullable Long contentionFactor,
120+
@Nullable Document rangeOptions) {
121+
this.queryType = queryType;
122+
this.contentionFactor = contentionFactor;
123+
this.rangeOptions = rangeOptions;
124+
}
125+
126+
/**
127+
* Create an empty {@link QueryableEncryptionOptions}.
128+
*
129+
* @return unmodifiable {@link QueryableEncryptionOptions} instance.
130+
*/
131+
public static QueryableEncryptionOptions none() {
132+
return NONE;
133+
}
134+
135+
/**
136+
* Define the {@code queryType} to be used for queryable document encryption.
137+
*
138+
* @param queryType can be {@literal null}.
139+
* @return new instance of {@link QueryableEncryptionOptions}.
140+
*/
141+
public QueryableEncryptionOptions queryType(@Nullable String queryType) {
142+
return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions);
143+
}
144+
145+
/**
146+
* Define the {@code contentionFactor} to be used for queryable document encryption.
147+
*
148+
* @param contentionFactor can be {@literal null}.
149+
* @return new instance of {@link QueryableEncryptionOptions}.
150+
*/
151+
public QueryableEncryptionOptions contentionFactor(@Nullable Long contentionFactor) {
152+
return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions);
153+
}
154+
155+
/**
156+
* Define the {@code rangeOptions} to be used for queryable document encryption.
157+
*
158+
* @param rangeOptions can be {@literal null}.
159+
* @return new instance of {@link QueryableEncryptionOptions}.
160+
*/
161+
public QueryableEncryptionOptions rangeOptions(@Nullable Document rangeOptions) {
162+
return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions);
163+
}
164+
165+
/**
166+
* Get the {@code queryType} to apply.
167+
*
168+
* @return {@link Optional#empty()} if not set.
169+
*/
170+
public Optional<String> getQueryType() {
171+
return Optional.ofNullable(queryType);
172+
}
173+
174+
/**
175+
* Get the {@code contentionFactor} to apply.
176+
*
177+
* @return {@link Optional#empty()} if not set.
178+
*/
179+
public Optional<Long> getContentionFactor() {
180+
return Optional.ofNullable(contentionFactor);
181+
}
182+
183+
/**
184+
* Get the {@code rangeOptions} to apply.
185+
*
186+
* @return {@link Optional#empty()} if not set.
187+
*/
188+
public Optional<RangeOptions> getRangeOptions() {
189+
if (rangeOptions == null) {
190+
return Optional.empty();
191+
}
192+
RangeOptions encryptionRangeOptions = new RangeOptions();
193+
194+
if (rangeOptions.containsKey("min")) {
195+
encryptionRangeOptions.min(BsonUtils.simpleToBsonValue(rangeOptions.get("min")));
196+
}
197+
if (rangeOptions.containsKey("max")) {
198+
encryptionRangeOptions.max(BsonUtils.simpleToBsonValue(rangeOptions.get("max")));
199+
}
200+
if (rangeOptions.containsKey("trimFactor")) {
201+
Object trimFactor = rangeOptions.get("trimFactor");
202+
Assert.isInstanceOf(Integer.class, trimFactor, () -> String
203+
.format("Expected to find a %s but it turned out to be %s.", Integer.class, trimFactor.getClass()));
204+
205+
encryptionRangeOptions.trimFactor((Integer) trimFactor);
206+
}
207+
208+
if (rangeOptions.containsKey("sparsity")) {
209+
Object sparsity = rangeOptions.get("sparsity");
210+
Assert.isInstanceOf(Number.class, sparsity,
211+
() -> String.format("Expected to find a %s but it turned out to be %s.", Long.class, sparsity.getClass()));
212+
encryptionRangeOptions.sparsity(((Number) sparsity).longValue());
213+
}
214+
215+
if (rangeOptions.containsKey("precision")) {
216+
Object precision = rangeOptions.get("precision");
217+
Assert.isInstanceOf(Number.class, precision, () -> String
218+
.format("Expected to find a %s but it turned out to be %s.", Integer.class, precision.getClass()));
219+
encryptionRangeOptions.precision(((Number) precision).intValue());
220+
}
221+
return Optional.of(encryptionRangeOptions);
222+
}
223+
224+
/**
225+
* @return {@literal true} if no arguments set.
226+
*/
227+
boolean isEmpty() {
228+
return !Optionals.isAnyPresent(getQueryType(), getContentionFactor(), getRangeOptions());
229+
}
230+
231+
@Override
232+
public String toString() {
233+
return "QueryableEncryptionOptions{" + "queryType='" + queryType + '\'' + ", contentionFactor=" + contentionFactor
234+
+ ", rangeOptions=" + rangeOptions + '}';
235+
}
236+
237+
@Override
238+
public boolean equals(Object o) {
239+
if (this == o) {
240+
return true;
241+
}
242+
if (o == null || getClass() != o.getClass()) {
243+
return false;
244+
}
245+
QueryableEncryptionOptions that = (QueryableEncryptionOptions) o;
246+
247+
if (!ObjectUtils.nullSafeEquals(queryType, that.queryType)) {
248+
return false;
249+
}
250+
251+
if (!ObjectUtils.nullSafeEquals(contentionFactor, that.contentionFactor)) {
252+
return false;
253+
}
254+
return ObjectUtils.nullSafeEquals(rangeOptions, that.rangeOptions);
255+
}
256+
257+
@Override
258+
public int hashCode() {
259+
return Objects.hash(queryType, contentionFactor, rangeOptions);
260+
}
78261
}
79262
}

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.function.Supplier;
1919

2020
import org.bson.BsonBinary;
21+
import org.bson.BsonDocument;
2122
import org.bson.BsonValue;
2223
import org.springframework.data.mongodb.core.encryption.EncryptionKey.Type;
2324
import org.springframework.util.Assert;
@@ -29,6 +30,7 @@
2930
* {@link ClientEncryption} based {@link Encryption} implementation.
3031
*
3132
* @author Christoph Strobl
33+
* @author Ross Lawley
3234
* @since 4.1
3335
*/
3436
public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary> {
@@ -59,7 +61,19 @@ public BsonValue decrypt(BsonBinary value) {
5961

6062
@Override
6163
public BsonBinary encrypt(BsonValue value, EncryptionOptions options) {
64+
return getClientEncryption().encrypt(value, createEncryptOptions(options));
65+
}
66+
67+
@Override
68+
public BsonDocument encryptExpression(BsonDocument value, EncryptionOptions options) {
69+
return getClientEncryption().encryptExpression(value, createEncryptOptions(options));
70+
}
71+
72+
public ClientEncryption getClientEncryption() {
73+
return source.get();
74+
}
6275

76+
private EncryptOptions createEncryptOptions(EncryptionOptions options) {
6377
EncryptOptions encryptOptions = new EncryptOptions(options.algorithm());
6478

6579
if (Type.ALT.equals(options.key().type())) {
@@ -68,11 +82,10 @@ public BsonBinary encrypt(BsonValue value, EncryptionOptions options) {
6882
encryptOptions = encryptOptions.keyId((BsonBinary) options.key().value());
6983
}
7084

71-
return getClientEncryption().encrypt(value, encryptOptions);
72-
}
73-
74-
public ClientEncryption getClientEncryption() {
75-
return source.get();
85+
options.queryableEncryptionOptions().getQueryType().map(encryptOptions::queryType);
86+
options.queryableEncryptionOptions().getContentionFactor().map(encryptOptions::contentionFactor);
87+
options.queryableEncryptionOptions().getRangeOptions().map(encryptOptions::rangeOptions);
88+
return encryptOptions;
7689
}
7790

7891
}

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
* </pre>
4848
*
4949
* @author Christoph Strobl
50+
* @author Ross Lawley
5051
* @since 4.1
5152
* @see ValueConverter
5253
*/
@@ -60,7 +61,8 @@
6061
* Define the algorithm to use.
6162
* <p>
6263
* A {@literal Deterministic} algorithm ensures that a given input value always encrypts to the same output while a
63-
* {@literal randomized} one will produce different results every time.
64+
* {@literal randomized} one will produce different results every time. A {@literal range} algorithm allows for
65+
* the value to be queried whilst encrypted.
6466
* <p>
6567
* Please make sure to use an algorithm that is in line with MongoDB's encryption rules for simple types, complex
6668
* objects and arrays as well as the query limitations that come with each of them.
@@ -84,11 +86,30 @@
8486
*/
8587
String keyAltName() default "";
8688

89+
/**
90+
* Set the contention factor
91+
* <p>
92+
* Only required when using {@literal range} encryption.
93+
* @return the contention factor
94+
*/
95+
long contentionFactor() default -1;
96+
97+
/**
98+
* Set the {@literal range} options
99+
* <p>
100+
* Should be valid extended json representing the range options and including the following values:
101+
* {@code min}, {@code max}, {@code trimFactor} and {@code sparsity}.
102+
*
103+
* @return the json representation of range options
104+
*/
105+
String rangeOptions() default "";
106+
87107
/**
88108
* The {@link EncryptingConverter} type handling the {@literal en-/decryption} of the annotated property.
89109
*
90110
* @return the configured {@link EncryptingConverter}. A {@link MongoEncryptionConverter} by default.
91111
*/
92112
@AliasFor(annotation = ValueConverter.class, value = "value")
93113
Class<? extends PropertyValueConverter> value() default MongoEncryptionConverter.class;
114+
94115
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import com.mongodb.client.MongoDatabase;
3535
import com.mongodb.client.MongoIterable;
3636
import com.mongodb.client.model.IndexOptions;
37+
import com.mongodb.client.model.vault.RangeOptions;
3738
import com.mongodb.reactivestreams.client.MapReducePublisher;
3839

3940
/**
@@ -42,18 +43,23 @@
4243
* This class is for internal use within the framework and should not be used by applications.
4344
*
4445
* @author Christoph Strobl
46+
* @author Ross Lawley
4547
* @since 4.3
4648
*/
4749
public class MongoCompatibilityAdapter {
4850

4951
private static final String NO_LONGER_SUPPORTED = "%s is no longer supported on Mongo Client 5 or newer";
52+
private static final String NOT_SUPPORTED_ON_4 = "%s is not supported on Mongo Client 4";
5053

5154
private static final @Nullable Method getStreamFactoryFactory = ReflectionUtils.findMethod(MongoClientSettings.class,
5255
"getStreamFactoryFactory");
5356

5457
private static final @Nullable Method setBucketSize = ReflectionUtils.findMethod(IndexOptions.class, "bucketSize",
5558
Double.class);
5659

60+
private static final @Nullable Method setTrimFactor = ReflectionUtils.findMethod(RangeOptions.class, "setTrimFactor",
61+
Integer.class);
62+
5763
/**
5864
* Return a compatibility adapter for {@link MongoClientSettings.Builder}.
5965
*
@@ -199,6 +205,10 @@ public interface MongoDatabaseAdapterBuilder {
199205
MongoDatabaseAdapter forDb(com.mongodb.client.MongoDatabase db);
200206
}
201207

208+
public interface RangeOptionsAdapter {
209+
void trimFactor(Integer trimFactor);
210+
}
211+
202212
@SuppressWarnings({ "unchecked", "DataFlowIssue" })
203213
public static class MongoDatabaseAdapter {
204214

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
/*
2+
* Copyright 2023-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.mongodb.core.encryption;
17+
18+
import static java.util.Arrays.*;
19+
import static org.assertj.core.api.Assertions.*;
20+
import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*;
21+
import static org.springframework.data.mongodb.core.query.Criteria.*;
22+
23+
import java.security.SecureRandom;
24+
import java.util.Map;
25+
import java.util.Objects;
26+
import java.util.concurrent.atomic.AtomicReference;
27+
import java.util.function.Supplier;
28+
import java.util.stream.Collectors;
29+
30+
import com.mongodb.AutoEncryptionSettings;
31+
import com.mongodb.ClientEncryptionSettings;
32+
import com.mongodb.ConnectionString;
33+
import com.mongodb.MongoClientSettings;
34+
import com.mongodb.MongoNamespace;
35+
import com.mongodb.client.MongoClient;
36+
import com.mongodb.client.MongoClients;
37+
import com.mongodb.client.MongoCollection;
38+
import com.mongodb.client.MongoDatabase;
39+
import com.mongodb.client.model.CreateCollectionOptions;
40+
import com.mongodb.client.model.CreateEncryptedCollectionParams;
41+
import com.mongodb.client.model.Filters;
42+
import com.mongodb.client.model.IndexOptions;
43+
import com.mongodb.client.model.Indexes;
44+
import com.mongodb.client.vault.ClientEncryption;
45+
import com.mongodb.client.vault.ClientEncryptions;
46+
47+
import org.bson.BsonArray;
48+
import org.bson.BsonBinary;
49+
import org.bson.BsonDocument;
50+
import org.bson.BsonInt32;
51+
import org.bson.BsonInt64;
52+
import org.bson.BsonNull;
53+
import org.bson.BsonString;
54+
import org.bson.BsonValue;
55+
import org.bson.Document;
56+
import org.junit.jupiter.api.AfterEach;
57+
import org.junit.jupiter.api.Test;
58+
import org.junit.jupiter.api.extension.ExtendWith;
59+
import org.springframework.beans.factory.DisposableBean;
60+
import org.springframework.beans.factory.annotation.Autowired;
61+
import org.springframework.context.ApplicationContext;
62+
import org.springframework.context.annotation.Bean;
63+
import org.springframework.data.convert.PropertyValueConverterFactory;
64+
import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration;
65+
import org.springframework.data.mongodb.core.MongoTemplate;
66+
import org.springframework.data.mongodb.core.convert.MongoCustomConversions.MongoConverterConfigurationAdapter;
67+
import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter;
68+
import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted;
69+
import org.springframework.data.mongodb.test.util.EnableIfMongoServerVersion;
70+
import org.springframework.data.mongodb.test.util.EnableIfReplicaSetAvailable;
71+
import org.springframework.data.mongodb.test.util.MongoClientExtension;
72+
import org.springframework.data.util.Lazy;
73+
import org.springframework.test.context.ContextConfiguration;
74+
import org.springframework.test.context.junit.jupiter.SpringExtension;
75+
76+
/**
77+
* @author Ross Lawley
78+
*/
79+
@ExtendWith({ MongoClientExtension.class, SpringExtension.class })
80+
@EnableIfMongoServerVersion(isGreaterThanEqual = "8.0")
81+
@EnableIfReplicaSetAvailable
82+
@ContextConfiguration(classes = RangeEncryptionTests.EncryptionConfig.class)
83+
class RangeEncryptionTests {
84+
85+
@Autowired MongoTemplate template;
86+
87+
@AfterEach
88+
void tearDown() {
89+
template.getDb().getCollection("test").deleteMany(new BsonDocument());
90+
}
91+
92+
@Test
93+
void canGreaterThanEqualMatchRangeEncryptedField() {
94+
Person source = createPerson();
95+
template.insert(source);
96+
97+
Person loaded = template.query(Person.class).matching(where("encryptedInt").gte(source.encryptedInt)).firstValue();
98+
assertThat(loaded).isEqualTo(source);
99+
}
100+
101+
@Test
102+
void canLesserThanEqualMatchRangeEncryptedField() {
103+
Person source = createPerson();
104+
template.insert(source);
105+
106+
Person loaded = template.query(Person.class).matching(where("encryptedInt").lte(source.encryptedInt)).firstValue();
107+
assertThat(loaded).isEqualTo(source);
108+
}
109+
110+
@Test
111+
void canRangeMatchRangeEncryptedField() {
112+
Person source = createPerson();
113+
template.insert(source);
114+
115+
Person loaded = template.query(Person.class).matching(where("encryptedLong").lte(1001L).gte(1001L)).firstValue();
116+
assertThat(loaded).isEqualTo(source);
117+
}
118+
119+
@Test
120+
void canUpdateRangeEncryptedField() {
121+
Person source = createPerson();
122+
template.insert(source);
123+
124+
source.encryptedInt = 123;
125+
source.encryptedLong = 9999L;
126+
template.save(source);
127+
128+
Person loaded = template.query(Person.class).matching(where("id").is(source.id)).firstValue();
129+
assertThat(loaded).isEqualTo(source);
130+
}
131+
132+
@Test
133+
void errorsWhenUsingNonRangeOperatorEqOnRangeEncryptedField() {
134+
Person source = createPerson();
135+
template.insert(source);
136+
137+
assertThatThrownBy(
138+
() -> template.query(Person.class).matching(where("encryptedInt").is(source.encryptedInt)).firstValue())
139+
.isInstanceOf(AssertionError.class)
140+
.hasMessageStartingWith("Not a valid range query. Querying a range encrypted field but "
141+
+ "the query operator '$eq' for field path 'encryptedInt' is not a range query.");
142+
143+
}
144+
145+
@Test
146+
void errorsWhenUsingNonRangeOperatorInOnRangeEncryptedField() {
147+
Person source = createPerson();
148+
template.insert(source);
149+
150+
assertThatThrownBy(
151+
() -> template.query(Person.class).matching(where("encryptedLong").in(1001L, 9999L)).firstValue())
152+
.isInstanceOf(AssertionError.class)
153+
.hasMessageStartingWith("Not a valid range query. Querying a range encrypted field but "
154+
+ "the query operator '$in' for field path 'encryptedLong' is not a range query.");
155+
156+
}
157+
158+
private Person createPerson() {
159+
Person source = new Person();
160+
source.id = "id-1";
161+
source.encryptedInt = 101;
162+
source.encryptedLong = 1001L;
163+
return source;
164+
}
165+
166+
protected static class EncryptionConfig extends AbstractMongoClientConfiguration {
167+
168+
private static final String LOCAL_KMS_PROVIDER = "local";
169+
170+
private static final Lazy<Map<String, Map<String, Object>>> LAZY_KMS_PROVIDERS = Lazy.of(() -> {
171+
byte[] localMasterKey = new byte[96];
172+
new SecureRandom().nextBytes(localMasterKey);
173+
return Map.of(LOCAL_KMS_PROVIDER, Map.of("key", localMasterKey));
174+
});
175+
176+
@Autowired ApplicationContext applicationContext;
177+
178+
@Override
179+
protected String getDatabaseName() {
180+
return "qe-test";
181+
}
182+
183+
@Bean
184+
public MongoClient mongoClient() {
185+
return super.mongoClient();
186+
}
187+
188+
@Override
189+
protected void configureConverters(MongoConverterConfigurationAdapter converterConfigurationAdapter) {
190+
converterConfigurationAdapter
191+
.registerPropertyValueConverterFactory(PropertyValueConverterFactory.beanFactoryAware(applicationContext))
192+
.useNativeDriverJavaTimeCodecs();
193+
}
194+
195+
@Bean
196+
MongoEncryptionConverter encryptingConverter(MongoClientEncryption mongoClientEncryption) {
197+
Lazy<Map<String, BsonBinary>> lazyDataKeyMap = Lazy.of(() -> {
198+
try (MongoClient client = mongoClient()) {
199+
MongoDatabase database = client.getDatabase(getDatabaseName());
200+
database.getCollection("test").drop();
201+
202+
ClientEncryption clientEncryption = mongoClientEncryption.getClientEncryption();
203+
BsonDocument encryptedFields = new BsonDocument().append("fields",
204+
new BsonArray(asList(
205+
new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedInt"))
206+
.append("bsonType", new BsonString("int"))
207+
.append("queries",
208+
new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L))
209+
.append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1))
210+
.append("min", new BsonInt32(0)).append("max", new BsonInt32(200))),
211+
new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedLong"))
212+
.append("bsonType", new BsonString("long")).append("queries",
213+
new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L))
214+
.append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1))
215+
.append("min", new BsonInt64(1000)).append("max", new BsonInt64(9999))))));
216+
217+
BsonDocument local = clientEncryption.createEncryptedCollection(database, "test",
218+
new CreateCollectionOptions().encryptedFields(encryptedFields),
219+
new CreateEncryptedCollectionParams(LOCAL_KMS_PROVIDER));
220+
221+
return local.getArray("fields").stream().map(BsonValue::asDocument).collect(
222+
Collectors.toMap(field -> field.getString("path").getValue(), field -> field.getBinary("keyId")));
223+
}
224+
});
225+
return new MongoEncryptionConverter(mongoClientEncryption, EncryptionKeyResolver
226+
.annotated((ctx) -> EncryptionKey.keyId(lazyDataKeyMap.get().get(ctx.getProperty().getFieldName()))));
227+
}
228+
229+
@Bean
230+
CachingMongoClientEncryption clientEncryption(ClientEncryptionSettings encryptionSettings) {
231+
return new CachingMongoClientEncryption(() -> ClientEncryptions.create(encryptionSettings));
232+
}
233+
234+
@Override
235+
protected void configureClientSettings(MongoClientSettings.Builder builder) {
236+
try (MongoClient client = MongoClients.create()) {
237+
ClientEncryptionSettings clientEncryptionSettings = encryptionSettings(client);
238+
239+
builder.autoEncryptionSettings(AutoEncryptionSettings.builder() //
240+
.kmsProviders(clientEncryptionSettings.getKmsProviders()) //
241+
.keyVaultNamespace(clientEncryptionSettings.getKeyVaultNamespace()) //
242+
.bypassQueryAnalysis(true).build());
243+
}
244+
}
245+
246+
@Bean
247+
ClientEncryptionSettings encryptionSettings(MongoClient mongoClient) {
248+
MongoNamespace keyVaultNamespace = new MongoNamespace("encryption.testKeyVault");
249+
MongoCollection<Document> keyVaultCollection = mongoClient.getDatabase(keyVaultNamespace.getDatabaseName())
250+
.getCollection(keyVaultNamespace.getCollectionName());
251+
keyVaultCollection.drop();
252+
// Ensure that two data keys cannot share the same keyAltName.
253+
keyVaultCollection.createIndex(Indexes.ascending("keyAltNames"),
254+
new IndexOptions().unique(true).partialFilterExpression(Filters.exists("keyAltNames")));
255+
256+
mongoClient.getDatabase(getDatabaseName()).getCollection("test").drop(); // Clear old data
257+
258+
// Create the ClientEncryption instance
259+
return ClientEncryptionSettings.builder() //
260+
.keyVaultMongoClientSettings(
261+
MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://localhost")).build()) //
262+
.keyVaultNamespace(keyVaultNamespace.getFullName()) //
263+
.kmsProviders(LAZY_KMS_PROVIDERS.get()) //
264+
.build();
265+
}
266+
}
267+
268+
static class CachingMongoClientEncryption extends MongoClientEncryption implements DisposableBean {
269+
270+
static final AtomicReference<ClientEncryption> cache = new AtomicReference<>();
271+
272+
CachingMongoClientEncryption(Supplier<ClientEncryption> source) {
273+
super(() -> {
274+
ClientEncryption clientEncryption = cache.get();
275+
if (clientEncryption == null) {
276+
clientEncryption = source.get();
277+
cache.set(clientEncryption);
278+
}
279+
280+
return clientEncryption;
281+
});
282+
}
283+
284+
@Override
285+
public void destroy() {
286+
ClientEncryption clientEncryption = cache.get();
287+
if (clientEncryption != null) {
288+
clientEncryption.close();
289+
cache.set(null);
290+
}
291+
}
292+
}
293+
294+
@org.springframework.data.mongodb.core.mapping.Document("test")
295+
static class Person {
296+
297+
String id;
298+
String name;
299+
300+
@ExplicitEncrypted(algorithm = RANGE, contentionFactor = 0L,
301+
rangeOptions = "{\"min\": 0, \"max\": 200, \"trimFactor\": 1, \"sparsity\": 1}") Integer encryptedInt;
302+
@ExplicitEncrypted(algorithm = RANGE, contentionFactor = 0L,
303+
rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") Long encryptedLong;
304+
305+
public String getId() {
306+
return this.id;
307+
}
308+
309+
public void setId(String id) {
310+
this.id = id;
311+
}
312+
313+
public String getName() {
314+
return this.name;
315+
}
316+
317+
public void setName(String name) {
318+
this.name = name;
319+
}
320+
321+
public Integer getEncryptedInt() {
322+
return this.encryptedInt;
323+
}
324+
325+
public void setEncryptedInt(Integer encryptedInt) {
326+
this.encryptedInt = encryptedInt;
327+
}
328+
329+
public Long getEncryptedLong() {
330+
return this.encryptedLong;
331+
}
332+
333+
public void setEncryptedLong(Long encryptedLong) {
334+
this.encryptedLong = encryptedLong;
335+
}
336+
337+
@Override
338+
public boolean equals(Object o) {
339+
if (this == o)
340+
return true;
341+
if (o == null || getClass() != o.getClass())
342+
return false;
343+
344+
Person person = (Person) o;
345+
return Objects.equals(id, person.id) && Objects.equals(name, person.name)
346+
&& Objects.equals(encryptedInt, person.encryptedInt) && Objects.equals(encryptedLong, person.encryptedLong);
347+
}
348+
349+
@Override
350+
public int hashCode() {
351+
int result = Objects.hashCode(id);
352+
result = 31 * result + Objects.hashCode(name);
353+
result = 31 * result + Objects.hashCode(encryptedInt);
354+
result = 31 * result + Objects.hashCode(encryptedLong);
355+
return result;
356+
}
357+
358+
@Override
359+
public String toString() {
360+
return "Person{" + "id='" + id + '\'' + ", name='" + name + '\'' + ", encryptedInt=" + encryptedInt
361+
+ ", encryptedLong=" + encryptedLong + '}';
362+
}
363+
}
364+
365+
}

0 commit comments

Comments
 (0)
Please sign in to comment.