diff --git a/pom.xml b/pom.xml index d864b2e4e6..4baadbafe8 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-4284-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-benchmarks/pom.xml b/spring-data-mongodb-benchmarks/pom.xml index 1b2a1390e6..a98f47aa21 100644 --- a/spring-data-mongodb-benchmarks/pom.xml +++ b/spring-data-mongodb-benchmarks/pom.xml @@ -7,7 +7,7 @@ org.springframework.data spring-data-mongodb-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-4284-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 8db8d798fb..8a28b2af05 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -15,7 +15,7 @@ org.springframework.data spring-data-mongodb-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-4284-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 9a57f7eb52..d223076814 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-mongodb-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-4284-SNAPSHOT ../pom.xml @@ -112,6 +112,13 @@ true + + org.mongodb + mongodb-crypt + 1.6.1 + true + + io.projectreactor reactor-core diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoExceptionTranslator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoExceptionTranslator.java index 08fd0c36dd..f3b4e6a718 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoExceptionTranslator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoExceptionTranslator.java @@ -68,6 +68,8 @@ public class MongoExceptionTranslator implements PersistenceExceptionTranslator private static final Set DATA_INTEGRITY_EXCEPTIONS = new HashSet<>( Arrays.asList("WriteConcernException", "MongoWriteException", "MongoBulkWriteException")); + private static final Set SECURITY_EXCEPTIONS = Set.of("MongoCryptException"); + @Nullable public DataAccessException translateExceptionIfPossible(RuntimeException ex) { @@ -131,6 +133,8 @@ public DataAccessException translateExceptionIfPossible(RuntimeException ex) { return new ClientSessionException(ex.getMessage(), ex); } else if (MongoDbErrorCodes.isTransactionFailureCode(code)) { return new MongoTransactionException(ex.getMessage(), ex); + } else if(ex.getCause() != null && SECURITY_EXCEPTIONS.contains(ClassUtils.getShortName(ex.getCause().getClass()))) { + return new PermissionDeniedDataAccessException(ex.getMessage(), ex); } return new UncategorizedMongoDbException(ex.getMessage(), ex); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java index 43d7d39a82..fe1882dfdf 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java @@ -17,16 +17,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -39,7 +30,6 @@ import org.bson.conversions.Bson; import org.bson.json.JsonReader; import org.bson.types.ObjectId; - import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.context.ApplicationContext; @@ -51,16 +41,7 @@ import org.springframework.data.annotation.Reference; import org.springframework.data.convert.CustomConversions; import org.springframework.data.convert.TypeMapper; -import org.springframework.data.mapping.AccessOptions; -import org.springframework.data.mapping.Association; -import org.springframework.data.mapping.InstanceCreatorMetadata; -import org.springframework.data.mapping.MappingException; -import org.springframework.data.mapping.Parameter; -import org.springframework.data.mapping.PersistentEntity; -import org.springframework.data.mapping.PersistentProperty; -import org.springframework.data.mapping.PersistentPropertyAccessor; -import org.springframework.data.mapping.PersistentPropertyPath; -import org.springframework.data.mapping.PersistentPropertyPathAccessor; +import org.springframework.data.mapping.*; import org.springframework.data.mapping.callback.EntityCallbacks; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.model.ConvertingPropertyAccessor; @@ -331,7 +312,7 @@ private R doReadProjection(ConversionContext context, Bson bson, EntityProje PersistentPropertyAccessor convertingAccessor = PropertyTranslatingPropertyAccessor .create(new ConvertingPropertyAccessor<>(accessor, conversionService), propertyTranslator); MongoDbPropertyValueProvider valueProvider = new MongoDbPropertyValueProvider(context, documentAccessor, - evaluator); + evaluator, spELContext); readProperties(context, entity, convertingAccessor, documentAccessor, valueProvider, evaluator, Predicates.isTrue()); @@ -367,7 +348,7 @@ String getFieldName(MongoPersistentProperty prop) { populateProperties(context, mappedEntity, documentAccessor, evaluator, instance); PersistentPropertyAccessor convertingAccessor = new ConvertingPropertyAccessor<>(accessor, conversionService); - MongoDbPropertyValueProvider valueProvider = new MongoDbPropertyValueProvider(context, documentAccessor, evaluator); + MongoDbPropertyValueProvider valueProvider = new MongoDbPropertyValueProvider(context, documentAccessor, evaluator, spELContext); readProperties(context, mappedEntity, convertingAccessor, documentAccessor, valueProvider, evaluator, Predicates.isTrue()); @@ -529,7 +510,7 @@ private S populateProperties(ConversionContext context, MongoPersistentEntit ConversionContext contextToUse = context.withPath(currentPath); MongoDbPropertyValueProvider valueProvider = new MongoDbPropertyValueProvider(contextToUse, documentAccessor, - evaluator); + evaluator, spELContext); Predicate propertyFilter = isIdentifier(entity).or(isConstructorArgument(entity)).negate(); readProperties(contextToUse, entity, accessor, documentAccessor, valueProvider, evaluator, propertyFilter); @@ -868,9 +849,9 @@ private void writeProperties(Bson bson, MongoPersistentEntity entity, Persist dbObjectAccessor.put(prop, null); } } else if (!conversions.isSimpleType(value.getClass())) { - writePropertyInternal(value, dbObjectAccessor, prop); + writePropertyInternal(value, dbObjectAccessor, prop, accessor); } else { - writeSimpleInternal(value, bson, prop); + writeSimpleInternal(value, bson, prop, accessor); } } } @@ -887,11 +868,11 @@ private void writeAssociation(Association association, return; } - writePropertyInternal(value, dbObjectAccessor, inverseProp); + writePropertyInternal(value, dbObjectAccessor, inverseProp, accessor); } @SuppressWarnings({ "unchecked" }) - protected void writePropertyInternal(@Nullable Object obj, DocumentAccessor accessor, MongoPersistentProperty prop) { + protected void writePropertyInternal(@Nullable Object obj, DocumentAccessor accessor, MongoPersistentProperty prop, PersistentPropertyAccessor persistentPropertyAccessor) { if (obj == null) { return; @@ -902,7 +883,13 @@ protected void writePropertyInternal(@Nullable Object obj, DocumentAccessor acce if (conversions.hasValueConverter(prop)) { accessor.put(prop, conversions.getPropertyValueConversions().getValueConverter(prop).write(obj, - new MongoConversionContext(prop, this))); + new MongoConversionContext(new PropertyValueProvider<>() { + @Nullable + @Override + public T getPropertyValue(MongoPersistentProperty property) { + return (T) persistentPropertyAccessor.getProperty(property); + } + }, prop, this, spELContext))); return; } @@ -1234,12 +1221,18 @@ private void writeSimpleInternal(@Nullable Object value, Bson bson, String key) BsonUtils.addToMap(bson, key, getPotentiallyConvertedSimpleWrite(value, Object.class)); } - private void writeSimpleInternal(@Nullable Object value, Bson bson, MongoPersistentProperty property) { + private void writeSimpleInternal(@Nullable Object value, Bson bson, MongoPersistentProperty property, PersistentPropertyAccessor persistentPropertyAccessor) { DocumentAccessor accessor = new DocumentAccessor(bson); if (conversions.hasValueConverter(property)) { accessor.put(property, conversions.getPropertyValueConversions().getValueConverter(property).write(value, - new MongoConversionContext(property, this))); + new MongoConversionContext(new PropertyValueProvider<>() { + @Nullable + @Override + public T getPropertyValue(MongoPersistentProperty property) { + return (T) persistentPropertyAccessor.getProperty(property); + } + }, property, this, spELContext))); return; } @@ -1845,6 +1838,7 @@ static class MongoDbPropertyValueProvider implements PropertyValueProvider T getPropertyValue(MongoPersistentProperty property) { CustomConversions conversions = context.getCustomConversions(); if (conversions.hasValueConverter(property)) { return (T) conversions.getPropertyValueConversions().getValueConverter(property).read(value, - new MongoConversionContext(property, context.getSourceConverter())); + new MongoConversionContext(this, property, context.getSourceConverter(), spELContext)); } ConversionContext contextToUse = context.forProperty(property); @@ -1902,7 +1897,7 @@ public T getPropertyValue(MongoPersistentProperty property) { public MongoDbPropertyValueProvider withContext(ConversionContext context) { - return context == this.context ? this : new MongoDbPropertyValueProvider(context, accessor, evaluator); + return context == this.context ? this : new MongoDbPropertyValueProvider(context, accessor, evaluator, spELContext); } } @@ -1925,7 +1920,7 @@ class AssociationAwareMongoDbPropertyValueProvider extends MongoDbPropertyValueP */ AssociationAwareMongoDbPropertyValueProvider(ConversionContext context, DocumentAccessor source, SpELExpressionEvaluator evaluator) { - super(context, source, evaluator); + super(context, source, evaluator, MappingMongoConverter.this.spELContext); } @Override 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 f4a1ea7bd1..c5b434bf4d 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 @@ -17,6 +17,8 @@ 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.Nullable; @@ -29,13 +31,25 @@ */ public class MongoConversionContext implements ValueConversionContext { + private final PropertyValueProvider accessor; // TODO: generics private final MongoPersistentProperty persistentProperty; private final MongoConverter mongoConverter; - public MongoConversionContext(MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) { + @Nullable + private final SpELContext spELContext; + public MongoConversionContext(PropertyValueProvider accessor, + MongoPersistentProperty persistentProperty, MongoConverter mongoConverter) { + this(accessor, persistentProperty, mongoConverter, null); + } + + public MongoConversionContext(PropertyValueProvider accessor, + MongoPersistentProperty persistentProperty, MongoConverter mongoConverter, @Nullable SpELContext spELContext) { + + this.accessor = accessor; this.persistentProperty = persistentProperty; this.mongoConverter = mongoConverter; + this.spELContext = spELContext; } @Override @@ -43,7 +57,13 @@ public MongoPersistentProperty getProperty() { return persistentProperty; } + @Nullable + public Object getValue(String propertyPath) { + return accessor.getPropertyValue(persistentProperty.getOwner().getRequiredPersistentProperty(propertyPath)); + } + @Override + @SuppressWarnings("unchecked") public T write(@Nullable Object value, TypeInformation target) { return (T) mongoConverter.convertToMongoType(value, target); } @@ -53,4 +73,9 @@ public T read(@Nullable Object value, TypeInformation target) { return value instanceof Bson ? mongoConverter.read(target.getType(), (Bson) value) : ValueConversionContext.super.read(value, target); } + + @Nullable + public SpELContext getSpELContext() { + return spELContext; + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java index 9302d37cae..8e0908d780 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java @@ -437,7 +437,7 @@ protected Object getMappedValue(Field documentField, Object sourceValue) { && converter.getCustomConversions().hasValueConverter(documentField.getProperty())) { return converter.getCustomConversions().getPropertyValueConversions() .getValueConverter(documentField.getProperty()) - .write(value, new MongoConversionContext(documentField.getProperty(), converter)); + .write(value, new MongoConversionContext(null, documentField.getProperty(), converter)); } if (documentField.isIdField() && !documentField.isAssociation()) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/EncryptingConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/EncryptingConverter.java new file mode 100644 index 0000000000..fba6907108 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/EncryptingConverter.java @@ -0,0 +1,66 @@ +/* + * Copyright 2023 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.convert.encryption; + +import org.springframework.data.mongodb.core.convert.MongoConversionContext; +import org.springframework.data.mongodb.core.convert.MongoValueConverter; +import org.springframework.data.mongodb.core.encryption.EncryptionContext; + +/** + * A specialized {@link MongoValueConverter} for {@literal encryptiong} and {@literal decrypting} properties. + * + * @author Christoph Strobl + * @since 4.1 + */ +public interface EncryptingConverter extends MongoValueConverter { + + @Override + default S read(Object value, MongoConversionContext context) { + return decrypt(value, buildEncryptionContext(context)); + } + + /** + * Decrypt the given encrypted source value within the given {@link EncryptionContext context}. + * + * @param encryptedValue the encrypted source. + * @param context the context to operate in. + * @return never {@literal null}. + */ + S decrypt(Object encryptedValue, EncryptionContext context); + + @Override + default T write(Object value, MongoConversionContext context) { + return encrypt(value, buildEncryptionContext(context)); + } + + /** + * Encrypt the given raw source value within the given {@link EncryptionContext context}. + * + * @param value the encrypted source. + * @param context the context to operate in. + * @return never {@literal null}. + */ + T encrypt(Object value, EncryptionContext context); + + /** + * Obtain the {@link EncryptionContext} for a given {@link MongoConversionContext value conversion context}. + * + * @param context the current MongoDB specific {@link org.springframework.data.convert.ValueConversionContext}. + * @return the {@link EncryptionContext} to operate in. + * @see org.springframework.data.convert.ValueConversionContext + */ + EncryptionContext buildEncryptionContext(MongoConversionContext context); +} 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 new file mode 100644 index 0000000000..729a5ccd46 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/ExplicitEncryptionContext.java @@ -0,0 +1,70 @@ +/* + * Copyright 2023 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.convert.encryption; + +import org.springframework.data.mongodb.core.convert.MongoConversionContext; +import org.springframework.data.mongodb.core.encryption.EncryptionContext; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.util.TypeInformation; +import org.springframework.expression.EvaluationContext; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +/** + * Default {@link EncryptionContext} implementation. + * + * @author Christoph Strobl + * @since 4.1 + */ +class ExplicitEncryptionContext implements EncryptionContext { + + private final MongoConversionContext conversionContext; + + public ExplicitEncryptionContext(MongoConversionContext conversionContext) { + this.conversionContext = conversionContext; + } + + @Override + public MongoPersistentProperty getProperty() { + return conversionContext.getProperty(); + } + + @Nullable + @Override + public Object lookupValue(String path) { + return conversionContext.getValue(path); + } + + @Override + public Object convertToMongoType(Object value) { + return conversionContext.write(value); + } + + @Override + public EvaluationContext getEvaluationContext(Object source) { + return conversionContext.getSpELContext().getEvaluationContext(source); + } + + @Override + public T read(@Nullable Object value, TypeInformation target) { + return conversionContext.read(value, target); + } + + @Override + public T write(@Nullable Object value, TypeInformation target) { + return conversionContext.write(value, target); + } +} 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 new file mode 100644 index 0000000000..bf7d87bef1 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java @@ -0,0 +1,209 @@ +/* + * Copyright 2023 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.convert.encryption; + +import java.util.Collection; +import java.util.LinkedHashMap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.bson.BsonArray; +import org.bson.BsonBinary; +import org.bson.BsonDocument; +import org.bson.BsonValue; +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.encryption.Encryption; +import org.springframework.data.mongodb.core.encryption.EncryptionContext; +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.MongoPersistentProperty; +import org.springframework.data.mongodb.util.BsonUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; + +/** + * Default implementation of {@link EncryptingConverter}. Properties used with this converter must be annotated with + * {@link Encrypted @Encrypted} to provide key and algorithm metadata. + * + * @author Christoph Strobl + * @since 4.1 + */ +public class MongoEncryptionConverter implements EncryptingConverter { + + private static final Log LOGGER = LogFactory.getLog(MongoEncryptionConverter.class); + + private final Encryption encryption; + private final EncryptionKeyResolver keyResolver; + + public MongoEncryptionConverter(Encryption encryption, EncryptionKeyResolver keyResolver) { + + this.encryption = encryption; + this.keyResolver = keyResolver; + } + + @Nullable + @Override + public Object read(Object value, MongoConversionContext context) { + + Object decrypted = EncryptingConverter.super.read(value, context); + return decrypted instanceof BsonValue ? BsonUtils.toJavaType((BsonValue) decrypted) : decrypted; + } + + @Override + public Object decrypt(Object encryptedValue, EncryptionContext context) { + + Object decryptedValue = encryptedValue; + if (encryptedValue instanceof Binary || encryptedValue instanceof BsonBinary) { + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("Decrypting %s.%s.", getProperty(context).getOwner().getName(), + getProperty(context).getName())); + } + + decryptedValue = encryption.decrypt((BsonBinary) BsonUtils.simpleToBsonValue(encryptedValue)); + + // in case the driver has auto decryption (aka .bypassAutoEncryption(true)) active + // https://github.com/mongodb/mongo-java-driver/blob/master/driver-sync/src/examples/tour/ClientSideEncryptionExplicitEncryptionOnlyTour.java + if (encryptedValue == decryptedValue) { + return decryptedValue; + } + } + + MongoPersistentProperty persistentProperty = getProperty(context); + + if (getProperty(context).isCollectionLike() && decryptedValue instanceof Iterable iterable) { + + int size = iterable instanceof Collection c ? c.size() : 10; + + if (!persistentProperty.isEntity()) { + Collection collection = CollectionFactory.createCollection(persistentProperty.getType(), size); + iterable.forEach(it -> collection.add(BsonUtils.toJavaType((BsonValue) it))); + return collection; + } else { + Collection collection = CollectionFactory.createCollection(persistentProperty.getType(), size); + iterable.forEach(it -> { + collection.add(context.read(BsonUtils.toJavaType((BsonValue) it), persistentProperty.getActualType())); + }); + return collection; + } + } + + if (!persistentProperty.isEntity() && decryptedValue instanceof BsonValue bsonValue) { + if (persistentProperty.isMap() && persistentProperty.getType() != Document.class) { + return new LinkedHashMap<>((Document) BsonUtils.toJavaType(bsonValue)); + + } + return BsonUtils.toJavaType(bsonValue); + } + + if (persistentProperty.isEntity() && decryptedValue instanceof BsonDocument bsonDocument) { + return context.read(BsonUtils.toJavaType(bsonDocument), persistentProperty.getTypeInformation().getType()); + } + + return decryptedValue; + } + + @Override + public Object encrypt(Object value, EncryptionContext context) { + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("Encrypting %s.%s.", getProperty(context).getOwner().getName(), + getProperty(context).getName())); + } + + MongoPersistentProperty persistentProperty = getProperty(context); + + Encrypted annotation = persistentProperty.findAnnotation(Encrypted.class); + if (annotation == null) { + annotation = persistentProperty.getOwner().findAnnotation(Encrypted.class); + } + + if (annotation == null) { + throw new IllegalStateException(String.format("Property %s.%s is not annotated with @Encrypted", + getProperty(context).getOwner().getName(), getProperty(context).getName())); + } + + EncryptionOptions encryptionOptions = new EncryptionOptions(annotation.algorithm(), keyResolver.getKey(context)); + + if (!persistentProperty.isEntity()) { + + if (persistentProperty.isCollectionLike()) { + return encryption.encrypt(collectionLikeToBsonValue(value, persistentProperty, context), encryptionOptions); + } + if (persistentProperty.isMap()) { + Object convertedMap = context.write(value); + if (convertedMap instanceof Document document) { + return encryption.encrypt(document.toBsonDocument(), encryptionOptions); + } + } + return encryption.encrypt(BsonUtils.simpleToBsonValue(value), encryptionOptions); + } + if (persistentProperty.isCollectionLike()) { + return encryption.encrypt(collectionLikeToBsonValue(value, persistentProperty, context), encryptionOptions); + } + + Object write = context.write(value); + if (write instanceof Document doc) { + return encryption.encrypt(doc.toBsonDocument(), encryptionOptions); + } + return encryption.encrypt(BsonUtils.simpleToBsonValue(write), encryptionOptions); + } + + private BsonValue collectionLikeToBsonValue(Object value, MongoPersistentProperty property, + EncryptionContext context) { + + BsonArray bsonArray = new BsonArray(); + boolean isEntity = property.isEntity(); + + if (value instanceof Collection values) { + values.forEach(it -> { + + if (isEntity) { + Document document = (Document) context.write(it, property.getTypeInformation()); + bsonArray.add(document == null ? null : document.toBsonDocument()); + } else { + bsonArray.add(BsonUtils.simpleToBsonValue(it)); + } + }); + } else if (ObjectUtils.isArray(value)) { + + for (Object o : ObjectUtils.toObjectArray(value)) { + + if (isEntity) { + Document document = (Document) context.write(o, property.getTypeInformation()); + bsonArray.add(document == null ? null : document.toBsonDocument()); + } else { + bsonArray.add(BsonUtils.simpleToBsonValue(o)); + } + } + } + + return bsonArray; + } + + @Override + public EncryptionContext buildEncryptionContext(MongoConversionContext context) { + return new ExplicitEncryptionContext(context); + } + + protected MongoPersistentProperty getProperty(EncryptionContext context) { + return context.getProperty(); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/package-info.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/package-info.java new file mode 100644 index 0000000000..4a6f78357a --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/package-info.java @@ -0,0 +1,7 @@ +/** + * Converters integrating with + * explicit encryption + * mechanism of Client-Side Field Level Encryption. + */ +@org.springframework.lang.NonNullApi +package org.springframework.data.mongodb.core.convert.encryption; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java new file mode 100644 index 0000000000..6fdbcf24cd --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/Encryption.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 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; + +/** + * Component responsible for encrypting and decrypting values. + * + * @author Christoph Strobl + * @since 4.1 + */ +public interface Encryption { + + /** + * Encrypt the given value. + * + * @param value must not be {@literal null}. + * @param options must not be {@literal null}. + * @return the encrypted value. + */ + T encrypt(S value, EncryptionOptions options); + + /** + * Decrypt the given value. + * + * @param value must not be {@literal null}. + * @return the decrypted value. + */ + S decrypt(T value); + +} 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 new file mode 100644 index 0000000000..6028341e13 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java @@ -0,0 +1,131 @@ +/* + * Copyright 2023 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 org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.util.TypeInformation; +import org.springframework.expression.EvaluationContext; +import org.springframework.lang.Nullable; + +/** + * Context to encapsulate encryption for a specific {@link MongoPersistentProperty}. + * + * @author Christoph Strobl + * @since 4.1 + */ +public interface EncryptionContext { + + /** + * Returns the {@link MongoPersistentProperty} to be handled. + * + * @return will never be {@literal null}. + */ + MongoPersistentProperty getProperty(); + + /** + * Shortcut for converting a given {@literal value} into its store representation using the root + * {@code ValueConversionContext}. + * + * @param value + * @return + */ + Object convertToMongoType(Object value); + + /** + * Reads the value as an instance of the {@link PersistentProperty#getTypeInformation() property type}. + * + * @param value {@link Object value} to be read; can be {@literal null}. + * @return can be {@literal null}. + * @throws IllegalStateException if value cannot be read as an instance of {@link Class type}. + */ + default T read(@Nullable Object value) { + return (T) read(value, getProperty().getTypeInformation()); + } + + /** + * Reads the value as an instance of {@link Class type}. + * + * @param value {@link Object value} to be read; can be {@literal null}. + * @param target {@link Class type} of value to be read; must not be {@literal null}. + * @return can be {@literal null}. + * @throws IllegalStateException if value cannot be read as an instance of {@link Class type}. + */ + default T read(@Nullable Object value, Class target) { + return read(value, TypeInformation.of(target)); + } + + /** + * Reads the value as an instance of {@link TypeInformation type}. + * + * @param value {@link Object value} to be read; can be {@literal null}. + * @param target {@link TypeInformation type} of value to be read; must not be {@literal null}. + * @return can be {@literal null}. + * @throws IllegalStateException if value cannot be read as an instance of {@link Class type}. + */ + T read(@Nullable Object value, TypeInformation target); + + /** + * Write the value as an instance of the {@link PersistentProperty#getTypeInformation() property type}. + * + * @param value {@link Object value} to write; can be {@literal null}. + * @return can be {@literal null}. + * @throws IllegalStateException if value cannot be written as an instance of the + * {@link PersistentProperty#getTypeInformation() property type}. + * @see PersistentProperty#getTypeInformation() + * @see #write(Object, TypeInformation) + */ + @Nullable + default T write(@Nullable Object value) { + return (T) write(value, getProperty().getTypeInformation()); + } + + /** + * Write the value as an instance of {@link Class type}. + * + * @param value {@link Object value} to write; can be {@literal null}. + * @param target {@link Class type} of value to be written; must not be {@literal null}. + * @return can be {@literal null}. + * @throws IllegalStateException if value cannot be written as an instance of {@link Class type}. + */ + @Nullable + default T write(@Nullable Object value, Class target) { + return write(value, TypeInformation.of(target)); + } + + /** + * Write the value as an instance of given {@link TypeInformation type}. + * + * @param value {@link Object value} to write; can be {@literal null}. + * @param target {@link TypeInformation type} of value to be written; must not be {@literal null}. + * @return can be {@literal null}. + * @throws IllegalStateException if value cannot be written as an instance of {@link Class type}. + */ + @Nullable + T write(@Nullable Object value, TypeInformation target); + + /** + * Lookup the value for a given path within the current context. + * + * @param path the path/property name to resolve the current value for. + * @return can be {@literal null}. + */ + @Nullable + Object lookupValue(String path); + + EvaluationContext getEvaluationContext(Object source); + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKey.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKey.java new file mode 100644 index 0000000000..f4eebd43ed --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKey.java @@ -0,0 +1,81 @@ +/* + * Copyright 2023 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 org.bson.BsonBinary; +import org.springframework.util.Assert; + +/** + * The {@link EncryptionKey} represents a {@literal Data Encryption Key} reference that can be either direct via the + * {@link KeyId key id} or its {@link KeyAltName Key Alternative Name}. + * + * @author Christoph Strobl + * @since 4.1 + */ +public interface EncryptionKey { + + /** + * Create a new {@link EncryptionKey} that uses the keys id for reference. + * + * @param key must not be {@literal null}. + * @return new instance of {@link EncryptionKey KeyId}. + */ + static EncryptionKey keyId(BsonBinary key) { + + Assert.notNull(key, "KeyId must not be null"); + + return new KeyId(key); + } + + /** + * Create a new {@link EncryptionKey} that uses an {@literal Key Alternative Name} for reference. + * + * @param keyAltName must not be {@literal null} or empty. + * @return new instance of {@link EncryptionKey KeyAltName}. + */ + static EncryptionKey keyAltName(String keyAltName) { + + Assert.hasText(keyAltName, "Key Alternative Name must not be empty"); + + return new KeyAltName(keyAltName); + } + + /** + * @return the value that allows to reference a specific key. + */ + Object value(); + + /** + * @return the {@link Type} of reference. + */ + Type type(); + + /** + * The key reference type. + */ + enum Type { + + /** + * Key referenced via its {@literal id}. + */ + ID, + + /** + * Key referenced via an {@literal Key Alternative Name}. + */ + ALT + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolver.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolver.java new file mode 100644 index 0000000000..c9bc2b07ec --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolver.java @@ -0,0 +1,100 @@ +/* + * Copyright 2023 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 org.bson.BsonBinary; +import org.bson.types.Binary; +import org.springframework.data.mongodb.core.mapping.Encrypted; +import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.mongodb.util.BsonUtils; +import org.springframework.data.mongodb.util.encryption.EncryptionUtils; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Interface to obtain a {@link EncryptionKey Data Encryption Key} that is valid in a given {@link EncryptionContext + * context}. + *

+ * Use the {@link #annotated(EncryptionKeyResolver) based} variant which will first try to resolve a potential + * {@link ExplicitEncrypted#keyAltName() Key Alternate Name} from annotations before calling the fallback resolver. + * + * @author Christoph Strobl + * @since 4.1 + * @see EncryptionKey + */ +@FunctionalInterface +public interface EncryptionKeyResolver { + + /** + * Get the {@link EncryptionKey Data Encryption Key}. + * + * @param encryptionContext the current {@link EncryptionContext context}. + * @return never {@literal null}. + */ + EncryptionKey getKey(EncryptionContext encryptionContext); + + /** + * Obtain an {@link EncryptionKeyResolver} that evaluates {@link ExplicitEncrypted#keyAltName()} and only calls the + * fallback {@link EncryptionKeyResolver resolver} if no {@literal Key Alternate Name} is present. + * + * @param fallback must not be {@literal null}. + * @return new instance of {@link EncryptionKeyResolver}. + */ + static EncryptionKeyResolver annotated(EncryptionKeyResolver fallback) { + + Assert.notNull(fallback, "Fallback EncryptionKeyResolver must not be nul"); + + return ((encryptionContext) -> { + + MongoPersistentProperty property = encryptionContext.getProperty(); + ExplicitEncrypted annotation = property.findAnnotation(ExplicitEncrypted.class); + if (annotation == null || !StringUtils.hasText(annotation.keyAltName())) { + + Encrypted encrypted = property.getOwner().findAnnotation(Encrypted.class); + if (encrypted == null) { + return fallback.getKey(encryptionContext); + } + + Object o = EncryptionUtils.resolveKeyId(encrypted.keyId()[0], + () -> encryptionContext.getEvaluationContext(new Object())); + if (o instanceof BsonBinary binary) { + return EncryptionKey.keyId(binary); + } + if (o instanceof Binary binary) { + return EncryptionKey.keyId((BsonBinary) BsonUtils.simpleToBsonValue(binary)); + } + if (o instanceof String string) { + return EncryptionKey.keyAltName(string); + } + + throw new IllegalStateException(String.format("Cannot determine encryption key for %s.%s using key type %s", + property.getOwner().getName(), property.getName(), o == null ? "null" : o.getClass().getName())); + } + + String keyAltName = annotation.keyAltName(); + if (keyAltName.startsWith("/")) { + Object fieldValue = encryptionContext.lookupValue(keyAltName.replace("/", "")); + if (fieldValue == null) { + throw new IllegalStateException(String.format("Key Alternative Name for %s was null", keyAltName)); + } + return new KeyAltName(fieldValue.toString()); + } else { + return new KeyAltName(keyAltName); + } + }); + } +} 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 new file mode 100644 index 0000000000..e0480b6f1d --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java @@ -0,0 +1,79 @@ +/* + * Copyright 2023 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 org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Options, like the {@link #algorithm()}, to apply when encrypting values. + * + * @author Christoph Strobl + * @since 4.1 + */ +public class EncryptionOptions { + + private final String algorithm; + private final EncryptionKey key; + + public EncryptionOptions(String algorithm, EncryptionKey key) { + + Assert.hasText(algorithm, "Algorithm must not be empty"); + Assert.notNull(key, "EncryptionKey must not be empty"); + + this.key = key; + this.algorithm = algorithm; + } + + public EncryptionKey key() { + return key; + } + + public String algorithm() { + return algorithm; + } + + @Override + public boolean equals(Object o) { + + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + EncryptionOptions that = (EncryptionOptions) o; + + if (!ObjectUtils.nullSafeEquals(algorithm, that.algorithm)) { + return false; + } + return ObjectUtils.nullSafeEquals(key, that.key); + } + + @Override + public int hashCode() { + + int result = ObjectUtils.nullSafeHashCode(algorithm); + result = 31 * result + ObjectUtils.nullSafeHashCode(key); + return result; + } + + @Override + public String toString() { + return "EncryptionOptions{" + "algorithm='" + algorithm + '\'' + ", key=" + key + '}'; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyAltName.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyAltName.java new file mode 100644 index 0000000000..d6f8430148 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyAltName.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023 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 org.springframework.util.ObjectUtils; + +record KeyAltName(String value) implements EncryptionKey { + + @Override + public Type type() { + return Type.ALT; + } + + @Override + public String toString() { + + if (value().length() <= 3) { + return "KeyAltName('***')"; + } + return String.format("KeyAltName('%s***')", value.substring(0, 3)); + } + + @Override + public boolean equals(Object o) { + + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + KeyAltName that = (KeyAltName) o; + return ObjectUtils.nullSafeEquals(value, that.value); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(value); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyId.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyId.java new file mode 100644 index 0000000000..7136b72497 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyId.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 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 org.bson.BsonBinary; +import org.bson.BsonBinarySubType; +import org.springframework.util.ObjectUtils; + +record KeyId(BsonBinary value) implements EncryptionKey { + + @Override + public Type type() { + return Type.ID; + } + + @Override + public String toString() { + + if (BsonBinarySubType.isUuid(value.getType())) { + String representation = value.asUuid().toString(); + if (representation.length() > 6) { + return String.format("KeyId('%s***')", representation.substring(0, 6)); + } + } + return "KeyId('***')"; + } + + @Override + public boolean equals(Object o) { + + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + org.springframework.data.mongodb.core.encryption.KeyId that = (org.springframework.data.mongodb.core.encryption.KeyId) o; + return ObjectUtils.nullSafeEquals(value, that.value); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(value); + } +} 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 new file mode 100644 index 0000000000..dc34515fe1 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023 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 java.util.function.Supplier; + +import org.bson.BsonBinary; +import org.bson.BsonValue; +import org.springframework.data.mongodb.core.encryption.EncryptionKey.Type; +import org.springframework.util.Assert; + +import com.mongodb.client.model.vault.EncryptOptions; +import com.mongodb.client.vault.ClientEncryption; + +/** + * {@link ClientEncryption} based {@link Encryption} implementation. + * + * @author Christoph Strobl + * @since 4.1 + */ +public class MongoClientEncryption implements Encryption { + + private final Supplier source; + + MongoClientEncryption(Supplier source) { + this.source = source; + } + + /** + * Create a new {@link MongoClientEncryption} instance for the given {@link ClientEncryption}. + * + * @param clientEncryption must not be {@literal null}. + * @return new instance of {@link MongoClientEncryption}. + */ + public static MongoClientEncryption just(ClientEncryption clientEncryption) { + + Assert.notNull(clientEncryption, "ClientEncryption must not be null"); + + return new MongoClientEncryption(() -> clientEncryption); + } + + @Override + public BsonValue decrypt(BsonBinary value) { + return getClientEncryption().decrypt(value); + } + + @Override + public BsonBinary encrypt(BsonValue value, EncryptionOptions options) { + + EncryptOptions encryptOptions = new EncryptOptions(options.algorithm()); + + if (Type.ALT.equals(options.key().type())) { + encryptOptions = encryptOptions.keyAltName(options.key().value().toString()); + } else { + encryptOptions = encryptOptions.keyId((BsonBinary) options.key().value()); + } + + return getClientEncryption().encrypt(value, encryptOptions); + } + + public ClientEncryption getClientEncryption() { + return source.get(); + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/package-info.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/package-info.java new file mode 100644 index 0000000000..f3906d89dd --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/package-info.java @@ -0,0 +1,6 @@ +/** + * Infrastructure for explicit + * encryption mechanism of Client-Side Field Level Encryption. + */ +@org.springframework.lang.NonNullApi +package org.springframework.data.mongodb.core.encryption; 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 new file mode 100644 index 0000000000..3208b461b2 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023 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; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.ValueConverter; +import org.springframework.data.mongodb.core.convert.encryption.EncryptingConverter; +import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter; + +/** + * {@link ExplicitEncrypted} is a {@link ElementType#FIELD field} level {@link ValueConverter} annotation that indicates + * the target element is subject to encryption during the mapping process, in which a given domain type is converted + * into the store specific format. + *

+ * The {@link #value()} attribute, defines the bean type to look up within the + * {@link org.springframework.context.ApplicationContext} to obtain the {@link EncryptingConverter} responsible for the + * actual {@literal en-/decryption} while {@link #algorithm()} and {@link #keyAltName()} can be used to define aspects + * of the encryption process. + * + *

+ * public class Patient {
+ * 	private ObjectId id;
+ * 	private String name;
+ *
+ * 	@ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "secred-key-alternative-name") //
+ * 	private String ssn;
+ * }
+ * 
+ * + * @author Christoph Strobl + * @since 4.1 + * @see ValueConverter + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Encrypted +@ValueConverter +public @interface ExplicitEncrypted { + + /** + * Define the algorithm to use. + *

+ * A {@literal Deterministic} algorithm ensures that a given input value always encrypts to the same output while a + * {@literal randomized} one will produce different results every time. + *

+ * Please make sure to use an algorithm that is in line with MongoDB's encryption rules for simple types, complex + * objects and arrays as well as the query limitations that come with each of them. + * + * @return the string representation of the encryption algorithm to use. + * @see org.springframework.data.mongodb.core.EncryptionAlgorithms + */ + @AliasFor(annotation = Encrypted.class, value = "algorithm") + String algorithm() default ""; + + /** + * Set the {@literal Key Alternate Name} that references the {@literal Data Encryption Key} to be used. + *

+ * An empty String indicates that no alternative key name was configured. + *

+ * It is possible to use the {@literal "/"} character as a prefix to access a particular field value in the same + * domain type. In this case {@code "/name"} references the value of the {@literal name} field. Please note that + * update operations will require the full object to resolve those values. + * + * @return the {@literal Key Alternate Name} if set or an empty {@link String}. + */ + String keyAltName() default ""; + + /** + * The {@link EncryptingConverter} type handling the {@literal en-/decryption} of the annotated property. + * + * @return the configured {@link EncryptingConverter}. A {@link MongoEncryptionConverter} by default. + */ + @AliasFor(annotation = ValueConverter.class, value = "value") + Class value() default MongoEncryptionConverter.class; +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java index 1e1ebe4af8..b8d4093f75 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java @@ -38,6 +38,7 @@ import org.bson.codecs.configuration.CodecRegistry; import org.bson.conversions.Bson; import org.bson.json.JsonParseException; +import org.bson.types.Binary; import org.bson.types.ObjectId; import org.springframework.core.convert.converter.Converter; import org.springframework.data.mongodb.CodecRegistryProvider; @@ -370,6 +371,10 @@ public static BsonValue simpleToBsonValue(Object source) { return new BsonDouble((Float) source); } + if(source instanceof Binary binary) { + return new BsonBinary(binary.getType(), binary.getData()); + } + throw new IllegalArgumentException(String.format("Unable to convert %s (%s) to BsonValue.", source, source != null ? source.getClass().getName() : "null")); } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java index 809d89a6a5..a0e51a106b 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java @@ -15,6 +15,7 @@ */ package org.springframework.data.mongodb.util.encryption; +import java.util.Base64; import java.util.UUID; import java.util.function.Supplier; @@ -67,7 +68,7 @@ public static Object resolveKeyId(String value, Supplier eval new BsonBinary(UUID.fromString(potentialKeyId.toString())).getData()); } catch (IllegalArgumentException e) { - return new Binary(BsonBinarySubType.UUID_STANDARD, Base64Utils.decodeFromString(potentialKeyId.toString())); + return new Binary(BsonBinarySubType.UUID_STANDARD, Base64.getDecoder().decode(potentialKeyId.toString())); } } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java index ad6b5135ce..e211f2ce3b 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java @@ -2613,7 +2613,7 @@ void readsMapThatDoesNotComeAsDocument() { doReturn(Person.class).when(persistentProperty).getType(); doReturn(Person.class).when(persistentProperty).getRawType(); - converter.writePropertyInternal(sourceValue, accessor, persistentProperty); + converter.writePropertyInternal(sourceValue, accessor, persistentProperty, null); assertThat(accessor.getDocument()) .isEqualTo(new org.bson.Document("pName", new org.bson.Document("_id", id.toString()))); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolverUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolverUnitTests.java new file mode 100644 index 0000000000..bfe69deaf7 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolverUnitTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2023 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.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*; + +import lombok.Data; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.function.Function; + +import org.bson.BsonBinary; +import org.bson.BsonBinarySubType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.mongodb.core.mapping.Encrypted; +import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted; +import org.springframework.data.mongodb.test.util.MongoTestMappingContext; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +/** + * Unit tests for {@link EncryptionKeyResolver}. + * + * @author Christoph Strobl + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class EncryptionKeyResolverUnitTests { + + @Mock // + EncryptionKeyResolver fallbackKeyResolver; + + MongoTestMappingContext mappingContext = MongoTestMappingContext.newTestContext().init(); + + EncryptionKey defaultEncryptionKey = EncryptionKey + .keyId(new BsonBinary("super-secret".getBytes(StandardCharsets.UTF_8))); + + @BeforeEach + void beforeEach() { + when(fallbackKeyResolver.getKey(any())).thenReturn(defaultEncryptionKey); + } + + @Test // GH-4284 + void usesDefaultKeyIfNoAnnotationPresent() { + + EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class, + AnnotatedWithExplicitlyEncrypted::getNotAnnotated); + + EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isSameAs(defaultEncryptionKey); + } + + @Test // GH-4284 + void usesDefaultKeyIfAnnotatedValueIsEmpty() { + + EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class, + AnnotatedWithExplicitlyEncrypted::getAlgorithm); + + EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isSameAs(defaultEncryptionKey); + } + + @Test // GH-4284 + void usesDefaultAltKeyNameIfPresent() { + + EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class, + AnnotatedWithExplicitlyEncrypted::getAlgorithmAndAltKeyName); + + EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isEqualTo(EncryptionKey.keyAltName("sec-key-name")); + } + + @Test // GH-4284 + void readsAltKeyNameFromContextIfReferencingPropertyValue() { + + EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class, + AnnotatedWithExplicitlyEncrypted::getAlgorithmAndAltKeyNameFromPropertyValue); + when(ctx.lookupValue(eq("notAnnotated"))).thenReturn("born-to-be-wild"); + + EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isEqualTo(EncryptionKey.keyAltName("born-to-be-wild")); + } + + @Test // GH-4284 + void readsKeyIdFromEncryptedAnnotationIfNoBetterCandidateAvailable() { + + EncryptionContext ctx = prepareEncryptionContext( + AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType.class, + AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType::getKeyIdFromDomainType); + + EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isEqualTo(EncryptionKey.keyId( + new BsonBinary(BsonBinarySubType.UUID_STANDARD, Base64.getDecoder().decode("xKVup8B1Q+CkHaVRx+qa+g==")))); + } + + @Test // GH-4284 + void ignoresKeyIdFromEncryptedAnnotationWhenBetterCandidateAvailable() { + + EncryptionContext ctx = prepareEncryptionContext(KeyIdFromSpel.class, KeyIdFromSpel::getKeyIdFromDomainType); + + StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); + evaluationContext.setVariable("myKeyId", "xKVup8B1Q+CkHaVRx+qa+g=="); + + when(ctx.getEvaluationContext(any())).thenReturn(evaluationContext); + + EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isEqualTo(EncryptionKey.keyId( + new BsonBinary(BsonBinarySubType.UUID_STANDARD, Base64.getDecoder().decode("xKVup8B1Q+CkHaVRx+qa+g==")))); + } + + private EncryptionContext prepareEncryptionContext(Class type, Function property) { + + EncryptionContext encryptionContext = mock(EncryptionContext.class); + when(encryptionContext.getProperty()).thenReturn(mappingContext.getPersistentPropertyFor(type, property)); + return encryptionContext; + } + + @Data + class AnnotatedWithExplicitlyEncrypted { + + String notAnnotated; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + String algorithm; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "sec-key-name") // + String algorithmAndAltKeyName; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "/notAnnotated") // + String algorithmAndAltKeyNameFromPropertyValue; + } + + @Data + @Encrypted(keyId = "xKVup8B1Q+CkHaVRx+qa+g==") + class AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType { + + @ExplicitEncrypted // + String keyIdFromDomainType; + + @ExplicitEncrypted(keyAltName = "sec-key-name") // + String altKeyNameFromPropertyIgnoringKeyIdFromDomainType; + } + + @Data + @Encrypted(keyId = "#{#myKeyId}") + class KeyIdFromSpel { + + @ExplicitEncrypted // + String keyIdFromDomainType; + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyUnitTests.java new file mode 100644 index 0000000000..2083aa1274 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyUnitTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 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.assertj.core.api.Assertions.*; + +import java.util.UUID; + +import org.bson.BsonBinary; +import org.bson.UuidRepresentation; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link EncryptionKey}. + * + * @author Christoph Strobl + */ +class EncryptionKeyUnitTests { + + @Test // GH-4284 + void keyIdToStringDoesNotRevealEntireKey() { + + UUID uuid = UUID.randomUUID(); + + assertThat(EncryptionKey.keyId(new BsonBinary(uuid, UuidRepresentation.STANDARD)).toString()) + .contains(uuid.toString().substring(0, 6) + "***"); + } + + @Test // GH-4284 + void altKeyNameToStringDoesNotRevealEntireKey() { + + assertThat(EncryptionKey.keyAltName("s").toString()).contains("***"); + assertThat(EncryptionKey.keyAltName("su").toString()).contains("***"); + assertThat(EncryptionKey.keyAltName("sup").toString()).contains("***"); + assertThat(EncryptionKey.keyAltName("super-secret-key").toString()).contains("sup***"); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionTests.java new file mode 100644 index 0000000000..9cbe56ceeb --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionTests.java @@ -0,0 +1,576 @@ +/* + * Copyright 2023 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.assertj.core.api.Assertions.*; +import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*; +import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; +import static org.springframework.data.mongodb.core.query.Criteria.*; + +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.assertj.core.api.Assertions; +import org.bson.BsonBinary; +import org.bson.Document; +import org.bson.types.Binary; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.PermissionDeniedDataAccessException; +import org.springframework.data.convert.PropertyValueConverterFactory; +import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions.MongoConverterConfigurationAdapter; +import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter; +import org.springframework.data.mongodb.core.encryption.EncryptionTests.Config; +import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.data.util.Lazy; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +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.MongoCollection; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import com.mongodb.client.model.vault.DataKeyOptions; +import com.mongodb.client.vault.ClientEncryption; +import com.mongodb.client.vault.ClientEncryptions; + +/** + * @author Christoph Strobl + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = Config.class) +public class EncryptionTests { + + @Autowired MongoTemplate template; + + @Test // GH-4284 + void encryptAndDecryptSimpleValue() { + + Person source = new Person(); + source.id = "id-1"; + source.ssn = "mySecretSSN"; + + template.save(source); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> assertThat(it.get("ssn")).isInstanceOf(Binary.class)) // + .loadedIsEqualToSource(); + } + + @Test // GH-4284 + void encryptAndDecryptComplexValue() { + + Person source = new Person(); + source.id = "id-1"; + source.address = new Address(); + source.address.city = "NYC"; + source.address.street = "4th Ave."; + + template.save(source); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> assertThat(it.get("address")).isInstanceOf(Binary.class)) // + .loadedIsEqualToSource(); + } + + @Test // GH-4284 + void encryptAndDecryptValueWithinComplexOne() { + + Person source = new Person(); + source.id = "id-1"; + source.encryptedZip = new AddressWithEncryptedZip(); + source.encryptedZip.city = "Boston"; + source.encryptedZip.street = "central square"; + source.encryptedZip.zip = "1234567890"; + + template.save(source); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> { + assertThat(it.get("encryptedZip")).isInstanceOf(Document.class); + assertThat(it.get("encryptedZip", Document.class).get("city")).isInstanceOf(String.class); + assertThat(it.get("encryptedZip", Document.class).get("street")).isInstanceOf(String.class); + assertThat(it.get("encryptedZip", Document.class).get("zip")).isInstanceOf(Binary.class); + }) // + .loadedIsEqualToSource(); + } + + @Test // GH-4284 + void encryptAndDecryptListOfSimpleValue() { + + Person source = new Person(); + source.id = "id-1"; + source.listOfString = Arrays.asList("spring", "data", "mongodb"); + + template.save(source); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> assertThat(it.get("listOfString")).isInstanceOf(Binary.class)) // + .loadedIsEqualToSource(); + } + + @Test // GH-4284 + void encryptAndDecryptListOfComplexValue() { + + Person source = new Person(); + source.id = "id-1"; + + Address address = new Address(); + address.city = "SFO"; + address.street = "---"; + + source.listOfComplex = Collections.singletonList(address); + + template.save(source); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> assertThat(it.get("listOfComplex")).isInstanceOf(Binary.class)) // + .loadedIsEqualToSource(); + } + + @Test // GH-4284 + void encryptAndDecryptMapOfSimpleValues() { + + Person source = new Person(); + source.id = "id-1"; + source.mapOfString = Map.of("k1", "v1", "k2", "v2"); + + template.save(source); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> assertThat(it.get("mapOfString")).isInstanceOf(Binary.class)) // + .loadedIsEqualToSource(); + } + + @Test // GH-4284 + void encryptAndDecryptMapOfComplexValues() { + + Person source = new Person(); + source.id = "id-1"; + + Address address1 = new Address(); + address1.city = "SFO"; + address1.street = "---"; + + Address address2 = new Address(); + address2.city = "NYC"; + address2.street = "---"; + + source.mapOfComplex = Map.of("a1", address1, "a2", address2); + + template.save(source); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> assertThat(it.get("mapOfComplex")).isInstanceOf(Binary.class)) // + .loadedIsEqualToSource(); + } + + @Test // GH-4284 + void canQueryDeterministicallyEncrypted() { + + Person source = new Person(); + source.id = "id-1"; + source.ssn = "mySecretSSN"; + + template.save(source); + + Person loaded = template.query(Person.class).matching(where("ssn").is(source.ssn)).firstValue(); + assertThat(loaded).isEqualTo(source); + } + + @Test // GH-4284 + void cannotQueryRandomlyEncrypted() { + + Person source = new Person(); + source.id = "id-1"; + source.wallet = "secret-wallet-id"; + + template.save(source); + + Person loaded = template.query(Person.class).matching(where("wallet").is(source.wallet)).firstValue(); + assertThat(loaded).isNull(); + } + + @Test // GH-4284 + void updateSimpleTypeEncryptedFieldWithNewValue() { + + Person source = new Person(); + source.id = "id-1"; + + template.save(source); + + template.update(Person.class).matching(where("id").is(source.id)).apply(Update.update("ssn", "secret-value")) + .first(); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> assertThat(it.get("ssn")).isInstanceOf(Binary.class)) // + .loadedMatches(it -> assertThat(it.getSsn()).isEqualTo("secret-value")); + } + + @Test // GH-4284 + void updateComplexTypeEncryptedFieldWithNewValue() { + + Person source = new Person(); + source.id = "id-1"; + + template.save(source); + + Address address = new Address(); + address.city = "SFO"; + address.street = "---"; + + template.update(Person.class).matching(where("id").is(source.id)).apply(Update.update("address", address)).first(); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> assertThat(it.get("address")).isInstanceOf(Binary.class)) // + .loadedMatches(it -> assertThat(it.getAddress()).isEqualTo(address)); + } + + @Test // GH-4284 + void updateEncryptedFieldInNestedElementWithNewValue() { + + Person source = new Person(); + source.id = "id-1"; + source.encryptedZip = new AddressWithEncryptedZip(); + source.encryptedZip.city = "Boston"; + source.encryptedZip.street = "central square"; + + template.save(source); + + template.update(Person.class).matching(where("id").is(source.id)).apply(Update.update("encryptedZip.zip", "179")) + .first(); + + verifyThat(source) // + .identifiedBy(Person::getId) // + .wasSavedMatching(it -> { + assertThat(it.get("encryptedZip")).isInstanceOf(Document.class); + assertThat(it.get("encryptedZip", Document.class).get("city")).isInstanceOf(String.class); + assertThat(it.get("encryptedZip", Document.class).get("street")).isInstanceOf(String.class); + assertThat(it.get("encryptedZip", Document.class).get("zip")).isInstanceOf(Binary.class); + }) // + .loadedMatches(it -> assertThat(it.getEncryptedZip().getZip()).isEqualTo("179")); + } + + @Test + void aggregationWithMatch() { + + Person person = new Person(); + person.id = "id-1"; + person.name = "p1-name"; + person.ssn = "mySecretSSN"; + + template.save(person); + + AggregationResults aggregationResults = template.aggregateAndReturn(Person.class) + .by(newAggregation(Person.class, Aggregation.match(where("ssn").is(person.ssn)))).all(); + assertThat(aggregationResults.getMappedResults()).containsExactly(person); + } + + @Test + void altKeyDetection(@Autowired CachingMongoClientEncryption mongoClientEncryption) throws InterruptedException { + + BsonBinary user1key = mongoClientEncryption.getClientEncryption().createDataKey("local", + new DataKeyOptions().keyAltNames(Collections.singletonList("user-1"))); + + BsonBinary user2key = mongoClientEncryption.getClientEncryption().createDataKey("local", + new DataKeyOptions().keyAltNames(Collections.singletonList("user-2"))); + + Person p1 = new Person(); + p1.id = "id-1"; + p1.name = "user-1"; + p1.ssn = "ssn"; + p1.viaAltKeyNameField = "value-1"; + + Person p2 = new Person(); + p2.id = "id-2"; + p2.name = "user-2"; + p2.viaAltKeyNameField = "value-1"; + + Person p3 = new Person(); + p3.id = "id-3"; + p3.name = "user-1"; + p3.viaAltKeyNameField = "value-1"; + + template.save(p1); + template.save(p2); + template.save(p3); + + template.execute(Person.class, collection -> { + collection.find(new Document()).forEach(it -> System.out.println(it.toJson())); + return null; + }); + + // remove the key and invalidate encrypted data + mongoClientEncryption.getClientEncryption().deleteKey(user2key); + + // clear the 60 second key cache within the mongo client + mongoClientEncryption.destroy(); + + assertThat(template.query(Person.class).matching(where("id").is(p1.id)).firstValue()).isEqualTo(p1); + + assertThatExceptionOfType(PermissionDeniedDataAccessException.class) + .isThrownBy(() -> template.query(Person.class).matching(where("id").is(p2.id)).firstValue()); + } + + SaveAndLoadAssert verifyThat(T source) { + return new SaveAndLoadAssert<>(source); + } + + class SaveAndLoadAssert { + + T source; + Function idProvider; + + SaveAndLoadAssert(T source) { + this.source = source; + } + + SaveAndLoadAssert identifiedBy(Function idProvider) { + this.idProvider = idProvider; + return this; + } + + SaveAndLoadAssert wasSavedAs(Document expected) { + return wasSavedMatching(it -> Assertions.assertThat(it).isEqualTo(expected)); + } + + SaveAndLoadAssert wasSavedMatching(Consumer saved) { + EncryptionTests.this.assertSaved(source, idProvider, saved); + return this; + } + + SaveAndLoadAssert loadedMatches(Consumer expected) { + EncryptionTests.this.assertLoaded(source, idProvider, expected); + return this; + } + + SaveAndLoadAssert loadedIsEqualToSource() { + return loadedIsEqualTo(source); + } + + SaveAndLoadAssert loadedIsEqualTo(T expected) { + return loadedMatches(it -> Assertions.assertThat(it).isEqualTo(expected)); + } + + } + + void assertSaved(T source, Function idProvider, Consumer dbValue) { + + Document savedDocument = template.execute(Person.class, collection -> { + return collection.find(new Document("_id", idProvider.apply(source))).first(); + }); + dbValue.accept(savedDocument); + } + + void assertLoaded(T source, Function idProvider, Consumer loadedValue) { + + T loaded = template.query((Class) source.getClass()).matching(where("id").is(idProvider.apply(source))) + .firstValue(); + + loadedValue.accept(loaded); + } + + @Configuration + static class Config extends AbstractMongoClientConfiguration { + + @Autowired ApplicationContext applicationContext; + + @Override + protected String getDatabaseName() { + return "fle-test"; + } + + @Bean + public MongoClient mongoClient() { + return super.mongoClient(); + } + + @Override + protected void configureConverters(MongoConverterConfigurationAdapter converterConfigurationAdapter) { + + converterConfigurationAdapter + .registerPropertyValueConverterFactory(PropertyValueConverterFactory.beanFactoryAware(applicationContext)); + } + + @Bean + MongoEncryptionConverter encryptingConverter(MongoClientEncryption mongoClientEncryption) { + + Lazy dataKey = Lazy.of(() -> mongoClientEncryption.getClientEncryption().createDataKey("local", + new DataKeyOptions().keyAltNames(Collections.singletonList("mySuperSecretKey")))); + + return new MongoEncryptionConverter(mongoClientEncryption, + EncryptionKeyResolver.annotated((ctx) -> EncryptionKey.keyId(dataKey.get()))); + } + + @Bean + CachingMongoClientEncryption clientEncryption(ClientEncryptionSettings encryptionSettings) { + return new CachingMongoClientEncryption(() -> ClientEncryptions.create(encryptionSettings)); + } + + @Bean + ClientEncryptionSettings encryptionSettings(MongoClient mongoClient) { + + MongoNamespace keyVaultNamespace = new MongoNamespace("encryption.testKeyVault"); + MongoCollection keyVaultCollection = mongoClient.getDatabase(keyVaultNamespace.getDatabaseName()) + .getCollection(keyVaultNamespace.getCollectionName()); + keyVaultCollection.drop(); + // Ensure that two data keys cannot share the same keyAltName. + keyVaultCollection.createIndex(Indexes.ascending("keyAltNames"), + new IndexOptions().unique(true).partialFilterExpression(Filters.exists("keyAltNames"))); + + MongoCollection collection = mongoClient.getDatabase(getDatabaseName()).getCollection("test"); + collection.drop(); // Clear old data + + final byte[] localMasterKey = new byte[96]; + new SecureRandom().nextBytes(localMasterKey); + Map> kmsProviders = new HashMap<>() { + { + put("local", new HashMap<>() { + { + put("key", localMasterKey); + } + }); + } + }; + + // Create the ClientEncryption instance + ClientEncryptionSettings clientEncryptionSettings = ClientEncryptionSettings.builder() + .keyVaultMongoClientSettings( + MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://localhost")).build()) + .keyVaultNamespace(keyVaultNamespace.getFullName()).kmsProviders(kmsProviders).build(); + return clientEncryptionSettings; + } + + } + + static class CachingMongoClientEncryption extends MongoClientEncryption implements DisposableBean { + + static final AtomicReference cache = new AtomicReference<>(); + + CachingMongoClientEncryption(Supplier source) { + super(() -> { + + if (cache.get() != null) { + return cache.get(); + } + + ClientEncryption clientEncryption = source.get(); + cache.set(clientEncryption); + + return clientEncryption; + }); + } + + @Override + public void destroy() { + + ClientEncryption clientEncryption = cache.get(); + if (clientEncryption != null) { + clientEncryption.close(); + cache.set(null); + } + } + } + + @Data + @org.springframework.data.mongodb.core.mapping.Document("test") + static class Person { + + String id; + String name; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) // + String ssn; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "mySuperSecretKey") // + String wallet; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // full document must be random + Address address; + + AddressWithEncryptedZip encryptedZip; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random + List listOfString; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random + List

listOfComplex; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "/name") // + String viaAltKeyNameField; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + Map mapOfString; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + Map mapOfComplex; + } + + @Data + static class Address { + String city; + String street; + } + + @Getter + @Setter + static class AddressWithEncryptedZip extends Address { + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) String zip; + + @Override + public String toString() { + return "AddressWithEncryptedZip{" + "zip='" + zip + '\'' + ", city='" + getCity() + '\'' + ", street='" + + getStreet() + '\'' + '}'; + } + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryptionUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryptionUnitTests.java new file mode 100644 index 0000000000..65ec88e400 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryptionUnitTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023 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.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*; + +import java.util.function.Supplier; + +import org.bson.BsonBinary; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.mongodb.client.model.vault.EncryptOptions; +import com.mongodb.client.vault.ClientEncryption; + +/** + * Unit tests for {@link MongoClientEncryption}. + * + * @author Christoph Strobl + */ +@ExtendWith(MockitoExtension.class) +class MongoClientEncryptionUnitTests { + + @Mock // + ClientEncryption clientEncryption; + + @Test // GH-4284 + void delegatesDecrypt() { + + MongoClientEncryption mce = MongoClientEncryption.just(clientEncryption); + mce.decrypt(new BsonBinary(new byte[0])); + + verify(clientEncryption).decrypt(Mockito.any()); + } + + @Test // GH-4284 + void delegatesEncrypt() { + + MongoClientEncryption mce = MongoClientEncryption.just(clientEncryption); + mce.encrypt(new BsonBinary(new byte[0]), + new EncryptionOptions(AEAD_AES_256_CBC_HMAC_SHA_512_Random, EncryptionKey.keyAltName("sec-key-name"))); + + ArgumentCaptor options = ArgumentCaptor.forClass(EncryptOptions.class); + verify(clientEncryption).encrypt(any(), options.capture()); + assertThat(options.getValue().getAlgorithm()).isEqualTo(AEAD_AES_256_CBC_HMAC_SHA_512_Random); + assertThat(options.getValue().getKeyAltName()).isEqualTo("sec-key-name"); + } + + @Test // GH-4284 + void refreshObtainsNextInstanceFromSupplier() { + + ClientEncryption next = mock(ClientEncryption.class); + + MongoClientEncryption mce = new MongoClientEncryption(new Supplier<>() { + + int counter = 0; + + @Override + public ClientEncryption get() { + return counter++ % 2 == 0 ? clientEncryption : next; + } + }); + + assertThat(mce.getClientEncryption()).isSameAs(clientEncryption); + assertThat(mce.getClientEncryption()).isSameAs(next); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoEncryptionConverterUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoEncryptionConverterUnitTests.java new file mode 100644 index 0000000000..f94c088285 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoEncryptionConverterUnitTests.java @@ -0,0 +1,266 @@ +/* + * Copyright 2023 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.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +import org.bson.BsonArray; +import org.bson.BsonBinary; +import org.bson.BsonString; +import org.bson.BsonValue; +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.mongodb.core.convert.MongoConversionContext; +import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter; +import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.mongodb.test.util.MongoTestMappingContext; + +/** + * @author Christoph Strobl + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class MongoEncryptionConverterUnitTests { + + @Mock // + Encryption encryption; + + @Mock // + EncryptionKeyResolver fallbackKeyResolver; + + @Mock // + MongoConversionContext conversionContext; + + MongoTestMappingContext mappingContext = MongoTestMappingContext.newTestContext(); + EncryptionKeyResolver keyResolver; + MongoEncryptionConverter converter; + + @Captor ArgumentCaptor encryptionOptions; + + @Captor ArgumentCaptor valueToBeEncrypted; + + @BeforeEach + void beforeEach() { + + when(fallbackKeyResolver.getKey(any())).thenReturn(EncryptionKey.keyAltName("default")); + when(encryption.encrypt(valueToBeEncrypted.capture(), encryptionOptions.capture())) + .thenReturn(new BsonBinary(new byte[0])); + keyResolver = EncryptionKeyResolver.annotated(fallbackKeyResolver); + converter = new MongoEncryptionConverter(encryption, keyResolver); + } + + @Test // GH-4284 + void delegatesConversionOfSimpleValueWithDefaultEncryptionKeyFromKeyResolver() { + + when(conversionContext.getProperty()) + .thenReturn(mappingContext.getPersistentPropertyFor(Type.class, Type::getStringValueWithAlgorithmOnly)); + + converter.write("foo", conversionContext); + + assertThat(valueToBeEncrypted.getValue()).isEqualTo(new BsonString("foo")); + assertThat(encryptionOptions.getValue()).isEqualTo( + new EncryptionOptions(AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic, EncryptionKey.keyAltName("default"))); + } + + @Test // GH-4284 + void favorsAltKeyNameIfPresent() { + + when(conversionContext.getProperty()).thenReturn( + mappingContext.getPersistentPropertyFor(Type.class, Type::getStringValueWithAlgorithmAndAltKeyName)); + + converter.write("foo", conversionContext); + + assertThat(encryptionOptions.getValue()).isEqualTo( + new EncryptionOptions(AEAD_AES_256_CBC_HMAC_SHA_512_Random, EncryptionKey.keyAltName("sec-key-name"))); + } + + @Test // GH-4284 + void readsAltKeyNameFromProperty() { + + when(conversionContext.getProperty()).thenReturn(mappingContext.getPersistentPropertyFor(Type.class, + Type::getStringValueWithAlgorithmAndAltKeyNameFromPropertyValue)); + + ArgumentCaptor path = ArgumentCaptor.forClass(String.class); + when(conversionContext.getValue(path.capture())).thenReturn("(ツ)"); + + converter.write("foo", conversionContext); + assertThat(path.getValue()).isEqualTo("notAnnotated"); + + assertThat(encryptionOptions.getValue()) + .isEqualTo(new EncryptionOptions(AEAD_AES_256_CBC_HMAC_SHA_512_Random, EncryptionKey.keyAltName("(ツ)"))); + } + + @Test // GH-4284 + void delegatesConversionOfEntityTypes() { + + Document convertedValue = new Document("unencryptedValue", "nested-unencrypted"); + MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, + Type::getNestedFullyEncrypted); + when(conversionContext.getProperty()).thenReturn(property); + doReturn(convertedValue).when(conversionContext).write(any(), eq(property.getTypeInformation())); + + ArgumentCaptor path = ArgumentCaptor.forClass(String.class); + when(conversionContext.getValue(path.capture())).thenReturn("(ツ)"); + + JustATypeWithAnUnencryptedField source = new JustATypeWithAnUnencryptedField(); + source.unencryptedValue = "nested-unencrypted"; + + converter.write(source, conversionContext); + + assertThat(valueToBeEncrypted.getValue()).isEqualTo(convertedValue.toBsonDocument()); + } + + @Test // GH-4284 + void listsOfSimpleTypesAreConvertedEntirely() { + + MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, Type::getListOfString); + when(conversionContext.getProperty()).thenReturn(property); + + converter.write(List.of("one", "two"), conversionContext); + + assertThat(valueToBeEncrypted.getValue()) + .isEqualTo(new BsonArray(List.of(new BsonString("one"), new BsonString("two")))); + } + + @Test // GH-4284 + void listsOfComplexTypesAreConvertedEntirely() { + + Document convertedValue1 = new Document("unencryptedValue", "nested-unencrypted-1"); + Document convertedValue2 = new Document("unencryptedValue", "nested-unencrypted-2"); + + MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, Type::getListOfComplex); + when(conversionContext.getProperty()).thenReturn(property); + doReturn(convertedValue1, convertedValue2).when(conversionContext).write(any(), eq(property.getTypeInformation())); + + JustATypeWithAnUnencryptedField source1 = new JustATypeWithAnUnencryptedField(); + source1.unencryptedValue = "nested-unencrypted-1"; + + JustATypeWithAnUnencryptedField source2 = new JustATypeWithAnUnencryptedField(); + source2.unencryptedValue = "nested-unencrypted-1"; + + converter.write(List.of(source1, source2), conversionContext); + + assertThat(valueToBeEncrypted.getValue()) + .isEqualTo(new BsonArray(List.of(convertedValue1.toBsonDocument(), convertedValue2.toBsonDocument()))); + } + + @Test // GH-4284 + void simpleMapsAreConvertedEntirely() { + + MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, Type::getMapOfString); + when(conversionContext.getProperty()).thenReturn(property); + doReturn(new Document("k1", "v1").append("k2", "v2")).when(conversionContext).write(any(), + eq(property.getTypeInformation())); + + converter.write(Map.of("k1", "v1", "k2", "v2"), conversionContext); + + assertThat(valueToBeEncrypted.getValue()) + .isEqualTo(new Document("k1", new BsonString("v1")).append("k2", new BsonString("v2")).toBsonDocument()); + } + + @Test // GH-4284 + void complexMapsAreConvertedEntirely() { + + Document convertedValue1 = new Document("unencryptedValue", "nested-unencrypted-1"); + Document convertedValue2 = new Document("unencryptedValue", "nested-unencrypted-2"); + + MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, Type::getMapOfComplex); + when(conversionContext.getProperty()).thenReturn(property); + doReturn(new Document("k1", convertedValue1).append("k2", convertedValue2)).when(conversionContext).write(any(), + eq(property.getTypeInformation())); + + JustATypeWithAnUnencryptedField source1 = new JustATypeWithAnUnencryptedField(); + source1.unencryptedValue = "nested-unencrypted-1"; + + JustATypeWithAnUnencryptedField source2 = new JustATypeWithAnUnencryptedField(); + source2.unencryptedValue = "nested-unencrypted-1"; + + converter.write(Map.of("k1", source1, "k2", source2), conversionContext); + + assertThat(valueToBeEncrypted.getValue()).isEqualTo(new Document("k1", convertedValue1.toBsonDocument()) + .append("k2", convertedValue2.toBsonDocument()).toBsonDocument()); + } + + @Data + static class Type { + + String notAnnotated; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) // + String stringValueWithAlgorithmOnly; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "sec-key-name") // + String stringValueWithAlgorithmAndAltKeyName; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "/notAnnotated") // + String stringValueWithAlgorithmAndAltKeyNameFromPropertyValue; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // full document must be random + JustATypeWithAnUnencryptedField nestedFullyEncrypted; + + NestedWithEncryptedField nestedWithEncryptedField; + + // Client-Side Field Level Encryption does not support encrypting individual array elements + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + List listOfString; + + // Client-Side Field Level Encryption does not support encrypting individual array elements + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random + List listOfComplex; + + // just as it was a domain type encrypt the entire thing here + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + Map mapOfString; + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + Map mapOfComplex; + + RecordWithEncryptedValue recordWithEncryptedValue; + + List listOfRecordWithEncryptedValue; + } + + static class JustATypeWithAnUnencryptedField { + + String unencryptedValue; + } + + static class NestedWithEncryptedField extends JustATypeWithAnUnencryptedField { + + @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) // + String encryptedValue; + } + + record RecordWithEncryptedValue(@ExplicitEncrypted String value) { + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestMappingContext.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestMappingContext.java index 4c0142ece5..11e6612ff4 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestMappingContext.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestMappingContext.java @@ -16,10 +16,15 @@ package org.springframework.data.mongodb.test.util; import java.util.Collections; +import java.util.Optional; import java.util.function.Consumer; +import java.util.function.Function; import org.springframework.data.mongodb.core.convert.MongoCustomConversions; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.util.MethodInvocationRecorder; /** * @author Christoph Strobl @@ -45,6 +50,12 @@ public MongoTestMappingContext(Consumer contextConfig) contextConfig.accept(contextConfigurer); } + public MongoPersistentProperty getPersistentPropertyFor(Class type, Function property) { + + MongoPersistentEntity persistentEntity = getRequiredPersistentEntity(type); + return persistentEntity.getPersistentProperty(MethodInvocationRecorder.forProxyOf(type).record(property).getPropertyPath().get()); + } + public MongoTestMappingContext customConversions(MongoConverterConfigurer converterConfig) { this.converterConfigurer = converterConfig; @@ -75,4 +86,6 @@ public MongoTestMappingContext init() { public void afterPropertiesSet() { init(); } + + } diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 2eb7f54327..93b7896d88 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -32,6 +32,7 @@ include::{spring-data-commons-docs}/auditing.adoc[leveloffset=+1] include::reference/mongo-auditing.adoc[leveloffset=+1] include::reference/mapping.adoc[leveloffset=+1] include::reference/sharding.adoc[leveloffset=+1] +include::reference/mongo-encryption.adoc[leveloffset=+1] include::reference/kotlin.adoc[leveloffset=+1] include::reference/jmx.adoc[leveloffset=+1] diff --git a/src/main/asciidoc/reference/mongo-encryption.adoc b/src/main/asciidoc/reference/mongo-encryption.adoc new file mode 100644 index 0000000000..41c470f763 --- /dev/null +++ b/src/main/asciidoc/reference/mongo-encryption.adoc @@ -0,0 +1,170 @@ +[[mongo.encryption]] += Client Side Field Level Encryption (CSFLE) + +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. + +[NOTE] +==== +Make sure to set the drivers `com.mongodb.AutoEncryptionSettings` to use client-side encryption. +MongoDB does not support encryption for all field types. +Specific data types require deterministic encryption to preserve equality comparison functionality. +==== + +[[mongo.encryption.automatic]] +== Automatic Encryption + +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 <> that allows to perform encrypted read and write operations without the need to provide an explicit en-/decryption step. + +Please refer to the <> section for more information on defining a JSON Schema that holds encryption information. + +To make use of a the `MongoJsonSchema` it needs to be combined with `AutoEncryptionSettings` which can be done eg. via a `MongoClientSettingsBuilderCustomizer`. + +[source,java] +---- +@Bean +MongoClientSettingsBuilderCustomizer customizer(MappingContext mappingContext) { + return (builder) -> { + + // ... keyVaultCollection, kmsProvider, ... + + MongoJsonSchemaCreator schemaCreator = MongoJsonSchemaCreator.create(mappingContext); + MongoJsonSchema patientSchema = schemaCreator + .filter(MongoJsonSchemaCreator.encryptedOnly()) + .createSchemaFor(Patient.class); + + AutoEncryptionSettings autoEncryptionSettings = AutoEncryptionSettings.builder() + .keyVaultNamespace(keyVaultCollection) + .kmsProviders(kmsProviders) + .extraOptions(extraOpts) + .schemaMap(Collections.singletonMap("db.patient", patientSchema.schemaDocument().toBsonDocument())) + .build(); + + builder.autoEncryptionSettings(autoEncryptionSettings); + }; +} +---- + +[[mongo.encryption.explicit]] +== Explicit Encryption + +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 <> and a <>. +In other words, `@ExplicitEncrypted` uses existing building blocks to combine them for simplified explicit encryption support. + +[NOTE] +==== +Fields annotated with `@ExplicitEncrypted` are always encrypted as whole. +Consider the following example: + +[source,java] +---- +@ExplicitEncrypted(…) +String simpleValue; <1> + +@ExplicitEncrypted(…) +Address address; <2> + +@ExplicitEncrypted(…) +List<...> list; <3> + +@ExplicitEncrypted(…) +Map<..., ...> mapOfString; <4> +---- + +<1> Encrypts the value of the simple type such as a `String` if not `null`. +<2> Encrypts the entire `Address` object and all its nested fields as `Document`. +To only encrypt parts of the `Address`, like `Address#street` the `street` field within `Address` needs to be annotated with `@ExplicitEncrypted`. +<3> ``Collection``-like fields are encrypted as single value and not per entry. +<4> ``Map``-like fields are encrypted as single value and not as a key/value entry. +==== + +Depending on the encryption algorithm, MongoDB supports certain operations on an encrypted field using its https://www.mongodb.com/docs/manual/core/queryable-encryption/[Queryable Encryption] feature. +To pick a certain algorithm use `@ExplicitEncrypted(algorithm)`, see `EncryptionAlgorithms` for algorithm constants. +Please read the https://www.mongodb.com/docs/manual/core/csfle/fundamentals/encryption-algorithms[Encryption Types] manual for more information on algorithms and their usage. + +To perform the actual encryption we require a Data Encryption Key (DEK). +Please refer to the https://www.mongodb.com/docs/manual/core/csfle/quick-start/#create-a-data-encryption-key[MongoDB Documentation] for more information on how to set up key management and create a Data Encryption Key. +The DEK can be referenced directly via its `id` or a defined _alternative name_. +The `@EncryptedField` annotation only allows referencing a DEK via an alternative name. +It is possible to provide an `EncryptionKeyResolver`, which will be discussed later, to any DEK. + +.Reference the Data Encryption Key +==== +[source,java] +---- +@EncryptedField(algorithm=…, altKeyName = "secret-key") <1> +String ssn; +---- + +[source,java] +---- +@EncryptedField(algorithm=…, altKeyName = "/name") <2> +String ssn; +---- + +<1> Use the DEK stored with the alternative name `secret-key`. +<2> Uses a field reference that will read the actual field value and use that for key lookup. +Always requires the full document to be present for save operations. +Fields cannot be used in queries/aggregations. +==== + +By default, the `@ExplicitEncrypted(value=…)` attribute references a `MongoEncryptionConverter`. +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 <> section. + +[[mongo.encryption.explicit-setup]] +=== MongoEncryptionConverter Setup + +The converter setup for `MongoEncryptionConverter` requires a few steps as several components are involved. +The bean setup consists of the following: + +1. The `ClientEncryption` engine +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 +==== +[source,java] +---- +class Config extends AbstractMongoClientConfiguration { + + @Autowired ApplicationContext appContext; + + @Bean + ClientEncryption clientEncryption() { <1> + ClientEncryptionSettings encryptionSettings = ClientEncryptionSettings.builder(); + // … + + return ClientEncryptions.create(encryptionSettings); + } + + @Bean + MongoEncryptionConverter encryptingConverter(ClientEncryption clientEncryption) { + + Encryption encryption = MongoClientEncryption.just(clientEncryption); + EncryptionKeyResolver keyResolver = EncryptionKeyResolver.annotated((ctx) -> …); <2> + + return new MongoEncryptionConverter(encryption, keyResolver); <3> + } + + @Override + protected void configureConverters(MongoConverterConfigurationAdapter adapter) { + + adapter + .registerPropertyValueConverterFactory(PropertyValueConverterFactory.beanFactoryAware(appContext)); <4> + } +} +---- + +<1> Set up a `Encryption` engine using `com.mongodb.client.vault.ClientEncryption`. +The instance is stateful and must be closed after usage. +Spring takes care of this because `ClientEncryption` is ``Closeable``. +<2> Set up an annotation-based `EncryptionKeyResolver` to determine the `EncryptionKey` from annotations. +<3> Create the `MongoEncryptionConverter`. +<4> Enable for a `PropertyValueConverter` lookup from the `BeanFactory`. +==== diff --git a/src/main/asciidoc/reference/mongo-json-schema.adoc b/src/main/asciidoc/reference/mongo-json-schema.adoc index 26e8f7f099..369a7171dd 100644 --- a/src/main/asciidoc/reference/mongo-json-schema.adoc +++ b/src/main/asciidoc/reference/mongo-json-schema.adoc @@ -396,37 +396,8 @@ public class EncryptionExtension implements EvaluationContextExtension { } } ---- - -To combine derived encryption settings with `AutoEncryptionSettings` in a Spring Boot application use the `MongoClientSettingsBuilderCustomizer`. - -[source,java] ----- -@Bean -MongoClientSettingsBuilderCustomizer customizer(MappingContext mappingContext) { - return (builder) -> { - - // ... keyVaultCollection, kmsProvider, ... - - MongoJsonSchemaCreator schemaCreator = MongoJsonSchemaCreator.create(mappingContext); - MongoJsonSchema patientSchema = schemaCreator - .filter(MongoJsonSchemaCreator.encryptedOnly()) - .createSchemaFor(Patient.class); - - AutoEncryptionSettings autoEncryptionSettings = AutoEncryptionSettings.builder() - .keyVaultNamespace(keyVaultCollection) - .kmsProviders(kmsProviders) - .extraOptions(extraOpts) - .schemaMap(Collections.singletonMap("db.patient", patientSchema.schemaDocument().toBsonDocument())) - .build(); - - builder.autoEncryptionSettings(autoEncryptionSettings); - }; -} ----- ==== -NOTE: Make sure to set the drivers `com.mongodb.AutoEncryptionSettings` to use client-side encryption. MongoDB does not support encryption for all field types. Specific data types require deterministic encryption to preserve equality comparison functionality. - [[mongo.jsonSchema.types]] ==== JSON Schema Types