Browse Source

Introduce Queryable annotation and add Schema derivation.

This commit decouples queryable encryption from explicit encryption and introduces the Queryable annotation to represent different query types like range and equality.
Additionally it removes value conversion from range encryption and fixes update mapping of range encrypted fields.

Original Pull Request: #4885
pull/4964/head
Christoph Strobl 1 year ago
parent
commit
aec2398694
No known key found for this signature in database
GPG Key ID: E6054036D0C37A4B
  1. 286
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java
  2. 7
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java
  3. 35
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java
  4. 98
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java
  5. 27
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java
  6. 10
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/UpdateMapper.java
  7. 5
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java
  8. 91
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java
  9. 3
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java
  10. 105
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java
  11. 61
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java
  12. 18
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java
  13. 49
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Queryable.java
  14. 56
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/RangeEncrypted.java
  15. 97
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java
  16. 26
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java
  17. 2
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java
  18. 34
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristic.java
  19. 147
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java
  20. 31
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java
  21. 105
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java
  22. 20
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/DefaultIndexOperationsIntegrationTests.java
  23. 74
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java
  24. 140
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java
  25. 362
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java
  26. 27
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java
  27. 155
      src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc

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

@ -15,18 +15,35 @@ @@ -15,18 +15,35 @@
*/
package org.springframework.data.mongodb.core;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.bson.conversions.Bson;
import org.bson.BsonBinary;
import org.bson.BsonBinarySubType;
import org.bson.BsonNull;
import org.bson.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.QueryableJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
import org.springframework.data.mongodb.core.schema.QueryCharacteristic;
import org.springframework.data.mongodb.core.timeseries.Granularity;
import org.springframework.data.mongodb.core.timeseries.GranularityDefinition;
import org.springframework.data.mongodb.core.validation.Validator;
import org.springframework.data.util.Optionals;
import org.springframework.lang.CheckReturnValue;
import org.springframework.lang.Contract;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
@ -53,11 +70,12 @@ public class CollectionOptions { @@ -53,11 +70,12 @@ public class CollectionOptions {
private ValidationOptions validationOptions;
private @Nullable TimeSeriesOptions timeSeriesOptions;
private @Nullable CollectionChangeStreamOptions changeStreamOptions;
private @Nullable Bson encryptedFields;
private @Nullable EncryptedFieldsOptions encryptedFieldsOptions;
private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nullable Boolean capped,
@Nullable Collation collation, ValidationOptions validationOptions, @Nullable TimeSeriesOptions timeSeriesOptions,
@Nullable CollectionChangeStreamOptions changeStreamOptions, @Nullable Bson encryptedFields) {
@Nullable CollectionChangeStreamOptions changeStreamOptions,
@Nullable EncryptedFieldsOptions encryptedFieldsOptions) {
this.maxDocuments = maxDocuments;
this.size = size;
@ -66,7 +84,7 @@ public class CollectionOptions { @@ -66,7 +84,7 @@ public class CollectionOptions {
this.validationOptions = validationOptions;
this.timeSeriesOptions = timeSeriesOptions;
this.changeStreamOptions = changeStreamOptions;
this.encryptedFields = encryptedFields;
this.encryptedFieldsOptions = encryptedFieldsOptions;
}
/**
@ -131,6 +149,46 @@ public class CollectionOptions { @@ -131,6 +149,46 @@ public class CollectionOptions {
return empty().changeStream(CollectionChangeStreamOptions.preAndPostImages(true));
}
/**
* Create new {@link CollectionOptions} with the given {@code encryptedFields}.
*
* @param encryptedFieldsOptions can be null
* @return new instance of {@link CollectionOptions}.
* @since 4.5.0
*/
@Contract("_ -> new")
@CheckReturnValue
public static CollectionOptions encryptedCollection(@Nullable EncryptedFieldsOptions encryptedFieldsOptions) {
return new CollectionOptions(null, null, null, null, ValidationOptions.NONE, null, null, encryptedFieldsOptions);
}
/**
* Create new {@link CollectionOptions} reading encryption options from the given {@link MongoJsonSchema}.
*
* @param schema must not be {@literal null}.
* @return new instance of {@link CollectionOptions}.
* @since 4.5.0
*/
@Contract("_ -> new")
@CheckReturnValue
public static CollectionOptions encryptedCollection(MongoJsonSchema schema) {
return encryptedCollection(EncryptedFieldsOptions.fromSchema(schema));
}
/**
* Create new {@link CollectionOptions} building encryption options in a fluent style.
*
* @param optionsFunction must not be {@literal null}.
* @return new instance of {@link CollectionOptions}.
* @since 4.5.0
*/
@Contract("_ -> new")
@CheckReturnValue
public static CollectionOptions encryptedCollection(
Function<EncryptedFieldsOptions, EncryptedFieldsOptions> optionsFunction) {
return encryptedCollection(optionsFunction.apply(new EncryptedFieldsOptions()));
}
/**
* Create new {@link CollectionOptions} with already given settings and capped set to {@literal true}. <br />
* <strong>NOTE:</strong> Using capped collections requires defining {@link #size(long)}.
@ -140,7 +198,7 @@ public class CollectionOptions { @@ -140,7 +198,7 @@ public class CollectionOptions {
*/
public CollectionOptions capped() {
return new CollectionOptions(size, maxDocuments, true, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
changeStreamOptions, encryptedFieldsOptions);
}
/**
@ -152,7 +210,7 @@ public class CollectionOptions { @@ -152,7 +210,7 @@ public class CollectionOptions {
*/
public CollectionOptions maxDocuments(long maxDocuments) {
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
changeStreamOptions, encryptedFieldsOptions);
}
/**
@ -164,7 +222,7 @@ public class CollectionOptions { @@ -164,7 +222,7 @@ public class CollectionOptions {
*/
public CollectionOptions size(long size) {
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
changeStreamOptions, encryptedFieldsOptions);
}
/**
@ -176,7 +234,7 @@ public class CollectionOptions { @@ -176,7 +234,7 @@ public class CollectionOptions {
*/
public CollectionOptions collation(@Nullable Collation collation) {
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
changeStreamOptions, encryptedFieldsOptions);
}
/**
@ -297,7 +355,7 @@ public class CollectionOptions { @@ -297,7 +355,7 @@ public class CollectionOptions {
Assert.notNull(validationOptions, "ValidationOptions must not be null");
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
changeStreamOptions, encryptedFieldsOptions);
}
/**
@ -311,7 +369,7 @@ public class CollectionOptions { @@ -311,7 +369,7 @@ public class CollectionOptions {
Assert.notNull(timeSeriesOptions, "TimeSeriesOptions must not be null");
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
changeStreamOptions, encryptedFieldsOptions);
}
/**
@ -325,19 +383,22 @@ public class CollectionOptions { @@ -325,19 +383,22 @@ public class CollectionOptions {
Assert.notNull(changeStreamOptions, "ChangeStreamOptions must not be null");
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
changeStreamOptions, encryptedFieldsOptions);
}
/**
* Create new {@link CollectionOptions} with the given {@code encryptedFields}.
* Set the {@link EncryptedFieldsOptions} for collections using queryable encryption.
*
* @param encryptedFields can be null
* @param encryptedFieldsOptions must not be {@literal null}.
* @return new instance of {@link CollectionOptions}.
* @since 4.5.0
*/
public CollectionOptions encryptedFields(@Nullable Bson encryptedFields) {
@Contract("_ -> new")
@CheckReturnValue
public CollectionOptions encrypted(EncryptedFieldsOptions encryptedFieldsOptions) {
Assert.notNull(encryptedFieldsOptions, "EncryptedCollectionOptions must not be null");
return new CollectionOptions(size, maxDocuments, capped, collation, validationOptions, timeSeriesOptions,
changeStreamOptions, encryptedFields);
changeStreamOptions, encryptedFieldsOptions);
}
/**
@ -414,18 +475,18 @@ public class CollectionOptions { @@ -414,18 +475,18 @@ public class CollectionOptions {
* @return {@link Optional#empty()} if not specified.
* @since 4.5.0
*/
public Optional<Bson> getEncryptedFields() {
return Optional.ofNullable(encryptedFields);
public Optional<EncryptedFieldsOptions> getEncryptedFieldsOptions() {
return Optional.ofNullable(encryptedFieldsOptions);
}
@Override
public String toString() {
return "CollectionOptions{" + "maxDocuments=" + maxDocuments + ", size=" + size + ", capped=" + capped
+ ", collation=" + collation + ", validationOptions=" + validationOptions + ", timeSeriesOptions="
+ timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", encryptedFields=" + encryptedFields
+ ", disableValidation=" + disableValidation() + ", strictValidation=" + strictValidation() + ", moderateValidation="
+ moderateValidation() + ", warnOnValidationError=" + warnOnValidationError() + ", failOnValidationError="
+ failOnValidationError() + '}';
+ timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", encryptedCollectionOptions="
+ encryptedFieldsOptions + ", disableValidation=" + disableValidation() + ", strictValidation="
+ strictValidation() + ", moderateValidation=" + moderateValidation() + ", warnOnValidationError="
+ warnOnValidationError() + ", failOnValidationError=" + failOnValidationError() + '}';
}
@Override
@ -460,7 +521,7 @@ public class CollectionOptions { @@ -460,7 +521,7 @@ public class CollectionOptions {
if (!ObjectUtils.nullSafeEquals(changeStreamOptions, that.changeStreamOptions)) {
return false;
}
return ObjectUtils.nullSafeEquals(encryptedFields, that.encryptedFields);
return ObjectUtils.nullSafeEquals(encryptedFieldsOptions, that.encryptedFieldsOptions);
}
@Override
@ -472,7 +533,7 @@ public class CollectionOptions { @@ -472,7 +533,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);
result = 31 * result + ObjectUtils.nullSafeHashCode(encryptedFieldsOptions);
return result;
}
@ -606,6 +667,183 @@ public class CollectionOptions { @@ -606,6 +667,183 @@ public class CollectionOptions {
}
}
/**
* Encapsulation of Encryption options for collections.
*
* @author Christoph Strobl
* @since 4.5
*/
public static class EncryptedFieldsOptions {
private static final EncryptedFieldsOptions NONE = new EncryptedFieldsOptions();
private @Nullable MongoJsonSchema schema;
private List<QueryableJsonSchemaProperty> queryableProperties;
/**
* @return {@link EncryptedFieldsOptions#NONE}
*/
public static EncryptedFieldsOptions none() {
return NONE;
}
/**
* @return new instance of {@link EncryptedFieldsOptions}.
*/
public static EncryptedFieldsOptions fromSchema(MongoJsonSchema schema) {
return new EncryptedFieldsOptions(schema, List.of());
}
/**
* @return new instance of {@link EncryptedFieldsOptions}.
*/
public static EncryptedFieldsOptions fromProperties(List<QueryableJsonSchemaProperty> properties) {
return new EncryptedFieldsOptions(null, List.copyOf(properties));
}
EncryptedFieldsOptions() {
this(null, List.of());
}
private EncryptedFieldsOptions(@Nullable MongoJsonSchema schema,
List<QueryableJsonSchemaProperty> queryableProperties) {
this.schema = schema;
this.queryableProperties = queryableProperties;
}
/**
* Add a new {@link QueryableJsonSchemaProperty queryable property} for the given source property.
* <p>
* Please note that, a given {@link JsonSchemaProperty} may override options from a given {@link MongoJsonSchema} if
* set.
*
* @param property the queryable source - typically
* {@link org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty
* encrypted}.
* @param characteristics the query options to set.
* @return new instance of {@link EncryptedFieldsOptions}.
*/
@Contract("_, _ -> new")
@CheckReturnValue
public EncryptedFieldsOptions queryable(JsonSchemaProperty property, QueryCharacteristic... characteristics) {
List<QueryableJsonSchemaProperty> targetPropertyList = new ArrayList<>(queryableProperties.size() + 1);
targetPropertyList.addAll(queryableProperties);
targetPropertyList.add(JsonSchemaProperty.queryable(property, List.of(characteristics)));
return new EncryptedFieldsOptions(schema, targetPropertyList);
}
public Document toDocument() {
return new Document("fields", selectPaths());
}
@NonNull
private List<Document> selectPaths() {
Map<String, Document> fields = new LinkedHashMap<>();
for (Document field : fromSchema()) {
fields.put(field.get("path", String.class), field);
}
for (Document field : fromProperties()) {
fields.put(field.get("path", String.class), field);
}
return List.copyOf(fields.values());
}
private List<Document> fromProperties() {
if (queryableProperties.isEmpty()) {
return List.of();
}
List<Document> converted = new ArrayList<>(queryableProperties.size());
for (QueryableJsonSchemaProperty property : queryableProperties) {
Document field = new Document("path", property.getIdentifier());
if (!property.getTypes().isEmpty()) {
field.append("bsonType", property.getTypes().iterator().next().toBsonType().value());
}
if (property
.getTargetProperty() instanceof IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty encrypted) {
if (encrypted.getKeyId() != null) {
if (encrypted.getKeyId() instanceof String stringKey) {
field.append("keyId",
new BsonBinary(BsonBinarySubType.UUID_STANDARD, stringKey.getBytes(StandardCharsets.UTF_8)));
} else {
field.append("keyId", encrypted.getKeyId());
}
}
}
field.append("queries", property.getCharacteristics().getCharacteristics().stream()
.map(QueryCharacteristic::toDocument).collect(Collectors.toList()));
if (!field.containsKey("keyId")) {
field.append("keyId", BsonNull.VALUE);
}
converted.add(field);
}
return converted;
}
private List<Document> fromSchema() {
if (schema == null) {
return List.of();
}
Document root = schema.schemaDocument();
Map<String, Document> paths = new LinkedHashMap<>();
collectPaths(root, null, paths);
List<Document> fields = new ArrayList<>();
if (!paths.isEmpty()) {
for (Entry<String, Document> entry : paths.entrySet()) {
Document field = new Document("path", entry.getKey());
field.append("keyId", entry.getValue().getOrDefault("keyId", BsonNull.VALUE));
if (entry.getValue().containsKey("bsonType")) {
field.append("bsonType", entry.getValue().get("bsonType"));
}
field.put("queries", entry.getValue().get("queries"));
fields.add(field);
}
}
return fields;
}
}
private static void collectPaths(Document document, String currentPath, Map<String, Document> paths) {
if (document.containsKey("type") && document.get("type").equals("object")) {
Object o = document.get("properties");
if (o == null) {
return;
}
if (o instanceof Document properties) {
for (Entry<String, Object> entry : properties.entrySet()) {
if (entry.getValue() instanceof Document nested) {
String path = currentPath == null ? entry.getKey() : (currentPath + "." + entry.getKey());
if (nested.containsKey("encrypt")) {
Document target = new Document(nested.get("encrypt", Document.class));
if (nested.containsKey("queries")) {
List<?> queries = nested.get("queries", List.class);
if (!queries.isEmpty() && queries.iterator().next() instanceof Document qd) {
target.putAll(qd);
}
}
paths.put(path, target);
} else {
collectPaths(nested, path, paths);
}
}
}
}
}
}
/**
* Encapsulation of options applied to define collections change stream behaviour.
*

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

@ -39,6 +39,7 @@ import org.springframework.data.mapping.PersistentPropertyPath; @@ -39,6 +39,7 @@ import org.springframework.data.mapping.PersistentPropertyPath;
import org.springframework.data.mapping.PropertyPath;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mapping.model.ConvertingPropertyAccessor;
import org.springframework.data.mongodb.core.CollectionOptions.EncryptedFieldsOptions;
import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions;
import org.springframework.data.mongodb.core.convert.MongoConverter;
import org.springframework.data.mongodb.core.convert.MongoJsonSchemaMapper;
@ -379,7 +380,11 @@ class EntityOperations { @@ -379,7 +380,11 @@ class EntityOperations {
collectionOptions.getChangeStreamOptions().ifPresent(it -> result
.changeStreamPreAndPostImagesOptions(new ChangeStreamPreAndPostImagesOptions(it.getPreAndPostImages())));
collectionOptions.getEncryptedFields().ifPresent(result::encryptedFields);
collectionOptions.getEncryptedFieldsOptions().map(EncryptedFieldsOptions::toDocument).ifPresent(encryptedFields -> {
if (!encryptedFields.isEmpty()) {
result.encryptedFields(encryptedFields);
}
});
return result;
}

35
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java

@ -31,14 +31,19 @@ import org.springframework.data.mongodb.core.mapping.Encrypted; @@ -31,14 +31,19 @@ import org.springframework.data.mongodb.core.mapping.Encrypted;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.data.mongodb.core.mapping.Queryable;
import org.springframework.data.mongodb.core.mapping.RangeEncrypted;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.ArrayJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.ObjectJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.QueryableJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.JsonSchemaObject;
import org.springframework.data.mongodb.core.schema.JsonSchemaObject.Type;
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.MongoJsonSchemaBuilder;
import org.springframework.data.mongodb.core.schema.QueryCharacteristic;
import org.springframework.data.mongodb.core.schema.QueryCharacteristics;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject;
import org.springframework.data.util.TypeInformation;
import org.springframework.util.Assert;
@ -291,7 +296,35 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator { @@ -291,7 +296,35 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator {
if (!ObjectUtils.isEmpty(encrypted.keyId())) {
enc = enc.keys(property.getEncryptionKeyIds());
}
return enc;
Queryable queryable = property.findAnnotation(Queryable.class);
if (queryable == null || !StringUtils.hasText(queryable.queryType())) {
return enc;
}
QueryCharacteristic characteristic = new QueryCharacteristic() {
@Override
public String queryType() {
return queryable.queryType();
}
@Override
public Document toDocument() {
Document options = QueryCharacteristic.super.toDocument();
if (queryable.contentionFactor() >= 0) {
options.put("contention", queryable.contentionFactor());
}
if (!queryable.queryAttributes().isEmpty()) {
options.putAll(Document.parse(queryable.queryAttributes()));
}
return options;
}
};
return new QueryableJsonSchemaProperty(enc, QueryCharacteristics.of(List.of(characteristic)));
}
private JsonSchemaProperty createObjectSchemaPropertyForEntity(List<MongoPersistentProperty> path,

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

@ -16,12 +16,12 @@ @@ -16,12 +16,12 @@
package org.springframework.data.mongodb.core.convert;
import org.bson.conversions.Bson;
import org.springframework.data.convert.ValueConversionContext;
import org.springframework.data.mapping.model.PropertyValueProvider;
import org.springframework.data.mapping.model.SpELContext;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.data.util.TypeInformation;
import org.springframework.lang.CheckReturnValue;
import org.springframework.lang.Nullable;
/**
@ -38,7 +38,7 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi @@ -38,7 +38,7 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi
@Nullable private final MongoPersistentProperty persistentProperty;
@Nullable private final SpELContext spELContext;
@Nullable private final String fieldNameAndQueryOperator;
@Nullable private final OperatorContext operatorContext;
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) {
@ -53,19 +53,19 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi @@ -53,19 +53,19 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
@Nullable String fieldNameAndQueryOperator) {
this(accessor, persistentProperty, mongoConverter, null, fieldNameAndQueryOperator);
@Nullable OperatorContext operatorContext) {
this(accessor, persistentProperty, mongoConverter, null, operatorContext);
}
public MongoConversionContext(PropertyValueProvider<MongoPersistentProperty> accessor,
@Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter,
@Nullable SpELContext spELContext, @Nullable String fieldNameAndQueryOperator) {
@Nullable SpELContext spELContext, @Nullable OperatorContext operatorContext) {
this.accessor = accessor;
this.persistentProperty = persistentProperty;
this.mongoConverter = mongoConverter;
this.spELContext = spELContext;
this.fieldNameAndQueryOperator = fieldNameAndQueryOperator;
this.operatorContext = operatorContext;
}
@Override
@ -78,6 +78,17 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi @@ -78,6 +78,17 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi
return persistentProperty;
}
/**
*
* @param operatorContext
* @return new instance of {@link MongoConversionContext}.
* @since 4.5
*/
@CheckReturnValue
public MongoConversionContext forOperator(@Nullable OperatorContext operatorContext) {
return new MongoConversionContext(accessor, persistentProperty, mongoConverter, spELContext, operatorContext);
}
@Nullable
public Object getValue(String propertyPath) {
return accessor.getPropertyValue(getProperty().getOwner().getRequiredPersistentProperty(propertyPath));
@ -101,7 +112,78 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi @@ -101,7 +112,78 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi
}
@Nullable
public String getFieldNameAndQueryOperator() {
return fieldNameAndQueryOperator;
public OperatorContext getOperatorContext() {
return operatorContext;
}
/**
* The {@link OperatorContext} provides access to the actual conversion intent like a write operation or a query
* operator such as {@literal $gte}.
*
* @since 4.5
*/
public interface OperatorContext {
/**
* The operator the conversion is used in.
* @return {@literal write} for simple write operations during save, or a query operator.
*/
String getOperator();
/**
* The context path the operator is used in.
* @return never {@literal null}.
*/
String getPath();
boolean isWriteOperation();
}
public static class WriteOperatorContext implements OperatorContext {
private final String path;
public WriteOperatorContext(String path) {
this.path = path;
}
@Override
public String getOperator() {
return "write";
}
@Override
public String getPath() {
return path;
}
@Override
public boolean isWriteOperation() {
return true;
}
}
public static class QueryOperatorContext implements OperatorContext {
private final String operator;
private final String path;
public QueryOperatorContext(@Nullable String operator, String path) {
this.operator = operator != null ? operator : "$eq";
this.path = path;
}
public String getOperator() {
return operator;
}
public String getPath() {
return path;
}
@Override
public boolean isWriteOperation() {
return false;
}
}
}

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

@ -37,7 +37,6 @@ import org.bson.BsonValue; @@ -37,7 +37,6 @@ import org.bson.BsonValue;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.bson.types.ObjectId;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.annotation.Reference;
@ -58,6 +57,8 @@ import org.springframework.data.mongodb.MongoExpression; @@ -58,6 +57,8 @@ import org.springframework.data.mongodb.MongoExpression;
import org.springframework.data.mongodb.core.aggregation.AggregationExpression;
import org.springframework.data.mongodb.core.aggregation.RelaxedTypeBasedAggregationOperationContext;
import org.springframework.data.mongodb.core.convert.MappingMongoConverter.NestedDocument;
import org.springframework.data.mongodb.core.convert.MongoConversionContext.OperatorContext;
import org.springframework.data.mongodb.core.convert.MongoConversionContext.QueryOperatorContext;
import org.springframework.data.mongodb.core.mapping.FieldName;
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
@ -672,18 +673,21 @@ public class QueryMapper { @@ -672,18 +673,21 @@ public class QueryMapper {
MongoPersistentProperty property = documentField.getProperty();
String fieldNameAndQueryOperator = property != null && !property.getFieldName().equals(documentField.name)
? property.getFieldName() + "." + documentField.name
: documentField.name;
MongoConversionContext conversionContext = new MongoConversionContext(NoPropertyPropertyValueProvider.INSTANCE,
property, converter, fieldNameAndQueryOperator);
OperatorContext criteriaContext = new QueryOperatorContext(
isKeyword(documentField.name) ? documentField.name : "$eq", property.getFieldName());
MongoConversionContext conversionContext;
if (valueConverter instanceof MongoConversionContext mcc) {
conversionContext = mcc.forOperator(criteriaContext);
} else {
conversionContext = new MongoConversionContext(NoPropertyPropertyValueProvider.INSTANCE, property, converter,
criteriaContext);
}
return convertValueWithConversionContext(documentField, sourceValue, value, valueConverter, conversionContext);
}
@Nullable
private Object convertValueWithConversionContext(Field documentField, Object sourceValue, Object value,
protected Object convertValueWithConversionContext(Field documentField, Object sourceValue, Object value,
PropertyValueConverter<Object, Object, ValueConversionContext<MongoPersistentProperty>> valueConverter,
MongoConversionContext conversionContext) {
@ -707,10 +711,7 @@ public class QueryMapper { @@ -707,10 +711,7 @@ public class QueryMapper {
return BsonUtils.mapValues(document, (key, val) -> {
if (isKeyword(key)) {
MongoConversionContext fieldConversionContext = new MongoConversionContext(
NoPropertyPropertyValueProvider.INSTANCE, property, converter,
conversionContext.getFieldNameAndQueryOperator() + "." + key);
return convertValueWithConversionContext(documentField, val, val, valueConverter, fieldConversionContext);
return convertValueWithConversionContext(documentField, val, val, valueConverter, conversionContext.forOperator(new QueryOperatorContext(key, conversionContext.getOperatorContext().getPath())));
}
return val;
});
@ -1624,7 +1625,7 @@ public class QueryMapper { @@ -1624,7 +1625,7 @@ public class QueryMapper {
return converter;
}
private enum NoPropertyPropertyValueProvider implements PropertyValueProvider<MongoPersistentProperty> {
enum NoPropertyPropertyValueProvider implements PropertyValueProvider<MongoPersistentProperty> {
INSTANCE;

10
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/UpdateMapper.java

@ -24,10 +24,13 @@ import java.util.Map.Entry; @@ -24,10 +24,13 @@ import java.util.Map.Entry;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.PropertyValueConverter;
import org.springframework.data.convert.ValueConversionContext;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.mapping.Association;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mongodb.core.convert.MongoConversionContext.WriteOperatorContext;
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.data.mongodb.core.query.Query;
@ -160,6 +163,13 @@ public class UpdateMapper extends QueryMapper { @@ -160,6 +163,13 @@ public class UpdateMapper extends QueryMapper {
return super.getMappedObjectForField(field, rawValue);
}
protected Object convertValueWithConversionContext(Field documentField, Object sourceValue, Object value,
PropertyValueConverter<Object, Object, ValueConversionContext<MongoPersistentProperty>> valueConverter,
MongoConversionContext conversionContext) {
return super.convertValueWithConversionContext(documentField, sourceValue, value, valueConverter, conversionContext.forOperator(new WriteOperatorContext(documentField.name)));
}
private Entry<String, Object> getMappedUpdateModifier(Field field, Object rawValue) {
Object value;

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

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
package org.springframework.data.mongodb.core.convert.encryption;
import org.springframework.data.mongodb.core.convert.MongoConversionContext;
import org.springframework.data.mongodb.core.convert.MongoConversionContext.OperatorContext;
import org.springframework.data.mongodb.core.encryption.EncryptionContext;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.data.util.TypeInformation;
@ -70,7 +71,7 @@ class ExplicitEncryptionContext implements EncryptionContext { @@ -70,7 +71,7 @@ class ExplicitEncryptionContext implements EncryptionContext {
@Override
@Nullable
public String getFieldNameAndQueryOperator() {
return conversionContext.getFieldNameAndQueryOperator();
public OperatorContext getOperatorContext() {
return conversionContext.getOperatorContext();
}
}

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

@ -15,10 +15,9 @@ @@ -15,10 +15,9 @@
*/
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 static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static org.springframework.data.mongodb.core.encryption.EncryptionOptions.QueryableEncryptionOptions;
import java.util.Collection;
import java.util.LinkedHashMap;
@ -35,17 +34,19 @@ import org.bson.Document; @@ -35,17 +34,19 @@ import org.bson.Document;
import org.bson.types.Binary;
import org.springframework.core.CollectionFactory;
import org.springframework.data.mongodb.core.convert.MongoConversionContext;
import org.springframework.data.mongodb.core.convert.MongoConversionContext.OperatorContext;
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.core.mapping.Queryable;
import org.springframework.data.mongodb.util.BsonUtils;
import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* Default implementation of {@link EncryptingConverter}. Properties used with this converter must be annotated with
@ -169,45 +170,53 @@ public class MongoEncryptionConverter implements EncryptingConverter<Object, Obj @@ -169,45 +170,53 @@ public class MongoEncryptionConverter implements EncryptingConverter<Object, Obj
if (annotation == null) {
throw new IllegalStateException(String.format("Property %s.%s is not annotated with @Encrypted",
getProperty(context).getOwner().getName(), getProperty(context).getName()));
persistentProperty.getOwner().getName(), persistentProperty.getName()));
}
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());
}
OperatorContext operatorContext = context.getOperatorContext();
boolean isPartOfARangeQuery = algorithm.equalsIgnoreCase(RANGE) && fieldNameAndQueryOperator != null;
if (isPartOfARangeQuery) {
encryptExpression = true;
queryableEncryptionOptions = queryableEncryptionOptions.queryType("range");
}
encryptionOptions = new EncryptionOptions(algorithm, key, queryableEncryptionOptions);
}
EncryptionOptions encryptionOptions = new EncryptionOptions(algorithm, key,
getEQOptions(persistentProperty, operatorContext));
if (encryptExpression) {
return encryptExpression(fieldNameAndQueryOperator, value, encryptionOptions);
if (operatorContext != null && !operatorContext.isWriteOperation() && encryptionOptions.queryableEncryptionOptions() != null
&& !encryptionOptions.queryableEncryptionOptions().getQueryType().equals("equality")) {
return encryptExpression(operatorContext, value, encryptionOptions);
} else {
return encryptValue(value, context, persistentProperty, encryptionOptions);
}
}
private static @Nullable QueryableEncryptionOptions getEQOptions(MongoPersistentProperty persistentProperty,
OperatorContext operatorContext) {
Queryable queryableAnnotation = persistentProperty.findAnnotation(Queryable.class);
if (queryableAnnotation == null || !StringUtils.hasText(queryableAnnotation.queryType())) {
return null;
}
QueryableEncryptionOptions queryableEncryptionOptions = QueryableEncryptionOptions.none();
String queryAttributes = queryableAnnotation.queryAttributes();
if (!queryAttributes.isEmpty()) {
queryableEncryptionOptions = queryableEncryptionOptions.attributes(Document.parse(queryAttributes));
}
if (queryableAnnotation.contentionFactor() >= 0) {
queryableEncryptionOptions = queryableEncryptionOptions.contentionFactor(queryableAnnotation.contentionFactor());
}
boolean isPartOfARangeQuery = operatorContext != null && !operatorContext.isWriteOperation();
if (isPartOfARangeQuery) {
queryableEncryptionOptions = queryableEncryptionOptions.queryType(queryableAnnotation.queryType());
}
return queryableEncryptionOptions;
}
private BsonBinary encryptValue(Object value, EncryptionContext context, MongoPersistentProperty persistentProperty,
EncryptionOptions encryptionOptions) {
if (!persistentProperty.isEntity()) {
if (persistentProperty.isCollectionLike()) {
@ -221,6 +230,7 @@ public class MongoEncryptionConverter implements EncryptingConverter<Object, Obj @@ -221,6 +230,7 @@ public class MongoEncryptionConverter implements EncryptingConverter<Object, Obj
}
return encryption.encrypt(BsonUtils.simpleToBsonValue(value), encryptionOptions);
}
if (persistentProperty.isCollectionLike()) {
return encryption.encrypt(collectionLikeToBsonValue(value, persistentProperty, context), encryptionOptions);
}
@ -234,27 +244,22 @@ public class MongoEncryptionConverter implements EncryptingConverter<Object, Obj @@ -234,27 +244,22 @@ public class MongoEncryptionConverter implements EncryptingConverter<Object, Obj
/**
* 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.
* <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,
private BsonValue encryptExpression(OperatorContext operatorContext, Object value,
EncryptionOptions encryptionOptions) {
BsonValue doc = BsonUtils.simpleToBsonValue(value);
String fieldName = fieldNameAndQueryOperator;
String queryOperator = EQUALITY_OPERATOR;
BsonValue doc = BsonUtils.simpleToBsonValue(value);
int pos = fieldNameAndQueryOperator.lastIndexOf(".$");
if (pos > -1) {
fieldName = fieldNameAndQueryOperator.substring(0, pos);
queryOperator = fieldNameAndQueryOperator.substring(pos + 1);
}
String fieldName = operatorContext.getPath();
String queryOperator = operatorContext.getOperator();
if (!RANGE_OPERATORS.contains(queryOperator)) {
throw new AssertionError(String.format("Not a valid range query. Querying a range encrypted field but the "

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

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
package org.springframework.data.mongodb.core.encryption;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mongodb.core.convert.MongoConversionContext.OperatorContext;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.data.util.TypeInformation;
import org.springframework.expression.EvaluationContext;
@ -135,7 +136,7 @@ public interface EncryptionContext { @@ -135,7 +136,7 @@ public interface EncryptionContext {
* @return can be {@literal null}.
*/
@Nullable
default String getFieldNameAndQueryOperator() {
default OperatorContext getOperatorContext() {
return null;
}
}

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

@ -15,21 +15,17 @@ @@ -15,21 +15,17 @@
*/
package org.springframework.data.mongodb.core.encryption;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.mongodb.client.model.vault.RangeOptions;
import org.bson.Document;
import org.springframework.data.mongodb.core.FindAndReplaceOptions;
import org.springframework.data.mongodb.util.BsonUtils;
import org.springframework.data.util.Optionals;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* Options, like the {@link #algorithm()}, to apply when encrypting values.
*
* Options used to provide additional information when {@link Encryption encrypting} values. like the
* {@link #algorithm()} to be used.
*
* @author Christoph Strobl
* @author Ross Lawley
* @since 4.1
@ -38,13 +34,15 @@ public class EncryptionOptions { @@ -38,13 +34,15 @@ public class EncryptionOptions {
private final String algorithm;
private final EncryptionKey key;
private final QueryableEncryptionOptions queryableEncryptionOptions;
private final @Nullable QueryableEncryptionOptions queryableEncryptionOptions;
public EncryptionOptions(String algorithm, EncryptionKey key) {
this(algorithm, key, QueryableEncryptionOptions.NONE);
this(algorithm, key, null);
}
public EncryptionOptions(String algorithm, EncryptionKey key, QueryableEncryptionOptions queryableEncryptionOptions) {
public EncryptionOptions(String algorithm, EncryptionKey key,
@Nullable 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");
@ -62,7 +60,11 @@ public class EncryptionOptions { @@ -62,7 +60,11 @@ public class EncryptionOptions {
return algorithm;
}
public QueryableEncryptionOptions queryableEncryptionOptions() {
/**
* @return {@literal null} if not set.
* @since 4.5
*/
public @Nullable QueryableEncryptionOptions queryableEncryptionOptions() {
return queryableEncryptionOptions;
}
@ -107,20 +109,23 @@ public class EncryptionOptions { @@ -107,20 +109,23 @@ public class EncryptionOptions {
* Options, like the {@link #getQueryType()}, to apply when encrypting queryable values.
*
* @author Ross Lawley
* @author Christoph Strobl
* @since 4.5
*/
public static class QueryableEncryptionOptions {
private static final QueryableEncryptionOptions NONE = new QueryableEncryptionOptions(null, null, null);
private static final QueryableEncryptionOptions NONE = new QueryableEncryptionOptions(null, null, Map.of());
private final @Nullable String queryType;
private final @Nullable Long contentionFactor;
private final @Nullable Document rangeOptions;
private final Map<String, Object> attributes;
private QueryableEncryptionOptions(@Nullable String queryType, @Nullable Long contentionFactor,
@Nullable Document rangeOptions) {
Map<String, Object> attributes) {
this.queryType = queryType;
this.contentionFactor = contentionFactor;
this.rangeOptions = rangeOptions;
this.attributes = attributes;
}
/**
@ -139,7 +144,7 @@ public class EncryptionOptions { @@ -139,7 +144,7 @@ public class EncryptionOptions {
* @return new instance of {@link QueryableEncryptionOptions}.
*/
public QueryableEncryptionOptions queryType(@Nullable String queryType) {
return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions);
return new QueryableEncryptionOptions(queryType, contentionFactor, attributes);
}
/**
@ -149,89 +154,57 @@ public class EncryptionOptions { @@ -149,89 +154,57 @@ public class EncryptionOptions {
* @return new instance of {@link QueryableEncryptionOptions}.
*/
public QueryableEncryptionOptions contentionFactor(@Nullable Long contentionFactor) {
return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions);
return new QueryableEncryptionOptions(queryType, contentionFactor, attributes);
}
/**
* Define the {@code rangeOptions} to be used for queryable document encryption.
*
* @param rangeOptions can be {@literal null}.
* @param attributes can be {@literal null}.
* @return new instance of {@link QueryableEncryptionOptions}.
*/
public QueryableEncryptionOptions rangeOptions(@Nullable Document rangeOptions) {
return new QueryableEncryptionOptions(queryType, contentionFactor, rangeOptions);
public QueryableEncryptionOptions attributes(Map<String, Object> attributes) {
return new QueryableEncryptionOptions(queryType, contentionFactor, attributes);
}
/**
* Get the {@code queryType} to apply.
*
* @return {@link Optional#empty()} if not set.
* @return {@literal null} if not set.
*/
public Optional<String> getQueryType() {
return Optional.ofNullable(queryType);
public @Nullable String getQueryType() {
return queryType;
}
/**
* Get the {@code contentionFactor} to apply.
*
* @return {@link Optional#empty()} if not set.
* @return {@literal null} if not set.
*/
public Optional<Long> getContentionFactor() {
return Optional.ofNullable(contentionFactor);
public @Nullable Long getContentionFactor() {
return contentionFactor;
}
/**
* Get the {@code rangeOptions} to apply.
*
* @return {@link Optional#empty()} if not set.
* @return never {@literal null}.
*/
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);
public Map<String, Object> getAttributes() {
return Map.copyOf(attributes);
}
/**
* @return {@literal true} if no arguments set.
*/
boolean isEmpty() {
return !Optionals.isAnyPresent(getQueryType(), getContentionFactor(), getRangeOptions());
return getQueryType() == null && getContentionFactor() == null && getAttributes().isEmpty();
}
@Override
public String toString() {
return "QueryableEncryptionOptions{" + "queryType='" + queryType + '\'' + ", contentionFactor=" + contentionFactor
+ ", rangeOptions=" + rangeOptions + '}';
+ ", attributes=" + attributes + '}';
}
@Override
@ -251,12 +224,12 @@ public class EncryptionOptions { @@ -251,12 +224,12 @@ public class EncryptionOptions {
if (!ObjectUtils.nullSafeEquals(contentionFactor, that.contentionFactor)) {
return false;
}
return ObjectUtils.nullSafeEquals(rangeOptions, that.rangeOptions);
return ObjectUtils.nullSafeEquals(attributes, that.attributes);
}
@Override
public int hashCode() {
return Objects.hash(queryType, contentionFactor, rangeOptions);
return Objects.hash(queryType, contentionFactor, attributes);
}
}
}

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

@ -15,15 +15,21 @@ @@ -15,15 +15,21 @@
*/
package org.springframework.data.mongodb.core.encryption;
import static org.springframework.data.mongodb.util.MongoCompatibilityAdapter.rangeOptionsAdapter;
import java.util.Map;
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.data.mongodb.core.encryption.EncryptionOptions.QueryableEncryptionOptions;
import org.springframework.data.mongodb.util.BsonUtils;
import org.springframework.util.Assert;
import com.mongodb.client.model.vault.EncryptOptions;
import com.mongodb.client.model.vault.RangeOptions;
import com.mongodb.client.vault.ClientEncryption;
/**
@ -74,6 +80,7 @@ public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary> @@ -74,6 +80,7 @@ public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary>
}
private EncryptOptions createEncryptOptions(EncryptionOptions options) {
EncryptOptions encryptOptions = new EncryptOptions(options.algorithm());
if (Type.ALT.equals(options.key().type())) {
@ -82,10 +89,58 @@ public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary> @@ -82,10 +89,58 @@ public class MongoClientEncryption implements Encryption<BsonValue, BsonBinary>
encryptOptions = encryptOptions.keyId((BsonBinary) options.key().value());
}
options.queryableEncryptionOptions().getQueryType().map(encryptOptions::queryType);
options.queryableEncryptionOptions().getContentionFactor().map(encryptOptions::contentionFactor);
options.queryableEncryptionOptions().getRangeOptions().map(encryptOptions::rangeOptions);
if (options.queryableEncryptionOptions() == null) {
return encryptOptions;
}
QueryableEncryptionOptions qeOptions = options.queryableEncryptionOptions();
if (qeOptions.getQueryType() != null) {
encryptOptions.queryType(qeOptions.getQueryType());
}
if (qeOptions.getContentionFactor() != null) {
encryptOptions.contentionFactor(qeOptions.getContentionFactor());
}
if (!qeOptions.getAttributes().isEmpty()) {
encryptOptions.rangeOptions(rangeOptions(qeOptions.getAttributes()));
}
return encryptOptions;
}
protected RangeOptions rangeOptions(Map<String, Object> attributes) {
RangeOptions encryptionRangeOptions = new RangeOptions();
if (attributes.isEmpty()) {
return encryptionRangeOptions;
}
if (attributes.containsKey("min")) {
encryptionRangeOptions.min(BsonUtils.simpleToBsonValue(attributes.get("min")));
}
if (attributes.containsKey("max")) {
encryptionRangeOptions.max(BsonUtils.simpleToBsonValue(attributes.get("max")));
}
if (attributes.containsKey("trimFactor")) {
Object trimFactor = attributes.get("trimFactor");
Assert.isInstanceOf(Integer.class, trimFactor, () -> String
.format("Expected to find a %s but it turned out to be %s.", Integer.class, trimFactor.getClass()));
rangeOptionsAdapter(encryptionRangeOptions).trimFactor((Integer) trimFactor);
}
if (attributes.containsKey("sparsity")) {
Object sparsity = attributes.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 (attributes.containsKey("precision")) {
Object precision = attributes.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 encryptionRangeOptions;
}
}

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

@ -86,24 +86,6 @@ public @interface ExplicitEncrypted { @@ -86,24 +86,6 @@ 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.
*

49
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Queryable.java

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
/*
* Copyright 2025. 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.mapping;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author Christoph Strobl
* @since 4.5
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE })
public @interface Queryable {
/**
* @return empty {@link String} if not set.
* @since 4.5
*/
String queryType() default "";
/**
* @return empty {@link String} if not set.
* @since 4.5
*/
String queryAttributes() default "";
/**
* Set the contention factor
*
* @return the contention factor
*/
long contentionFactor() default -1;
}

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

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
/*
* Copyright 2025 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.mapping;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
/**
* @author Christoph Strobl
* @author Ross Lawley
* @since 4.5
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Encrypted(algorithm = "Range")
@Queryable(queryType = "range")
public @interface RangeEncrypted {
/**
* Set the contention factor
*
* @return the contention factor
*/
@AliasFor(annotation = Queryable.class, value = "contentionFactor")
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}.
* <p>
* Please note that values are data type sensitive and may require proper identification via eg. {@code $numberLong}.
*
* @return the json representation of range options
*/
@AliasFor(annotation = Queryable.class, value = "queryAttributes")
String rangeOptions() default "";
}

97
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java

@ -23,8 +23,8 @@ import java.util.Set; @@ -23,8 +23,8 @@ import java.util.Set;
import java.util.UUID;
import org.bson.Document;
import org.springframework.data.domain.Range;
import org.springframework.data.mongodb.core.EncryptionAlgorithms;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ArrayJsonSchemaObject;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.BooleanJsonSchemaObject;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.DateJsonSchemaObject;
@ -1036,7 +1036,7 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen @@ -1036,7 +1036,7 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen
private final JsonSchemaProperty targetProperty;
private final @Nullable String algorithm;
private final @Nullable String keyId;
private final @Nullable Object keyId;
private final @Nullable List<?> keyIds;
/**
@ -1048,7 +1048,7 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen @@ -1048,7 +1048,7 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen
this(target, null, null, null);
}
private EncryptedJsonSchemaProperty(JsonSchemaProperty target, @Nullable String algorithm, @Nullable String keyId,
private EncryptedJsonSchemaProperty(JsonSchemaProperty target, @Nullable String algorithm, @Nullable Object keyId,
@Nullable List<?> keyIds) {
Assert.notNull(target, "Target must not be null");
@ -1068,13 +1068,25 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen @@ -1068,13 +1068,25 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen
return new EncryptedJsonSchemaProperty(target);
}
/**
* Create new instance of {@link EncryptedJsonSchemaProperty} with {@literal Range} encryption, wrapping the given
* {@link JsonSchemaProperty target}.
*
* @param target must not be {@literal null}.
* @return new instance of {@link EncryptedJsonSchemaProperty}.
* @since 4.5
*/
public static EncryptedJsonSchemaProperty rangeEncrypted(JsonSchemaProperty target) {
return new EncryptedJsonSchemaProperty(target).algorithm(EncryptionAlgorithms.RANGE);
}
/**
* Use {@literal AEAD_AES_256_CBC_HMAC_SHA_512-Random} algorithm.
*
* @return new instance of {@link EncryptedJsonSchemaProperty}.
*/
public EncryptedJsonSchemaProperty aead_aes_256_cbc_hmac_sha_512_random() {
return algorithm("AEAD_AES_256_CBC_HMAC_SHA_512-Random");
return algorithm(EncryptionAlgorithms.AEAD_AES_256_CBC_HMAC_SHA_512_Random);
}
/**
@ -1083,7 +1095,7 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen @@ -1083,7 +1095,7 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen
* @return new instance of {@link EncryptedJsonSchemaProperty}.
*/
public EncryptedJsonSchemaProperty aead_aes_256_cbc_hmac_sha_512_deterministic() {
return algorithm("AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic");
return algorithm(EncryptionAlgorithms.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic);
}
/**
@ -1103,6 +1115,15 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen @@ -1103,6 +1115,15 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen
return new EncryptedJsonSchemaProperty(targetProperty, algorithm, keyId, null);
}
/**
* @param keyId must not be {@literal null}.
* @return new instance of {@link EncryptedJsonSchemaProperty}.
* @since 4.5
*/
public EncryptedJsonSchemaProperty keyId(Object keyId) {
return new EncryptedJsonSchemaProperty(targetProperty, algorithm, keyId, null);
}
/**
* @param keyId must not be {@literal null}.
* @return new instance of {@link EncryptedJsonSchemaProperty}.
@ -1171,5 +1192,71 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen @@ -1171,5 +1192,71 @@ public class IdentifiableJsonSchemaProperty<T extends JsonSchemaObject> implemen
return null;
}
public Object getKeyId() {
if (keyId != null) {
return keyId;
}
if (keyIds != null && keyIds.size() == 1) {
return keyIds.iterator().next();
}
return null;
}
}
/**
* {@link JsonSchemaProperty} implementation typically wrapping {@link EncryptedJsonSchemaProperty encrypted
* properties} to mark them as queryable.
*
* @author Christoph Strobl
* @since 4.5
*/
public static class QueryableJsonSchemaProperty implements JsonSchemaProperty {
private final JsonSchemaProperty targetProperty;
private final QueryCharacteristics characteristics;
public QueryableJsonSchemaProperty(JsonSchemaProperty target, QueryCharacteristics characteristics) {
this.targetProperty = target;
this.characteristics = characteristics;
}
@Override
public Document toDocument() {
Document doc = targetProperty.toDocument();
Document propertySpecification = doc.get(targetProperty.getIdentifier(), Document.class);
if (propertySpecification.containsKey("encrypt")) {
Document encrypt = propertySpecification.get("encrypt", Document.class);
List<Document> queries = characteristics.getCharacteristics().stream().map(QueryCharacteristic::toDocument)
.toList();
encrypt.append("queries", queries);
}
return doc;
}
@Override
public String getIdentifier() {
return targetProperty.getIdentifier();
}
@Override
public Set<Type> getTypes() {
return targetProperty.getTypes();
}
boolean isEncrypted() {
return targetProperty instanceof EncryptedJsonSchemaProperty;
}
public JsonSchemaProperty getTargetProperty() {
return targetProperty;
}
public QueryCharacteristics getCharacteristics() {
return characteristics;
}
}
}

26
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java

@ -16,10 +16,22 @@ @@ -16,10 +16,22 @@
package org.springframework.data.mongodb.core.schema;
import java.util.Collection;
import java.util.List;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.ArrayJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.BooleanJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.DateJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.NullJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.NumericJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.ObjectJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.QueryableJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.RequiredJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.StringJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.TimestampJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.UntypedJsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.NumericJsonSchemaObject;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ObjectJsonSchemaObject;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.*;
import org.springframework.lang.Nullable;
/**
@ -69,6 +81,18 @@ public interface JsonSchemaProperty extends JsonSchemaObject { @@ -69,6 +81,18 @@ public interface JsonSchemaProperty extends JsonSchemaObject {
return EncryptedJsonSchemaProperty.encrypted(property);
}
/**
* Turns the given target property into a {@link QueryableJsonSchemaProperty queryable} one, eg. for {@literal range}
* encrypted properties.
*
* @param property the queryable property. Must not be {@literal null}.
* @param queries predefined query characteristics.
* @since 4.5
*/
static QueryableJsonSchemaProperty queryable(JsonSchemaProperty property, List<QueryCharacteristic> queries) {
return new QueryableJsonSchemaProperty(property, new QueryCharacteristics(queries));
}
/**
* Creates a new {@link StringJsonSchemaProperty} with given {@literal identifier} of {@code type : 'string'}.
*

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

@ -19,7 +19,9 @@ import java.util.ArrayList; @@ -19,7 +19,9 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import org.bson.Document;

34
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristic.java

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
/*
* Copyright 2025 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.schema;
import org.bson.Document;
/**
* @author Christoph Strobl
* @since 4.5
*/
public interface QueryCharacteristic {
/**
* @return the query type, eg. {@literal range}.
*/
String queryType();
default Document toDocument() {
return new Document("queryType", queryType());
}
}

147
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java

@ -0,0 +1,147 @@ @@ -0,0 +1,147 @@
/*
* Copyright 2025 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.schema;
import java.util.Arrays;
import java.util.List;
import org.bson.BsonNull;
import org.bson.Document;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Range.Bound;
import org.springframework.lang.Nullable;
/**
* @author Christoph Strobl
* @since 4.5
*/
public class QueryCharacteristics {
private static final QueryCharacteristics NONE = new QueryCharacteristics(List.of());
private final List<QueryCharacteristic> characteristics;
QueryCharacteristics(List<QueryCharacteristic> characteristics) {
this.characteristics = characteristics;
}
public static QueryCharacteristics none() {
return NONE;
}
public static QueryCharacteristics of(List<QueryCharacteristic> characteristics) {
return new QueryCharacteristics(List.copyOf(characteristics));
}
QueryCharacteristics(QueryCharacteristic... characteristics) {
this.characteristics = Arrays.asList(characteristics);
}
public List<QueryCharacteristic> getCharacteristics() {
return characteristics;
}
public static <T> RangeQuery<T> range() {
return new RangeQuery<>();
}
public static <T> EqualityQuery<T> equality() {
return new EqualityQuery<>(null);
}
public static class EqualityQuery<T> implements QueryCharacteristic {
private final @Nullable Long contention;
public EqualityQuery(@Nullable Long contention) {
this.contention = contention;
}
public EqualityQuery<T> contention(long contention) {
return new EqualityQuery<>(contention);
}
@Override
public String queryType() {
return "equality";
}
@Override
public Document toDocument() {
return QueryCharacteristic.super.toDocument().append("contention", contention);
}
}
public static class RangeQuery<T> implements QueryCharacteristic {
private final @Nullable Range<T> valueRange;
private final @Nullable Integer trimFactor;
private final @Nullable Long sparsity;
private final @Nullable Long contention;
private RangeQuery() {
this(Range.unbounded(), null, null, null);
}
public RangeQuery(Range<T> valueRange, Integer trimFactor, Long sparsity, Long contention) {
this.valueRange = valueRange;
this.trimFactor = trimFactor;
this.sparsity = sparsity;
this.contention = contention;
}
@Override
public String queryType() {
return "range";
}
public RangeQuery<T> min(T lower) {
Range<T> range = Range.of(Bound.inclusive(lower),
valueRange != null ? valueRange.getUpperBound() : Bound.unbounded());
return new RangeQuery<>(range, trimFactor, sparsity, contention);
}
public RangeQuery<T> max(T upper) {
Range<T> range = Range.of(valueRange != null ? valueRange.getLowerBound() : Bound.unbounded(),
Bound.inclusive(upper));
return new RangeQuery<>(range, trimFactor, sparsity, contention);
}
public RangeQuery<T> trimFactor(int trimFactor) {
return new RangeQuery<>(valueRange, trimFactor, sparsity, contention);
}
public RangeQuery<T> sparsity(long sparsity) {
return new RangeQuery<>(valueRange, trimFactor, sparsity, contention);
}
public RangeQuery<T> contention(long contention) {
return new RangeQuery<>(valueRange, trimFactor, sparsity, contention);
}
@Override
@SuppressWarnings("unchecked")
public Document toDocument() {
return QueryCharacteristic.super.toDocument().append("contention", contention).append("trimFactor", trimFactor)
.append("sparsity", sparsity).append("min", valueRange.getLowerBound().getValue().orElse((T) BsonNull.VALUE))
.append("max", valueRange.getUpperBound().getValue().orElse((T) BsonNull.VALUE));
}
}
}

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

@ -57,8 +57,18 @@ public class MongoCompatibilityAdapter { @@ -57,8 +57,18 @@ 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);
private static final @Nullable Method setTrimFactor;
static {
// method name changed in between
Method trimFactor = ReflectionUtils.findMethod(RangeOptions.class, "setTrimFactor", Integer.class);
if (trimFactor != null) {
setTrimFactor = trimFactor;
} else {
setTrimFactor = ReflectionUtils.findMethod(RangeOptions.class, "trimFactor", Integer.class);
}
}
/**
* Return a compatibility adapter for {@link MongoClientSettings.Builder}.
@ -128,6 +138,23 @@ public class MongoCompatibilityAdapter { @@ -128,6 +138,23 @@ public class MongoCompatibilityAdapter {
};
}
/**
* Return a compatibility adapter for {@link RangeOptions}.
*
* @param options
* @return
*/
public static RangeOptionsAdapter rangeOptionsAdapter(RangeOptions options) {
return trimFactor -> {
if (!MongoClientVersion.isVersion5orNewer() || setTrimFactor == null) {
throw new UnsupportedOperationException(NOT_SUPPORTED_ON_4.formatted("RangeOptions.trimFactor"));
}
ReflectionUtils.invokeMethod(setTrimFactor, options, trimFactor);
};
}
/**
* Return a compatibility adapter for {@code MapReducePublisher}.
*

105
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java

@ -15,12 +15,24 @@ @@ -15,12 +15,24 @@
*/
package org.springframework.data.mongodb.core;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.data.mongodb.core.CollectionOptions.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.data.mongodb.core.CollectionOptions.EncryptedFieldsOptions;
import static org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions;
import static org.springframework.data.mongodb.core.CollectionOptions.emitChangedRevisions;
import static org.springframework.data.mongodb.core.CollectionOptions.empty;
import static org.springframework.data.mongodb.core.CollectionOptions.encryptedCollection;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.int32;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.queryable;
import java.util.List;
import org.bson.BsonNull;
import org.bson.Document;
import org.junit.jupiter.api.Test;
import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
import org.springframework.data.mongodb.core.schema.QueryCharacteristics;
import org.springframework.data.mongodb.core.validation.Validator;
/**
@ -76,4 +88,93 @@ class CollectionOptionsUnitTests { @@ -76,4 +88,93 @@ class CollectionOptionsUnitTests {
.isNotEqualTo(empty().validator(Validator.document(new Document("three", "four"))))
.isNotEqualTo(empty().validator(Validator.document(new Document("one", "two"))).moderateValidation());
}
@Test // GH-4185
@SuppressWarnings("unchecked")
void queryableEncryptionOptionsFromSchemaRenderCorrectly() {
MongoJsonSchema schema = MongoJsonSchema.builder()
.property(JsonSchemaProperty.object("spring")
.properties(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int32("data")), List.of())))
.property(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("mongodb")), List.of())).build();
EncryptedFieldsOptions encryptionOptions = EncryptedFieldsOptions.fromSchema(schema);
assertThat(encryptionOptions.toDocument().get("fields", List.class)).hasSize(2)
.contains(new Document("path", "mongodb").append("bsonType", "long").append("queries", List.of())
.append("keyId", BsonNull.VALUE))
.contains(new Document("path", "spring.data").append("bsonType", "int").append("queries", List.of())
.append("keyId", BsonNull.VALUE));
}
@Test // GH-4185
@SuppressWarnings("unchecked")
void queryableEncryptionPropertiesOverrideByPath() {
CollectionOptions collectionOptions = encryptedCollection(options -> options //
.queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int32("spring")))
.queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("data")))
// override first with data type long
.queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("spring"))));
assertThat(collectionOptions.getEncryptedFieldsOptions()).map(EncryptedFieldsOptions::toDocument)
.hasValueSatisfying(it -> {
assertThat(it.get("fields", List.class)).hasSize(2).contains(new Document("path", "spring")
.append("bsonType", "long").append("queries", List.of()).append("keyId", BsonNull.VALUE));
});
}
@Test // GH-4185
@SuppressWarnings("unchecked")
void queryableEncryptionPropertiesOverridesPathFromSchema() {
EncryptedFieldsOptions encryptionOptions = EncryptedFieldsOptions.fromSchema(MongoJsonSchema.builder()
.property(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int32("spring")), List.of()))
.property(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("data")), List.of())).build());
// override spring from schema with data type long
CollectionOptions collectionOptions = CollectionOptions.encryptedCollection(
encryptionOptions.queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("spring"))));
assertThat(collectionOptions.getEncryptedFieldsOptions()).map(EncryptedFieldsOptions::toDocument)
.hasValueSatisfying(it -> {
assertThat(it.get("fields", List.class)).hasSize(2).contains(new Document("path", "spring")
.append("bsonType", "long").append("queries", List.of()).append("keyId", BsonNull.VALUE));
});
}
@Test // GH-4185
void encryptionOptionsAreImmutable() {
EncryptedFieldsOptions source = EncryptedFieldsOptions
.fromProperties(List.of(queryable(int32("spring.data"), List.of(QueryCharacteristics.range().min(1)))));
assertThat(source.queryable(queryable(int32("mongodb"), List.of(QueryCharacteristics.range().min(1)))))
.isNotSameAs(source).satisfies(it -> {
assertThat(it.toDocument().get("fields", List.class)).hasSize(2);
});
assertThat(source.toDocument().get("fields", List.class)).hasSize(1);
}
@Test // GH-4185
@SuppressWarnings("unchecked")
void queryableEncryptionPropertiesOverridesNestedPathFromSchema() {
EncryptedFieldsOptions encryptionOptions = EncryptedFieldsOptions.fromSchema(MongoJsonSchema.builder()
.property(JsonSchemaProperty.object("spring")
.properties(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int32("data")), List.of())))
.property(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("mongodb")), List.of())).build());
// override spring from schema with data type long
CollectionOptions collectionOptions = CollectionOptions.encryptedCollection(
encryptionOptions.queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("spring.data"))));
assertThat(collectionOptions.getEncryptedFieldsOptions()).map(EncryptedFieldsOptions::toDocument)
.hasValueSatisfying(it -> {
assertThat(it.get("fields", List.class)).hasSize(2).contains(new Document("path", "spring.data")
.append("bsonType", "long").append("queries", List.of()).append("keyId", BsonNull.VALUE));
});
}
}

20
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/DefaultIndexOperationsIntegrationTests.java

@ -15,9 +15,9 @@ @@ -15,9 +15,9 @@
*/
package org.springframework.data.mongodb.core;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.data.mongodb.core.index.PartialIndexFilter.*;
import static org.springframework.data.mongodb.core.query.Criteria.*;
import static org.springframework.data.mongodb.core.index.PartialIndexFilter.of;
import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.test.util.Assertions.assertThat;
import org.bson.BsonDocument;
import org.bson.Document;
@ -79,7 +79,7 @@ public class DefaultIndexOperationsIntegrationTests { @@ -79,7 +79,7 @@ public class DefaultIndexOperationsIntegrationTests {
IndexDefinition id = new Index().named("partial-with-criteria").on("k3y", Direction.ASC)
.partial(of(where("q-t-y").gte(10)));
indexOps.ensureIndex(id);
indexOps.createIndex(id);
IndexInfo info = findAndReturnIndexInfo(indexOps.getIndexInfo(), "partial-with-criteria");
assertThat(Document.parse(info.getPartialFilterExpression()))
@ -92,7 +92,7 @@ public class DefaultIndexOperationsIntegrationTests { @@ -92,7 +92,7 @@ public class DefaultIndexOperationsIntegrationTests {
IndexDefinition id = new Index().named("partial-with-mapped-criteria").on("k3y", Direction.ASC)
.partial(of(where("quantity").gte(10)));
template.indexOps(DefaultIndexOperationsIntegrationTestsSample.class).ensureIndex(id);
template.indexOps(DefaultIndexOperationsIntegrationTestsSample.class).createIndex(id);
IndexInfo info = findAndReturnIndexInfo(indexOps.getIndexInfo(), "partial-with-mapped-criteria");
assertThat(Document.parse(info.getPartialFilterExpression()))
@ -105,7 +105,7 @@ public class DefaultIndexOperationsIntegrationTests { @@ -105,7 +105,7 @@ public class DefaultIndexOperationsIntegrationTests {
IndexDefinition id = new Index().named("partial-with-dbo").on("k3y", Direction.ASC)
.partial(of(new org.bson.Document("qty", new org.bson.Document("$gte", 10))));
indexOps.ensureIndex(id);
indexOps.createIndex(id);
IndexInfo info = findAndReturnIndexInfo(indexOps.getIndexInfo(), "partial-with-dbo");
assertThat(Document.parse(info.getPartialFilterExpression()))
@ -120,7 +120,7 @@ public class DefaultIndexOperationsIntegrationTests { @@ -120,7 +120,7 @@ public class DefaultIndexOperationsIntegrationTests {
indexOps = new DefaultIndexOperations(template, COLLECTION_NAME, MappingToSameCollection.class);
indexOps.ensureIndex(id);
indexOps.createIndex(id);
IndexInfo info = findAndReturnIndexInfo(indexOps.getIndexInfo(), "partial-with-inheritance");
assertThat(Document.parse(info.getPartialFilterExpression()))
@ -150,7 +150,7 @@ public class DefaultIndexOperationsIntegrationTests { @@ -150,7 +150,7 @@ public class DefaultIndexOperationsIntegrationTests {
new DefaultIndexOperations(template, COLLECTION_NAME, MappingToSameCollection.class);
indexOps.ensureIndex(id);
indexOps.createIndex(id);
Document expected = new Document("locale", "de_AT") //
.append("caseLevel", false) //
@ -179,7 +179,7 @@ public class DefaultIndexOperationsIntegrationTests { @@ -179,7 +179,7 @@ public class DefaultIndexOperationsIntegrationTests {
IndexDefinition index = new Index().named("my-index").on("a", Direction.ASC);
indexOps = new DefaultIndexOperations(template, COLLECTION_NAME, MappingToSameCollection.class);
indexOps.ensureIndex(index);
indexOps.createIndex(index);
IndexInfo info = findAndReturnIndexInfo(indexOps.getIndexInfo(), "my-index");
assertThat(info.isHidden()).isFalse();
@ -191,7 +191,7 @@ public class DefaultIndexOperationsIntegrationTests { @@ -191,7 +191,7 @@ public class DefaultIndexOperationsIntegrationTests {
IndexDefinition index = new Index().named("my-hidden-index").on("a", Direction.ASC).hidden();
indexOps = new DefaultIndexOperations(template, COLLECTION_NAME, MappingToSameCollection.class);
indexOps.ensureIndex(index);
indexOps.createIndex(index);
IndexInfo info = findAndReturnIndexInfo(indexOps.getIndexInfo(), "my-hidden-index");
assertThat(info.isHidden()).isTrue();

74
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java

@ -15,7 +15,8 @@ @@ -15,7 +15,8 @@
*/
package org.springframework.data.mongodb.core;
import static org.springframework.data.mongodb.test.util.Assertions.*;
import static org.springframework.data.mongodb.test.util.Assertions.assertThat;
import static org.springframework.data.mongodb.test.util.Assertions.assertThatExceptionOfType;
import java.util.Collections;
import java.util.Date;
@ -38,6 +39,8 @@ import org.springframework.data.mongodb.core.mapping.Field; @@ -38,6 +39,8 @@ import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.mapping.MongoId;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.data.mongodb.core.mapping.Queryable;
import org.springframework.data.mongodb.core.mapping.RangeEncrypted;
import org.springframework.data.mongodb.core.schema.JsonSchemaObject.Type;
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
@ -282,6 +285,48 @@ class MappingMongoJsonSchemaCreatorUnitTests { @@ -282,6 +285,48 @@ class MappingMongoJsonSchemaCreatorUnitTests {
.containsEntry("properties.domainTypeValue", Document.parse("{'encrypt': {'bsonType': 'object' } }"));
}
@Test // GH-4185
void qeRangeEncryptedProperties() {
MongoJsonSchema schema = MongoJsonSchemaCreator.create() //
.filter(MongoJsonSchemaCreator.encryptedOnly()) // filter non encrypted fields
.createSchemaFor(QueryableEncryptedRoot.class);
String expectedForInt = """
{ 'encrypt' : {
'algorithm' : 'Range',
'bsonType' : 'int',
'queries' : [
{ 'queryType' : 'range', 'contention' : { '$numberLong' : '0' }, 'max' : 200, 'min' : 0, 'sparsity' : 1, 'trimFactor' : 1 }
]
}}""";
String expectedForRootLong = """
{ 'encrypt' : {
'algorithm' : 'Range',
'bsonType' : 'long',
'queries' : [
{ 'queryType' : 'range', contention : { '$numberLong' : '0' }, 'sparsity' : 0 }
]
}}""";
String expectedForNestedLong = """
{ 'encrypt' : {
'algorithm' : 'Range',
'bsonType' : 'long',
'queries' : [
{ 'queryType' : 'range', contention : { '$numberLong' : '1' }, 'max' : { '$numberLong' : '1' }, 'min' : { '$numberLong' : '-1' }, 'sparsity' : 1, 'trimFactor' : 1 }
]
}}""";
assertThat(schema.schemaDocument()) //
.doesNotContainKey("properties.unencrypted") //
.containsEntry("properties.encryptedInt", Document.parse(expectedForInt))
.containsEntry("properties.encryptedLong", Document.parse(expectedForRootLong))
.containsEntry("properties.nested.properties.encrypted_long", Document.parse(expectedForNestedLong));
}
// --> TYPES AND JSON
// --> ENUM
@ -311,7 +356,8 @@ class MappingMongoJsonSchemaCreatorUnitTests { @@ -311,7 +356,8 @@ class MappingMongoJsonSchemaCreatorUnitTests {
" 'binaryDataProperty' : { 'bsonType' : 'binData' }," + //
" 'collectionProperty' : { 'type' : 'array' }," + //
" 'simpleTypeCollectionProperty' : { 'type' : 'array', 'items' : { 'type' : 'string' } }," + //
" 'complexTypeCollectionProperty' : { 'type' : 'array', 'items' : { 'type' : 'object', 'properties' : { 'field' : { 'type' : 'string'} } } }" + //
" 'complexTypeCollectionProperty' : { 'type' : 'array', 'items' : { 'type' : 'object', 'properties' : { 'field' : { 'type' : 'string'} } } }"
+ //
" 'enumTypeCollectionProperty' : { 'type' : 'array', 'items' : " + JUST_SOME_ENUM + " }" + //
" 'mapProperty' : { 'type' : 'object' }," + //
" 'objectProperty' : { 'type' : 'object' }," + //
@ -692,4 +738,28 @@ class MappingMongoJsonSchemaCreatorUnitTests { @@ -692,4 +738,28 @@ class MappingMongoJsonSchemaCreatorUnitTests {
static class WithEncryptedEntityLikeProperty {
@Encrypted SomeDomainType domainTypeValue;
}
static class QueryableEncryptedRoot {
String unencrypted;
@RangeEncrypted(contentionFactor = 0L, rangeOptions = "{ 'min': 0, 'max': 200, 'trimFactor': 1, 'sparsity': 1}") //
Integer encryptedInt;
@Encrypted(algorithm = "Range")
@Queryable(contentionFactor = 0L, queryType = "range", queryAttributes = "{ 'sparsity': 0 }") //
Long encryptedLong;
NestedRangeEncrypted nested;
}
static class NestedRangeEncrypted {
@Field("encrypted_long")
@RangeEncrypted(contentionFactor = 1L,
rangeOptions = "{ 'min': { '$numberLong' : '-1' }, 'max': { '$numberLong' : '1' }, 'trimFactor': 1, 'sparsity': 1}") //
Long encryptedLong;
}
}

140
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java

@ -0,0 +1,140 @@ @@ -0,0 +1,140 @@
/*
* Copyright 2025 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 org.springframework.data.mongodb.core.schema.JsonSchemaProperty.encrypted;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.int32;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.int64;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.queryable;
import static org.springframework.data.mongodb.core.schema.QueryCharacteristics.range;
import static org.springframework.data.mongodb.test.util.Assertions.assertThat;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.bson.BsonBinary;
import org.bson.Document;
import org.bson.UuidRepresentation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration;
import org.springframework.data.mongodb.core.CollectionOptions;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
import org.springframework.data.mongodb.test.util.Client;
import org.springframework.data.mongodb.test.util.MongoClientExtension;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import com.mongodb.client.MongoClient;
/**
* @author Christoph Strobl
*/
@ExtendWith({ MongoClientExtension.class, SpringExtension.class })
@ContextConfiguration
public class MongoQueryableEncryptionCollectionCreationTests {
public static final String COLLECTION_NAME = "enc-collection";
static @Client MongoClient mongoClient;
@Configuration
static class Config extends AbstractMongoClientConfiguration {
@Override
public MongoClient mongoClient() {
return mongoClient;
}
@Override
protected String getDatabaseName() {
return "encryption-schema-tests";
}
}
@Autowired MongoTemplate template;
@BeforeEach
void beforeEach() {
template.dropCollection(COLLECTION_NAME);
}
@ParameterizedTest // GH-4185
@MethodSource("collectionOptions")
public void createsCollectionWithEncryptedFieldsCorrectly(CollectionOptions collectionOptions) {
template.createCollection(COLLECTION_NAME, collectionOptions);
Document encryptedFields = readEncryptedFieldsFromDatabase(COLLECTION_NAME);
assertThat(encryptedFields).containsKey("fields");
List<Document> fields = encryptedFields.get("fields", List.of());
assertThat(fields.get(0)).containsEntry("path", "encryptedInt") //
.containsEntry("bsonType", "int") //
.containsEntry("queries", List
.of(Document.parse("{'queryType': 'range', 'contention': { '$numberLong' : '1' }, 'min': 5, 'max': 100}")));
assertThat(fields.get(1)).containsEntry("path", "nested.encryptedLong") //
.containsEntry("bsonType", "long") //
.containsEntry("queries", List.of(Document.parse(
"{'queryType': 'range', 'contention': { '$numberLong' : '0' }, 'min': { '$numberLong' : '-1' }, 'max': { '$numberLong' : '1' }}")));
}
private static Stream<Arguments> collectionOptions() {
BsonBinary key1 = new BsonBinary(UUID.randomUUID(), UuidRepresentation.STANDARD);
BsonBinary key2 = new BsonBinary(UUID.randomUUID(), UuidRepresentation.STANDARD);
CollectionOptions manualOptions = CollectionOptions.encryptedCollection(options -> options //
.queryable(encrypted(int32("encryptedInt")).keys(key1), range().min(5).max(100).contention(1)) //
.queryable(encrypted(JsonSchemaProperty.int64("nested.encryptedLong")).keys(key2),
range().min(-1L).max(1L).contention(0)));
CollectionOptions schemaOptions = CollectionOptions.encryptedCollection(MongoJsonSchema.builder()
.property(
queryable(encrypted(int32("encryptedInt")).keyId(key1), List.of(range().min(5).max(100).contention(1))))
.property(queryable(encrypted(int64("nested.encryptedLong")).keyId(key2),
List.of(range().min(-1L).max(1L).contention(0))))
.build());
return Stream.of(Arguments.of(manualOptions), Arguments.of(schemaOptions));
}
Document readEncryptedFieldsFromDatabase(String collectionName) {
Document collectionInfo = template
.executeCommand(new Document("listCollections", 1).append("filter", new Document("name", collectionName)));
if (collectionInfo.containsKey("cursor")) {
collectionInfo = (Document) collectionInfo.get("cursor", Document.class).get("firstBatch", List.class).iterator()
.next();
}
if (!collectionInfo.containsKey("options")) {
return new Document();
}
return collectionInfo.get("options", Document.class).get("encryptedFields", Document.class);
}
}

362
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java

@ -15,45 +15,28 @@ @@ -15,45 +15,28 @@
*/
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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.data.mongodb.core.query.Criteria.where;
import java.security.SecureRandom;
import java.util.LinkedHashMap;
import java.util.List;
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.assertj.core.api.Assumptions;
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.Before;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.DisposableBean;
@ -61,20 +44,53 @@ import org.springframework.beans.factory.annotation.Autowired; @@ -61,20 +44,53 @@ 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.convert.ValueConverter;
import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration;
import org.springframework.data.mongodb.core.CollectionOptions;
import org.springframework.data.mongodb.core.CollectionOptions.EncryptedFieldsOptions;
import org.springframework.data.mongodb.core.MongoJsonSchemaCreator;
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.core.mapping.Encrypted;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.data.mongodb.core.mapping.Queryable;
import org.springframework.data.mongodb.core.mapping.RangeEncrypted;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
import org.springframework.data.mongodb.test.util.EnableIfMongoServerVersion;
import org.springframework.data.mongodb.test.util.EnableIfReplicaSetAvailable;
import org.springframework.data.mongodb.test.util.MongoClientExtension;
import org.springframework.data.mongodb.util.MongoClientVersion;
import org.springframework.data.util.Lazy;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.StringUtils;
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.model.vault.EncryptOptions;
import com.mongodb.client.model.vault.RangeOptions;
import com.mongodb.client.result.UpdateResult;
import com.mongodb.client.vault.ClientEncryption;
import com.mongodb.client.vault.ClientEncryptions;
/**
* @author Ross Lawley
* @author Christoph Strobl
*/
@ExtendWith({ MongoClientExtension.class, SpringExtension.class })
@EnableIfMongoServerVersion(isGreaterThanEqual = "8.0")
@ -83,41 +99,128 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -83,41 +99,128 @@ import org.springframework.test.context.junit.jupiter.SpringExtension;
class RangeEncryptionTests {
@Autowired MongoTemplate template;
@Autowired MongoClientEncryption clientEncryption;
@Autowired EncryptionKeyHolder keyHolder;
@BeforeEach
void clientVersionCheck() {
Assumptions.assumeThat(MongoClientVersion.isVersion5orNewer()).isTrue();
}
@AfterEach
void tearDown() {
template.getDb().getCollection("test").deleteMany(new BsonDocument());
}
@Test
void canGreaterThanEqualMatchRangeEncryptedField() {
@Test // GH-4185
void manuallyEncryptedValuesCanBeSavedAndRetrievedCorrectly() {
EncryptOptions encryptOptions = new EncryptOptions("Range").contentionFactor(1L)
.keyId(keyHolder.getEncryptionKey("encryptedInt"))
.rangeOptions(new RangeOptions().min(new BsonInt32(0)).max(new BsonInt32(200)).sparsity(1L));
EncryptOptions encryptExpressionOptions = new EncryptOptions("Range").contentionFactor(1L)
.rangeOptions(new RangeOptions().min(new BsonInt32(0)).max(new BsonInt32(200)))
.keyId(keyHolder.getEncryptionKey("encryptedInt")).queryType("range");
EncryptOptions equalityEncOptions = new EncryptOptions("Indexed").contentionFactor(0L)
.keyId(keyHolder.getEncryptionKey("age"));
;
EncryptOptions equalityEncOptionsString = new EncryptOptions("Indexed").contentionFactor(0L)
.keyId(keyHolder.getEncryptionKey("name"));
;
Document source = new Document("_id", "id-1");
source.put("name",
clientEncryption.getClientEncryption().encrypt(new BsonString("It's a Me, Mario!"), equalityEncOptionsString));
source.put("age", clientEncryption.getClientEncryption().encrypt(new BsonInt32(101), equalityEncOptions));
source.put("encryptedInt", clientEncryption.getClientEncryption().encrypt(new BsonInt32(101), encryptOptions));
source.put("_class", Person.class.getName());
template.execute(Person.class, col -> col.insertOne(source));
Document result = template.execute(Person.class, col -> {
BsonDocument filterSource = new BsonDocument("encryptedInt", new BsonDocument("$gte", new BsonInt32(100)));
BsonDocument filter = clientEncryption.getClientEncryption()
.encryptExpression(new Document("$and", List.of(filterSource)), encryptExpressionOptions);
return col.find(filter).first();
});
assertThat(result).containsEntry("encryptedInt", 101);
}
@Test // GH-4185
void canLesserThanEqualMatchRangeEncryptedField() {
Person source = createPerson();
template.insert(source);
Person loaded = template.query(Person.class).matching(where("encryptedInt").gte(source.encryptedInt)).firstValue();
Person loaded = template.query(Person.class).matching(where("encryptedInt").lte(source.encryptedInt)).firstValue();
assertThat(loaded).isEqualTo(source);
}
@Test
void canLesserThanEqualMatchRangeEncryptedField() {
@Test // GH-4185
void canQueryMixOfEqualityEncryptedAndUnencrypted() {
Person source = template.insert(createPerson());
Person loaded = template.query(Person.class)
.matching(where("name").is(source.name).and("unencryptedValue").is(source.unencryptedValue)).firstValue();
assertThat(loaded).isEqualTo(source);
}
@Test // GH-4185
void canQueryMixOfRangeEncryptedAndUnencrypted() {
Person source = template.insert(createPerson());
Person loaded = template.query(Person.class)
.matching(where("encryptedInt").lte(source.encryptedInt).and("unencryptedValue").is(source.unencryptedValue))
.firstValue();
assertThat(loaded).isEqualTo(source);
}
@Test // GH-4185
void canQueryEqualityEncryptedField() {
Person source = createPerson();
template.insert(source);
Person loaded = template.query(Person.class).matching(where("encryptedInt").lte(source.encryptedInt)).firstValue();
Person loaded = template.query(Person.class).matching(where("age").is(source.age)).firstValue();
assertThat(loaded).isEqualTo(source);
}
@Test
@Test // GH-4185
void canExcludeSafeContentFromResult() {
Person source = createPerson();
template.insert(source);
Query q = Query.query(where("encryptedLong").lte(1001L).gte(1001L));
q.fields().exclude("__safeContent__");
Person loaded = template.query(Person.class).matching(q).firstValue();
assertThat(loaded).isEqualTo(source);
}
@Test // GH-4185
void canRangeMatchRangeEncryptedField() {
Person source = createPerson();
template.insert(source);
Person loaded = template.query(Person.class).matching(where("encryptedLong").lte(1001L).gte(1001L)).firstValue();
Query q = Query.query(where("encryptedLong").lte(1001L).gte(1001L));
Person loaded = template.query(Person.class).matching(q).firstValue();
assertThat(loaded).isEqualTo(source);
}
@Test
void canUpdateRangeEncryptedField() {
@Test // GH-4185
void canReplaceEntityWithRangeEncryptedField() {
Person source = createPerson();
template.insert(source);
@ -129,8 +232,23 @@ class RangeEncryptionTests { @@ -129,8 +232,23 @@ class RangeEncryptionTests {
assertThat(loaded).isEqualTo(source);
}
@Test
@Test // GH-4185
void canUpdateRangeEncryptedField() {
Person source = createPerson();
template.insert(source);
UpdateResult updateResult = template.update(Person.class).matching(where("id").is(source.id))
.apply(Update.update("encryptedLong", 5000L)).first();
assertThat(updateResult.getModifiedCount()).isOne();
Person loaded = template.query(Person.class).matching(where("id").is(source.id)).firstValue();
assertThat(loaded.encryptedLong).isEqualTo(5000L);
}
@Test // GH-4185
void errorsWhenUsingNonRangeOperatorEqOnRangeEncryptedField() {
Person source = createPerson();
template.insert(source);
@ -139,11 +257,11 @@ class RangeEncryptionTests { @@ -139,11 +257,11 @@ class RangeEncryptionTests {
.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
@Test // GH-4185
void errorsWhenUsingNonRangeOperatorInOnRangeEncryptedField() {
Person source = createPerson();
template.insert(source);
@ -152,14 +270,19 @@ class RangeEncryptionTests { @@ -152,14 +270,19 @@ class RangeEncryptionTests {
.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.unencryptedValue = "y2k";
source.name = "it's a me mario!";
source.age = 42;
source.encryptedInt = 101;
source.encryptedLong = 1001L;
source.nested = new NestedWithQEFields();
source.nested.value = "Luigi time!";
return source;
}
@ -193,37 +316,63 @@ class RangeEncryptionTests { @@ -193,37 +316,63 @@ class RangeEncryptionTests {
}
@Bean
MongoEncryptionConverter encryptingConverter(MongoClientEncryption mongoClientEncryption) {
EncryptionKeyHolder keyHolder(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),
MongoJsonSchema personSchema = MongoJsonSchemaCreator.create(new MongoMappingContext()) // init schema creator
.filter(MongoJsonSchemaCreator.encryptedOnly()) //
.createSchemaFor(Person.class); //
Document encryptedFields = CollectionOptions.encryptedCollection(personSchema) //
.getEncryptedFieldsOptions() //
.map(EncryptedFieldsOptions::toDocument) //
.orElseThrow();
CreateCollectionOptions createCollectionOptions = new CreateCollectionOptions()
.encryptedFields(encryptedFields);
BsonDocument local = clientEncryption.createEncryptedCollection(database, "test", createCollectionOptions,
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")));
Map<String, BsonBinary> keyMap = new LinkedHashMap<>();
for (Object o : local.getArray("fields")) {
if (o instanceof BsonDocument db) {
String path = db.getString("path").getValue();
BsonBinary binary = db.getBinary("keyId");
for (String part : path.split("\\.")) {
keyMap.put(part, binary);
}
}
}
return keyMap;
}
});
return new MongoEncryptionConverter(mongoClientEncryption, EncryptionKeyResolver
.annotated((ctx) -> EncryptionKey.keyId(lazyDataKeyMap.get().get(ctx.getProperty().getFieldName()))));
return new EncryptionKeyHolder(lazyDataKeyMap);
}
@Bean
MongoEncryptionConverter encryptingConverter(MongoClientEncryption mongoClientEncryption,
EncryptionKeyHolder keyHolder) {
return new MongoEncryptionConverter(mongoClientEncryption, EncryptionKeyResolver.annotated((ctx) -> {
String path = ctx.getProperty().getFieldName();
if (ctx.getProperty().getMongoField().getName().isPath()) {
path = StringUtils.arrayToDelimitedString(ctx.getProperty().getMongoField().getName().parts(), ".");
}
if (ctx.getOperatorContext() != null) {
path = ctx.getOperatorContext().getPath();
}
return EncryptionKey.keyId(keyHolder.getEncryptionKey(path));
}));
}
@Bean
@ -291,16 +440,47 @@ class RangeEncryptionTests { @@ -291,16 +440,47 @@ class RangeEncryptionTests {
}
}
static class EncryptionKeyHolder {
Supplier<Map<String, BsonBinary>> lazyDataKeyMap;
public EncryptionKeyHolder(Supplier<Map<String, BsonBinary>> lazyDataKeyMap) {
this.lazyDataKeyMap = Lazy.of(lazyDataKeyMap);
}
BsonBinary getEncryptionKey(String path) {
return lazyDataKeyMap.get().get(path);
}
}
@org.springframework.data.mongodb.core.mapping.Document("test")
static class Person {
String id;
String unencryptedValue;
@ValueConverter(MongoEncryptionConverter.class)
@Encrypted(algorithm = "Indexed") //
@Queryable(queryType = "equality", contentionFactor = 0) //
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;
@ValueConverter(MongoEncryptionConverter.class)
@Encrypted(algorithm = "Indexed") //
@Queryable(queryType = "equality", contentionFactor = 0) //
Integer age;
@ValueConverter(MongoEncryptionConverter.class)
@RangeEncrypted(contentionFactor = 0L,
rangeOptions = "{\"min\": 0, \"max\": 200, \"trimFactor\": 1, \"sparsity\": 1}") //
Integer encryptedInt;
@ValueConverter(MongoEncryptionConverter.class)
@RangeEncrypted(contentionFactor = 0L,
rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") //
Long encryptedLong;
NestedWithQEFields nested;
public String getId() {
return this.id;
@ -336,29 +516,57 @@ class RangeEncryptionTests { @@ -336,29 +516,57 @@ class RangeEncryptionTests {
@Override
public boolean equals(Object o) {
if (this == o)
if (o == this) {
return true;
if (o == null || getClass() != o.getClass())
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Person person = (Person) o;
return Objects.equals(id, person.id) && Objects.equals(name, person.name)
return Objects.equals(id, person.id) && Objects.equals(unencryptedValue, person.unencryptedValue)
&& Objects.equals(name, person.name) && Objects.equals(age, person.age)
&& 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;
return Objects.hash(id, unencryptedValue, name, age, encryptedInt, encryptedLong);
}
@Override
public String toString() {
return "Person{" + "id='" + id + '\'' + ", unencryptedValue='" + unencryptedValue + '\'' + ", name='" + name
+ '\'' + ", age=" + age + ", encryptedInt=" + encryptedInt + ", encryptedLong=" + encryptedLong + '}';
}
}
static class NestedWithQEFields {
@ValueConverter(MongoEncryptionConverter.class)
@Encrypted(algorithm = "Indexed") //
@Queryable(queryType = "equality", contentionFactor = 0) //
String value;
@Override
public String toString() {
return "Person{" + "id='" + id + '\'' + ", name='" + name + '\'' + ", encryptedInt=" + encryptedInt
+ ", encryptedLong=" + encryptedLong + '}';
return "NestedWithQEFields{" + "value='" + value + '\'' + '}';
}
@Override
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
NestedWithQEFields that = (NestedWithQEFields) o;
return Objects.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}

27
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java

@ -15,11 +15,17 @@ @@ -15,11 +15,17 @@
*/
package org.springframework.data.mongodb.core.schema;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.*;
import static org.springframework.data.mongodb.test.util.Assertions.*;
import static org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty.rangeEncrypted;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.encrypted;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.number;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.queryable;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.string;
import static org.springframework.data.mongodb.test.util.Assertions.assertThat;
import static org.springframework.data.mongodb.test.util.Assertions.assertThatIllegalArgumentException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.bson.Document;
@ -105,6 +111,23 @@ class MongoJsonSchemaUnitTests { @@ -105,6 +111,23 @@ class MongoJsonSchemaUnitTests {
.append("algorithm", "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic").append("bsonType", "string"))))));
}
@Test // GH-4185
void rendersQueryablePropertyCorrectly() {
MongoJsonSchema schema = MongoJsonSchema.builder().properties( //
queryable(rangeEncrypted(number("ssn")),
List.of(QueryCharacteristics.range().contention(0).trimFactor(1).sparsity(1).min(0).max(200))))
.build();
assertThat(schema.toDocument()).isEqualTo(new Document("$jsonSchema",
new Document("type", "object").append("properties",
new Document("ssn",
new Document("encrypt",
new Document("bsonType", "long").append("algorithm", "Range").append("queries",
List.of(new Document("contention", 0L).append("trimFactor", 1).append("sparsity", 1L)
.append("queryType", "range").append("min", 0).append("max", 200))))))));
}
@Test // DATAMONGO-1835
void throwsExceptionOnNullRoot() {
assertThatIllegalArgumentException().isThrownBy(() -> MongoJsonSchema.of((JsonSchemaObject) null));

155
src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
[[mongo.encryption]]
= Encryption (CSFLE)
= Encryption
Client Side Encryption is a feature that encrypts data in your application before it is sent to MongoDB.
We recommend you get familiar with the concepts, ideally from the https://www.mongodb.com/docs/manual/core/csfle/[MongoDB Documentation] to learn more about its capabilities and restrictions before you continue applying Encryption through Spring Data.
We recommend you get familiar with the concepts, ideally from the https://www.mongodb.com/docs/manual/core/security-in-use-encryption/[MongoDB Documentation] to learn more about its capabilities and restrictions before you continue applying Encryption through Spring Data.
[NOTE]
====
@ -11,8 +11,13 @@ MongoDB does not support encryption for all field types. @@ -11,8 +11,13 @@ MongoDB does not support encryption for all field types.
Specific data types require deterministic encryption to preserve equality comparison functionality.
====
== Client Side Field Level Encryption (CSFLE)
Choosing CSFLE gives you full flexibility and allows you to use different keys for a single field, eg. in a one key per tenant scenario. +
Please make sure to consult the https://www.mongodb.com/docs/manual/core/csfle/[MongoDB CSFLE Documentation] before you continue reading.
[[mongo.encryption.automatic]]
== Automatic Encryption
=== Automatic Encryption (CSFLE)
MongoDB supports https://www.mongodb.com/docs/manual/core/csfle/[Client-Side Field Level Encryption] out of the box using the MongoDB driver with its Automatic Encryption feature.
Automatic Encryption requires a xref:mongodb/mapping/mapping-schema.adoc[JSON Schema] that allows to perform encrypted read and write operations without the need to provide an explicit en-/decryption step.
@ -47,7 +52,7 @@ MongoClientSettingsBuilderCustomizer customizer(MappingContext mappingContext) { @@ -47,7 +52,7 @@ MongoClientSettingsBuilderCustomizer customizer(MappingContext mappingContext) {
----
[[mongo.encryption.explicit]]
== Explicit Encryption
=== Explicit Encryption (CSFLE)
Explicit encryption uses the MongoDB driver's encryption library (`org.mongodb:mongodb-crypt`) to perform encryption and decryption tasks.
The `@ExplicitEncrypted` annotation is a combination of the `@Encrypted` annotation used for xref:mongodb/mapping/mapping-schema.adoc#mongo.jsonSchema.encrypted-fields[JSON Schema creation] and a xref:mongodb/mapping/property-converters.adoc[Property Converter].
@ -114,8 +119,147 @@ By default, the `@ExplicitEncrypted(value=…)` attribute references a `MongoEnc @@ -114,8 +119,147 @@ By default, the `@ExplicitEncrypted(value=…)` attribute references a `MongoEnc
It is possible to change the default implementation and exchange it with any `PropertyValueConverter` implementation by providing the according type reference.
To learn more about custom `PropertyValueConverters` and the required configuration, please refer to the xref:mongodb/mapping/property-converters.adoc[Property Converters - Mapping specific fields] section.
[[mongo.encryption.queryable]]
== Queryable Encryption (QE)
Choosing QE enables you to run different types of queries, like _range_ or _equality_, against encrypted fields. +
Please make sure to consult the https://www.mongodb.com/docs/manual/core/queryable-encryption/[MongoDB QE Documentation] before you continue reading to learn more about QE features and limitations.
=== Collection Setup
Queryable Encryption requires upfront declaration of certain aspects allowed within an actual query against an encrypted field.
The information covers the algorithm in use as well as allowed query types along with their attributes and must be provided when creating the collection.
`MongoOperations#createCollection(...)` can be used to do the initial setup for collections utilizing QE.
The configuration for QE via Spring Data uses the same building blocks (a xref:mongodb/mapping/mapping-schema.adoc#mongo.jsonSchema.encrypted-fields[JSON Schema creation]) as CSFLE, converting the schema/properties into the configuration format required by MongoDB.
[tabs]
======
Manual Collection Setup::
+
====
[source,java,indent=0,subs="verbatim,quotes",role="primary"]
----
CollectionOptions collectionOptions = CollectionOptions.encryptedCollection(options -> options
.queryable(encrypted(string("ssn")).algorithm("Indexed"), equality().contention(0))
.queryable(encrypted(int32("age")).algorithm("Range"), range().contention(8).min(0).max(150))
.queryable(encrypted(int64("address.sign")).algorithm("Range"), range().contention(2).min(-10L).max(10L))
);
mongoTemplate.createCollection(Patient.class, collectionOptions); <1>
----
<1> Using the template to create the collection may prevent capturing generated keyIds. In this case render the `Document` from the options and use the `createEncryptedCollection(...)` method via the encryption library.
====
Derived Collection Setup::
+
====
[source,java,indent=0,subs="verbatim,quotes",role="secondary"]
----
class Patient {
@Id String id;
@Encrypted(algorithm = "Indexed") //
@Queryable(queryType = "equality", contentionFactor = 0)
String ssn;
@RangeEncrypted(contentionFactor = 8, rangeOptions = "{ 'min' : 0, 'max' : 150 }")
Integer age;
Address address;
}
MongoJsonSchema patientSchema = MongoJsonSchemaCreator.create(mappingContext)
.filter(MongoJsonSchemaCreator.encryptedOnly())
.createSchemaFor(Patient.class);
CollectionOptions collectionOptions = CollectionOptions.encryptedCollection(patientSchema);
mongoTemplate.createCollection(Patient.class, collectionOptions); <1>
----
<1> Using the template to create the collection may prevent capturing generated keyIds. In this case render the `Document` from the options and use the `createEncryptedCollection(...)` method via the encryption library.
The `Queryable` annotation allows to define allowed query types for encrypted fields.
`@RangeEncrypted` is a combination of `@Encrypted` and `@Queryable` for fields allowing `range` queries.
It is possible to create custom annotations out of the provided ones.
====
MongoDB Collection Info::
+
====
[source,java,indent=0,subs="verbatim,quotes",role="thrid"]
----
{
name: 'patient',
type: 'collection',
options: {
encryptedFields: {
escCollection: 'enxcol_.test.esc',
ecocCollection: 'enxcol_.test.ecoc',
fields: [
{
keyId: ...,
path: 'ssn',
bsonType: 'string',
queries: [ { queryType: 'equality', contention: Long('0') } ]
},
{
keyId: ...,
path: 'age',
bsonType: 'int',
queries: [ { queryType: 'range', contention: Long('8'), min: 0, max: 150 } ]
},
{
keyId: ...,
path: 'address.sign',
bsonType: 'long',
queries: [ { queryType: 'range', contention: Long('2'), min: Long('-10'), max: Long('10') } ]
}
]
}
}
}
----
====
======
[NOTE]
====
- It is not possible to use both QE and CSFLE within the same collection.
- It is not possible to query a `range` indexed field with an `equality` operator.
- It is not possible to query an `equality` indexed field with a `range` operator.
- It is not possible to set `bypassAutoEncrytion(true)`.
- It is not possible to use self maintained encryption keys via `@Encrypted` in combination with Queryable Encryption.
- Contention is only optional on the server side, the clients requires you to set the value (Default us `8`).
- Additional options for eg. `min` and `max` need to match the actual field type. Make sure to use `$numberLong` etc. to ensure target types when parsing bson String.
- Queryable Encryption will an extra field `__safeContent__` to each of your documents.
Unless explicitly excluded the field will be loaded into memory when retrieving results.
====
[[mongo.encryption.queryable.automatic]]
=== Automatic Encryption (QE)
MongoDB supports Queryable Encryption out of the box using the MongoDB driver with its Automatic Encryption feature.
Automatic Encryption requires a xref:mongodb/mapping/mapping-schema.adoc[JSON Schema] that allows to perform encrypted read and write operations without the need to provide an explicit en-/decryption step.
All you need to do is create the collection according to the MongoDB documentation.
You may utilize techniques to create the required configuration outlined in the section above.
[[mongo.encryption.queryable.manual]]
=== Explicit Encryption (QE)
Explicit encryption uses the MongoDB driver's encryption library (`org.mongodb:mongodb-crypt`) to perform encryption and decryption tasks based on the meta information provided by annotation within the domain model.
[NOTE]
====
There is no official support for using Explicit Queryable Encryption.
The audacious user may combine `@Encyrpted` and `@Queryable` with `@ValueConverter(MongoEncryptionConverter.class)` at their own risk.
====
[[mongo.encryption.explicit-setup]]
=== MongoEncryptionConverter Setup
[[mongo.encryption.converter-setup]]
== MongoEncryptionConverter Setup
The converter setup for `MongoEncryptionConverter` requires a few steps as several components are involved.
The bean setup consists of the following:
@ -124,7 +268,6 @@ The bean setup consists of the following: @@ -124,7 +268,6 @@ The bean setup consists of the following:
2. A `MongoEncryptionConverter` instance configured with `ClientEncryption` and a `EncryptionKeyResolver`.
3. A `PropertyValueConverterFactory` that uses the registered `MongoEncryptionConverter` bean.
A side effect of using annotated key resolution is that the `@ExplicitEncrypted` annotation does not need to specify an alt key name.
The `EncryptionKeyResolver` uses an `EncryptionContext` providing access to the property allowing for dynamic DEK resolution.
.Sample MongoEncryptionConverter Configuration

Loading…
Cancel
Save