From aec23986949d7dc2b63faf6529a524a35e2f3d26 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 25 Feb 2025 10:46:30 +0100 Subject: [PATCH] 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 --- .../data/mongodb/core/CollectionOptions.java | 286 ++++++++++++-- .../data/mongodb/core/EntityOperations.java | 7 +- .../core/MappingMongoJsonSchemaCreator.java | 35 +- .../core/convert/MongoConversionContext.java | 98 ++++- .../mongodb/core/convert/QueryMapper.java | 27 +- .../mongodb/core/convert/UpdateMapper.java | 10 + .../encryption/ExplicitEncryptionContext.java | 5 +- .../encryption/MongoEncryptionConverter.java | 91 ++--- .../core/encryption/EncryptionContext.java | 3 +- .../core/encryption/EncryptionOptions.java | 105 ++--- .../encryption/MongoClientEncryption.java | 61 ++- .../core/mapping/ExplicitEncrypted.java | 18 - .../data/mongodb/core/mapping/Queryable.java | 49 +++ .../mongodb/core/mapping/RangeEncrypted.java | 56 +++ .../IdentifiableJsonSchemaProperty.java | 97 ++++- .../core/schema/JsonSchemaProperty.java | 26 +- .../mongodb/core/schema/MergedJsonSchema.java | 2 + .../core/schema/QueryCharacteristic.java | 34 ++ .../core/schema/QueryCharacteristics.java | 147 +++++++ .../util/MongoCompatibilityAdapter.java | 31 +- .../core/CollectionOptionsUnitTests.java | 105 ++++- ...efaultIndexOperationsIntegrationTests.java | 20 +- ...appingMongoJsonSchemaCreatorUnitTests.java | 74 +++- ...ableEncryptionCollectionCreationTests.java | 140 +++++++ .../core/encryption/RangeEncryptionTests.java | 362 ++++++++++++++---- .../core/schema/MongoJsonSchemaUnitTests.java | 27 +- .../ROOT/pages/mongodb/mongo-encryption.adoc | 155 +++++++- 27 files changed, 1784 insertions(+), 287 deletions(-) create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Queryable.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/RangeEncrypted.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristic.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java index 97cbfb536..28215dc64 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java @@ -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 { 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 { this.validationOptions = validationOptions; this.timeSeriesOptions = timeSeriesOptions; this.changeStreamOptions = changeStreamOptions; - this.encryptedFields = encryptedFields; + this.encryptedFieldsOptions = encryptedFieldsOptions; } /** @@ -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 optionsFunction) { + return encryptedCollection(optionsFunction.apply(new EncryptedFieldsOptions())); + } + /** * Create new {@link CollectionOptions} with already given settings and capped set to {@literal true}.
* NOTE: Using capped collections requires defining {@link #size(long)}. @@ -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 { */ 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 { */ 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 { */ 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 { 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 { 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 { 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 { * @return {@link Optional#empty()} if not specified. * @since 4.5.0 */ - public Optional getEncryptedFields() { - return Optional.ofNullable(encryptedFields); + public Optional 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 { 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 { 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 { } } + /** + * 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 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 properties) { + return new EncryptedFieldsOptions(null, List.copyOf(properties)); + } + + EncryptedFieldsOptions() { + this(null, List.of()); + } + + private EncryptedFieldsOptions(@Nullable MongoJsonSchema schema, + List queryableProperties) { + + this.schema = schema; + this.queryableProperties = queryableProperties; + } + + /** + * Add a new {@link QueryableJsonSchemaProperty queryable property} for the given source property. + *

+ * 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 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 selectPaths() { + + Map 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 fromProperties() { + + if (queryableProperties.isEmpty()) { + return List.of(); + } + + List 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 fromSchema() { + + if (schema == null) { + return List.of(); + } + + Document root = schema.schemaDocument(); + Map paths = new LinkedHashMap<>(); + collectPaths(root, null, paths); + + List fields = new ArrayList<>(); + if (!paths.isEmpty()) { + + for (Entry 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 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 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. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java index b7a2380ce..24977c5af 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java @@ -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 { 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; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java index 839f49c7d..790fa9429 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java +++ b/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; 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 { 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 path, diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java index acc8dfacb..9d672ea92 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConversionContext.java @@ -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 accessor, @Nullable MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) { @@ -53,19 +53,19 @@ public class MongoConversionContext implements ValueConversionContext 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 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> valueConverter, MongoConversionContext conversionContext) { @@ -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 { return converter; } - private enum NoPropertyPropertyValueProvider implements PropertyValueProvider { + enum NoPropertyPropertyValueProvider implements PropertyValueProvider { INSTANCE; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/UpdateMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/UpdateMapper.java index 35cb578c2..805bafe97 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/UpdateMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/UpdateMapper.java @@ -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 { return super.getMappedObjectForField(field, rawValue); } + protected Object convertValueWithConversionContext(Field documentField, Object sourceValue, Object value, + PropertyValueConverter> valueConverter, + MongoConversionContext conversionContext) { + + return super.convertValueWithConversionContext(documentField, sourceValue, value, valueConverter, conversionContext.forOperator(new WriteOperatorContext(documentField.name))); + } + private Entry getMappedUpdateModifier(Field field, Object rawValue) { Object value; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java index e78feba73..67c30fcf9 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java @@ -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 { @Override @Nullable - public String getFieldNameAndQueryOperator() { - return conversionContext.getFieldNameAndQueryOperator(); + public OperatorContext getOperatorContext() { + return conversionContext.getOperatorContext(); } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java index e3fdbe37c..c69653d2d 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java @@ -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; 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= 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 EncryptingConverterThe 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. + *

+ * 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 " diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java index 1643e2f95..5f5e29578 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java @@ -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 { * @return can be {@literal null}. */ @Nullable - default String getFieldNameAndQueryOperator() { + default OperatorContext getOperatorContext() { return null; } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java index 5affbeddb..73a66e4a8 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java @@ -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 { 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 { 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 { * 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 attributes; private QueryableEncryptionOptions(@Nullable String queryType, @Nullable Long contentionFactor, - @Nullable Document rangeOptions) { + Map attributes) { + this.queryType = queryType; this.contentionFactor = contentionFactor; - this.rangeOptions = rangeOptions; + this.attributes = attributes; } /** @@ -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 { * @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 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 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 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 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 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 { 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); } } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java index 4d250fba0..f83f98d4a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java @@ -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 } 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 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 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; + } + } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java index a8aedce8b..37d1019f6 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java @@ -86,24 +86,6 @@ public @interface ExplicitEncrypted { */ String keyAltName() default ""; - /** - * Set the contention factor - *

- * Only required when using {@literal range} encryption. - * @return the contention factor - */ - long contentionFactor() default -1; - - /** - * Set the {@literal range} options - *

- * 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. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Queryable.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Queryable.java new file mode 100644 index 000000000..abebc11a5 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Queryable.java @@ -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; +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/RangeEncrypted.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/RangeEncrypted.java new file mode 100644 index 000000000..5710c081f --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/RangeEncrypted.java @@ -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 + *

+ * Should be valid extended json representing the range options and including the following values: {@code min}, + * {@code max}, {@code trimFactor} and {@code sparsity}. + *

+ * 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 ""; +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java index 29cedfd6c..c95bd4c73 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java @@ -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 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 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 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 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 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 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 queries = characteristics.getCharacteristics().stream().map(QueryCharacteristic::toDocument) + .toList(); + encrypt.append("queries", queries); + } + + return doc; + } + + @Override + public String getIdentifier() { + return targetProperty.getIdentifier(); + } + + @Override + public Set getTypes() { + return targetProperty.getTypes(); + } + + boolean isEncrypted() { + return targetProperty instanceof EncryptedJsonSchemaProperty; + } + + public JsonSchemaProperty getTargetProperty() { + return targetProperty; + } + + public QueryCharacteristics getCharacteristics() { + return characteristics; + } } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java index 8529951db..a854c6184 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java @@ -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 { 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 queries) { + return new QueryableJsonSchemaProperty(property, new QueryCharacteristics(queries)); + } + /** * Creates a new {@link StringJsonSchemaProperty} with given {@literal identifier} of {@code type : 'string'}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java index e0f3e2610..a6fc3ab8b 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java @@ -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; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristic.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristic.java new file mode 100644 index 000000000..2b405c56c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristic.java @@ -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()); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java new file mode 100644 index 000000000..ad64e85ea --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java @@ -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 characteristics; + + QueryCharacteristics(List characteristics) { + this.characteristics = characteristics; + } + + public static QueryCharacteristics none() { + return NONE; + } + + public static QueryCharacteristics of(List characteristics) { + return new QueryCharacteristics(List.copyOf(characteristics)); + } + + QueryCharacteristics(QueryCharacteristic... characteristics) { + this.characteristics = Arrays.asList(characteristics); + } + + public List getCharacteristics() { + return characteristics; + } + + public static RangeQuery range() { + return new RangeQuery<>(); + } + + public static EqualityQuery equality() { + return new EqualityQuery<>(null); + } + + public static class EqualityQuery implements QueryCharacteristic { + + private final @Nullable Long contention; + + public EqualityQuery(@Nullable Long contention) { + this.contention = contention; + } + + public EqualityQuery 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 implements QueryCharacteristic { + + private final @Nullable Range 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 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 min(T lower) { + + Range range = Range.of(Bound.inclusive(lower), + valueRange != null ? valueRange.getUpperBound() : Bound.unbounded()); + return new RangeQuery<>(range, trimFactor, sparsity, contention); + } + + public RangeQuery max(T upper) { + + Range range = Range.of(valueRange != null ? valueRange.getLowerBound() : Bound.unbounded(), + Bound.inclusive(upper)); + return new RangeQuery<>(range, trimFactor, sparsity, contention); + } + + public RangeQuery trimFactor(int trimFactor) { + return new RangeQuery<>(valueRange, trimFactor, sparsity, contention); + } + + public RangeQuery sparsity(long sparsity) { + return new RangeQuery<>(valueRange, trimFactor, sparsity, contention); + } + + public RangeQuery 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)); + } + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java index b17b9f196..a61fe0eca 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoCompatibilityAdapter.java @@ -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 { }; } + /** + * 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}. * diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java index f2691275c..9de0863cd 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java @@ -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 { .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)); + }); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/DefaultIndexOperationsIntegrationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/DefaultIndexOperationsIntegrationTests.java index af4fac84b..78a6e6b49 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/DefaultIndexOperationsIntegrationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/DefaultIndexOperationsIntegrationTests.java @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java index d18ed6f11..adaecad5d 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java @@ -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; 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 { .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 { " '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 { 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; + } + } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java new file mode 100644 index 000000000..d88bde68a --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java @@ -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 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 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); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java index 2c5e3abc6..2ea0b5b21 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java @@ -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; 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; 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 { 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 { .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 { .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 { } @Bean - MongoEncryptionConverter encryptingConverter(MongoClientEncryption mongoClientEncryption) { + EncryptionKeyHolder keyHolder(MongoClientEncryption mongoClientEncryption) { + Lazy> 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 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 { } } + static class EncryptionKeyHolder { + + Supplier> lazyDataKeyMap; + + public EncryptionKeyHolder(Supplier> 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 { @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); } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java index 3514927b1..e2c385464 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java @@ -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 { .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)); diff --git a/src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc b/src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc index 98a6d2478..a4aae2374 100644 --- a/src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc +++ b/src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc @@ -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. 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) { ---- [[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 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: 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