diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 9a57f7eb5..597ca94f3 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/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 08fd0c36d..f3b4e6a71 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 class MongoExceptionTranslator implements PersistenceExceptionTranslator 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 43d7d39a8..83f6db640 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 @@ -331,7 +331,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +367,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +529,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +868,9 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +887,11 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +902,13 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +1240,18 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +1857,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App final ConversionContext context; final DocumentAccessor accessor; final SpELExpressionEvaluator evaluator; + final SpELContext spELContext; /** * Creates a new {@link MongoDbPropertyValueProvider} for the given source, {@link SpELExpressionEvaluator} and @@ -1855,7 +1868,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App * @param evaluator must not be {@literal null}. */ MongoDbPropertyValueProvider(ConversionContext context, Bson source, SpELExpressionEvaluator evaluator) { - this(context, new DocumentAccessor(source), evaluator); + this(context, new DocumentAccessor(source), evaluator, null); } /** @@ -1867,7 +1880,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App * @param evaluator must not be {@literal null}. */ MongoDbPropertyValueProvider(ConversionContext context, DocumentAccessor accessor, - SpELExpressionEvaluator evaluator) { + SpELExpressionEvaluator evaluator, SpELContext spELContext) { Assert.notNull(context, "ConversionContext must no be null"); Assert.notNull(accessor, "DocumentAccessor must no be null"); @@ -1876,6 +1889,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App this.context = context; this.accessor = accessor; this.evaluator = evaluator; + this.spELContext = spELContext; } @Nullable @@ -1892,7 +1906,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +1916,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 +1939,7 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App */ 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 f4a1ea7bd..30bd4c37d 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 @@ -15,10 +15,17 @@ */ package org.springframework.data.mongodb.core.convert; +import java.util.function.Supplier; + import org.bson.conversions.Bson; import org.springframework.data.convert.ValueConversionContext; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.model.PropertyValueProvider; +import org.springframework.data.mapping.model.SpELContext; +import org.springframework.data.mapping.model.SpELExpressionEvaluator; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.util.TypeInformation; +import org.springframework.expression.EvaluationContext; import org.springframework.lang.Nullable; /** @@ -29,13 +36,23 @@ import org.springframework.lang.Nullable; */ 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, SpELContext spELContext) { + + this.accessor = accessor; this.persistentProperty = persistentProperty; this.mongoConverter = mongoConverter; + this.spELContext = spELContext; } @Override @@ -43,6 +60,10 @@ public class MongoConversionContext implements ValueConversionContext T write(@Nullable Object value, TypeInformation target) { return (T) mongoConverter.convertToMongoType(value, target); @@ -53,4 +74,9 @@ public class MongoConversionContext implements ValueConversionContext extends MongoValueConverter { + + @Override + default S read(Object value, MongoConversionContext context) { + return decrypt(value, buildEncryptionContext(context)); + } + + @Override + default T write(Object value, MongoConversionContext context) { + return encrypt(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); + + /** + * 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 000000000..729a5ccd4 --- /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 000000000..4db54a9f0 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/encryption/MongoEncryptionConverter.java @@ -0,0 +1,197 @@ +/* + * 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; + +/** + * @author Christoph Strobl + * @since 4.1 + */ +public class MongoEncryptionConverter implements EncryptingConverter { + + private static final Log LOGGER = LogFactory.getLog(MongoEncryptionConverter.class); + + private 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) { + if (!persistentProperty.isEntity()) { + Collection collection = CollectionFactory.createCollection(persistentProperty.getType(), 10); + iterable.forEach(it -> collection.add(BsonUtils.toJavaType((BsonValue) it))); + return collection; + } else { + Collection collection = CollectionFactory.createCollection(persistentProperty.getType(), 10); + 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; + } + + private MongoPersistentProperty getProperty(EncryptionContext context) { + return context.getProperty(); + } + + @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); + } + + EncryptionOptions encryptionOptions = new EncryptionOptions(annotation.algorithm()); + encryptionOptions.setKey(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); + } + + public BsonValue collectionLikeToBsonValue(Object value, MongoPersistentProperty property, + EncryptionContext context) { + + BsonArray bsonArray = new BsonArray(); + if (!property.isEntity()) { + if (value instanceof Collection values) { + values.forEach(it -> bsonArray.add(BsonUtils.simpleToBsonValue(it))); + } else if (ObjectUtils.isArray(value)) { + for (Object o : ObjectUtils.toObjectArray(value)) { + bsonArray.add(BsonUtils.simpleToBsonValue(o)); + } + } + return bsonArray; + } else { + if (value instanceof Collection values) { + values.forEach(it -> { + Document write = (Document) context.write(it, property.getTypeInformation()); + bsonArray.add(write.toBsonDocument()); + }); + } else if (ObjectUtils.isArray(value)) { + for (Object o : ObjectUtils.toObjectArray(value)) { + Document write = (Document) context.write(o, property.getTypeInformation()); + bsonArray.add(write.toBsonDocument()); + } + } + return bsonArray; + } + } + + public EncryptionContext buildEncryptionContext(MongoConversionContext context) { + return new ExplicitEncryptionContext(context); + } +} 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 000000000..70897d3cb --- /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 en-/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 000000000..053b63a1f --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionContext.java @@ -0,0 +1,129 @@ +/* + * 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.convert.ValueConversionContext; +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; + +/** + * @author Christoph Strobl + */ +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 + * {@link 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 000000000..febaa762b --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKey.java @@ -0,0 +1,157 @@ +/* + * 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; + +/** + * The {@link EncryptionKey} represents a {@literal Data Encryption Key} reference that can be either direct via the + * {@link KeyId key id} or its {@link AltKeyName Key Alternative Name}. + * + * @author Christoph Strobl + * @since 4.1 + */ +public interface EncryptionKey { + + /** + * @return the value that allows to reference a specific key + */ + Object value(); + + /** + * @return the {@link Type} of reference. + */ + Type type(); + + /** + * Create a new {@link EncryptionKey} that uses the keys id for reference. + * + * @param key must not be {@literal null}. + * @return new instance of {@link KeyId}. + */ + static KeyId keyId(BsonBinary key) { + return new KeyId(key); + } + + /** + * Create a new {@link EncryptionKey} that uses an {@literal Key Alternative Name} for reference. + * + * @param altKeyName must not be {@literal null}. + * @return new instance of {@link KeyId}. + */ + static AltKeyName altKeyName(String altKeyName) { + return new AltKeyName(altKeyName); + } + + /** + * @param value must not be {@literal null}. + */ + 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; + } + + KeyId that = (KeyId) o; + return ObjectUtils.nullSafeEquals(value, that.value); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(value); + } + } + + /** + * @param value must not be {@literal null}. + */ + record AltKeyName(String value) implements EncryptionKey { + + @Override + public Type type() { + return Type.ALT; + } + + @Override + public String toString() { + + if (value().length() <= 3) { + return "AltKeyName('***')"; + } + return String.format("AltKeyName('%s***')", value.substring(0, 3)); + } + + @Override + public boolean equals(Object o) { + + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + AltKeyName that = (AltKeyName) o; + return ObjectUtils.nullSafeEquals(value, that.value); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(value); + } + } + + /** + * 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 000000000..61deacff5 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolver.java @@ -0,0 +1,92 @@ +/* + * 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.ExplicitlyEncrypted; +import org.springframework.data.mongodb.util.BsonUtils; +import org.springframework.data.mongodb.util.encryption.EncryptionUtils; +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 #annotationBased(EncryptionKeyResolver) based} variant which will first try to resolve a potential + * {@link ExplicitlyEncrypted#altKeyName() 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 ExplicitlyEncrypted#altKeyName()} 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 annotationBased(EncryptionKeyResolver fallback) { + + return ((encryptionContext) -> { + + ExplicitlyEncrypted annotation = encryptionContext.getProperty().findAnnotation(ExplicitlyEncrypted.class); + if (annotation == null || !StringUtils.hasText(annotation.altKeyName())) { + + Encrypted encrypted = encryptionContext.getProperty().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.altKeyName(string); + } + } + + String altKeyName = annotation.altKeyName(); + if (altKeyName.startsWith("/")) { + Object fieldValue = encryptionContext.lookupValue(altKeyName.replace("/", "")); + if (fieldValue == null) { + throw new IllegalStateException(String.format("Key Alternative Name for %s was null", altKeyName)); + } + return new EncryptionKey.AltKeyName(fieldValue.toString()); + } else { + return new EncryptionKey.AltKeyName(altKeyName); + } + }); + } +} 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 000000000..37805ecec --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java @@ -0,0 +1,80 @@ +/* + * 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.lang.Nullable; +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 @Nullable EncryptionKey key; + + public EncryptionOptions(String algorithm) { + this.algorithm = algorithm; + } + + public EncryptionOptions setKey(EncryptionKey key) { + + this.key = key; + return this; + } + + public EncryptionKey key() { + return key; + } + + public String algorithm() { + return algorithm; + } + + @Override + public String toString() { + return "EncryptionOptions{" + "algorithm='" + algorithm + '\'' + ", key=" + key + '}'; + } + + @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; + } +} 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 000000000..6f1f5a2bd --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java @@ -0,0 +1,113 @@ +/* + * 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.concurrent.atomic.AtomicReference; +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; + private final AtomicReference cached; + + private MongoClientEncryption(Supplier source) { + + this.source = source; + this.cached = new AtomicReference<>(source.get()); + } + + /** + * The caching {@link MongoClientEncryption} variant caches and reuses the {@link ClientEncryption} obtained from the + * {@link Supplier} until explicitly {@link #refresh() refreshed}. + * + * @param clientEncryption must not be {@literal null} nor emit {@literal null}. + * @return new instance of {@link MongoClientEncryption}. + */ + public static MongoClientEncryption caching(Supplier clientEncryption) { + return new MongoClientEncryption(clientEncryption); + } + + /** + * 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 boolean refresh() { + return false; + } + }; + } + + /** + * @return {@literal true} if refreshed, {@literal false} otherwise. + */ + public boolean refresh() { + cached.set(source.get()); + return true; + } + + /** + * {@link ClientEncryption#close() Shutdown} the underlying {@link ClientEncryption}. + */ + public void shutdown() { + getClientEncryption().close(); + } + + @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 cached.get(); + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitlyEncrypted.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitlyEncrypted.java new file mode 100644 index 000000000..f64d1ed14 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitlyEncrypted.java @@ -0,0 +1,93 @@ +/* + * 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 ExplicitlyEncrypted} 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 #altKeyName()} can be used to define aspects + * of the encryption process. + * + *

+ * public class Patient {
+ * 	private ObjectId id;
+ * 	private String name;
+ *
+ * 	@ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, altKeyName = "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 ExplicitlyEncrypted { + + /** + * 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. + */ + @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 altKeyName() 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 1e1ebe4af..b8d4093f7 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.DocumentCodec; 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 class BsonUtils { 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 809d89a6a..a0e51a106 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 final class EncryptionUtils { 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 ad6b5135c..e211f2ce3 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 @@ class MappingMongoConverterUnitTests { 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 000000000..926dfda36 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolverUnitTests.java @@ -0,0 +1,176 @@ +/* + * 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.ExplicitlyEncrypted; +import org.springframework.data.mongodb.test.util.MongoTestMappingContext; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +/** + * @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.annotationBased(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isSameAs(defaultEncryptionKey); + } + + @Test // GH-4284 + void usesDefaultKeyIfAnnotatedValueIsEmpty() { + + EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class, + AnnotatedWithExplicitlyEncrypted::getAlgorithm); + + EncryptionKey key = EncryptionKeyResolver.annotationBased(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isSameAs(defaultEncryptionKey); + } + + @Test // GH-4284 + void usesDefaultAltKeyNameIfPresent() { + + EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class, + AnnotatedWithExplicitlyEncrypted::getAlgorithmAndAltKeyName); + + EncryptionKey key = EncryptionKeyResolver.annotationBased(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isEqualTo(EncryptionKey.altKeyName("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.annotationBased(fallbackKeyResolver).getKey(ctx); + + assertThat(key).isEqualTo(EncryptionKey.altKeyName("born-to-be-wild")); + } + + @Test // GH-4284 + void readsKeyIdFromEncryptedAnnotationIfNoBetterCandidateAvailable() { + + EncryptionContext ctx = prepareEncryptionContext( + AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType.class, + AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType::getKeyIdFromDomainType); + + EncryptionKey key = EncryptionKeyResolver.annotationBased(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.annotationBased(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; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + String algorithm; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, altKeyName = "sec-key-name") // + String algorithmAndAltKeyName; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, altKeyName = "/notAnnotated") // + String algorithmAndAltKeyNameFromPropertyValue; + } + + @Data + @Encrypted(keyId = "xKVup8B1Q+CkHaVRx+qa+g==") + class AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType { + + @ExplicitlyEncrypted // + String keyIdFromDomainType; + + @ExplicitlyEncrypted(altKeyName = "sec-key-name") // + String altKeyNameFromPropertyIgnoringKeyIdFromDomainType; + } + + @Data + @Encrypted(keyId = "#{#myKeyId}") + class KeyIdFromSpel { + + @ExplicitlyEncrypted // + 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 000000000..1098f4c2b --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyUnitTests.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** + * @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.altKeyName("s").toString()).contains("***"); + assertThat(EncryptionKey.altKeyName("su").toString()).contains("***"); + assertThat(EncryptionKey.altKeyName("sup").toString()).contains("***"); + assertThat(EncryptionKey.altKeyName("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 000000000..c39c76818 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionTests.java @@ -0,0 +1,542 @@ +/* + * 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.function.Consumer; +import java.util.function.Function; + +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.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.ExplicitlyEncrypted; +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.ClientEncryptions; + +/** + * @author Christoph Strobl + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = Config.class) +public class EncryptionTests { + + @Autowired MongoTemplate template; + + @Test // GH-4284 + void enDeCryptSimpleValue() { + + 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 enDeCryptComplexValue() { + + 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 enDeCryptValueWithinComplexOne() { + + 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 enDeCryptListOfSimpleValue() { + + 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 enDeCryptListOfComplexValue() { + + 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 enDeCryptMapOfSimpleValues() { + + 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 enDeCryptMapOfComplexValues() { + + 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 MongoClientEncryption 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.refresh(); + + 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.annotationBased((ctx) -> EncryptionKey.keyId(dataKey.get()))); + } + + @Bean + MongoClientEncryption clientEncryption(ClientEncryptionSettings encryptionSettings) { + return MongoClientEncryption.caching(() -> 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; + } + } + + @Data + @org.springframework.data.mongodb.core.mapping.Document("test") + static class Person { + + String id; + String name; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) // + String ssn; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, altKeyName = "mySuperSecretKey") // + String wallet; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // full document must be random + Address address; + + AddressWithEncryptedZip encryptedZip; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random + List listOfString; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random + List

listOfComplex; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, altKeyName = "/name") // + String viaAltKeyNameField; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + Map mapOfString; + + @ExplicitlyEncrypted(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 { + + @ExplicitlyEncrypted(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 000000000..4b087ef42 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryptionUnitTests.java @@ -0,0 +1,96 @@ +/* + * 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; + +/** + * @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).setKey(EncryptionKey.altKeyName("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 refreshHasNoEffectForFixedClientEncryption() { + + MongoClientEncryption mce = MongoClientEncryption.just(clientEncryption); + mce.decrypt(new BsonBinary(new byte[0])); + + assertThat(mce.getClientEncryption()).isSameAs(clientEncryption); + assertThat(mce.refresh()).isFalse(); + assertThat(mce.getClientEncryption()).isSameAs(clientEncryption); + } + + @Test // GH-4284 + void refreshObtainsNextInstanceFromSupplier() { + + ClientEncryption next = mock(ClientEncryption.class); + + MongoClientEncryption mce = MongoClientEncryption.caching(new Supplier<>() { + + int counter = 0; + + @Override + public ClientEncryption get() { + return counter++ % 2 == 0 ? clientEncryption : next; + } + }); + + assertThat(mce.getClientEncryption()).isSameAs(clientEncryption); + assertThat(mce.refresh()).isTrue(); + 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 000000000..7d405065e --- /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.ExplicitlyEncrypted; +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.altKeyName("default")); + when(encryption.encrypt(valueToBeEncrypted.capture(), encryptionOptions.capture())) + .thenReturn(new BsonBinary(new byte[0])); + keyResolver = EncryptionKeyResolver.annotationBased(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).setKey(EncryptionKey.altKeyName("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).setKey(EncryptionKey.altKeyName("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).setKey(EncryptionKey.altKeyName("(ツ)"))); + } + + @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; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) // + String stringValueWithAlgorithmOnly; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, altKeyName = "sec-key-name") // + String stringValueWithAlgorithmAndAltKeyName; + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, altKeyName = "/notAnnotated") // + String stringValueWithAlgorithmAndAltKeyNameFromPropertyValue; + + @ExplicitlyEncrypted(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 + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + List listOfString; + + // Client-Side Field Level Encryption does not support encrypting individual array elements + @ExplicitlyEncrypted(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 + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // + Map mapOfString; + + @ExplicitlyEncrypted(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 { + + @ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) // + String encryptedValue; + } + + record RecordWithEncryptedValue(@ExplicitlyEncrypted 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 4c0142ece..11e6612ff 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 class MongoTestMappingContext extends MongoMappingContext { 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 class MongoTestMappingContext extends MongoMappingContext { public void afterPropertiesSet() { init(); } + + } diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 2eb7f5432..93b7896d8 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 000000000..80d7fc1d7 --- /dev/null +++ b/src/main/asciidoc/reference/mongo-encryption.adoc @@ -0,0 +1,156 @@ +[[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. +Please make sure to read the https://www.mongodb.com/docs/manual/core/csfle/[MongoDB Documentation] to learn more about its capabilities and restrictions. + +[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 en-/decryption tasks. +The `@ExplicitlyEncrypted` annotation is a combination of the `@Encrypted` annotation used for <> and a <>. +In other words, `@ExplicitlyEncrypted` uses existing building blocks and combines them to provide simplified support for explicit encryption. + +[NOTE] +==== +Fields annotated with `@ExplicitlyEncrypted` are always encrypted entirely as outlined in below. + +[source,java] +---- +@ExplicitlyEncrypted(...) +String simpleValue; <1> + +@ExplicitlyEncrypted(...) +Address address; <2> + +@ExplicitlyEncrypted(...) +List<...> list; <3> + +@ExplicitlyEncrypted(...) +Map<..., ...> mapOfString; <3> +---- +<1> Encrypts the value of the simple type eg. a `String` if not `null`. +<2> Encrypts the entire `Address` object and all its nested fields. To only encrypt parts of the `Address`, like `Address#street` the `street` field needs to be annotated. +<3> `Collection` like fields are encrypted entirely and not a value by value basis. +<4> `Map` like fields are encrypted entirely and not on a key/value basis. +==== + +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 `@ExplicitlyEncrypted(algorithm = ... )` and choose the required one via `EncryptionAlgorithms`. +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 do also need 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 `@ExplicitlyEncrypted(value=...)` attribute will reference 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 default `MongoEncryptionConverter` needs to be registered within the `ApplicationContext`. +To do so we need to 1st setup the `Bean` and 2nd use a `BeanFactoryAwarePropertyValueConverterFactory` in the converter configuration. +The converter itself needs to know about the actual `Encryption` that is capable of en-/decrypting `BsonValue` to/from `BsonBinary` as well as a `EncryptionKeyResolver`. +`MongoClientEncryption` is the default implementation delegating en-/decryption to `com.mongodb.client.vault.ClientEncryption`. +The `EncryptionKeyResolver` provides the DEK to be used for encrypting the field. +Since the `@ExplicitlyEncrypted` annotation does not need to specify an alt key name the `EncryptionKeyResolver` receives the current `EncryptionContext` that provides access to the field for dynamic DEK resolution. +`EncryptionKeyResolver.annotationBased(...)` offers an implementation that will lookup values from the `@ExplicitlyEncrypted` annotation before falling back to the context based resolution. + +.Sample MongoEncryptionConverter Configuration +==== +[source,java] +---- +class Config extends AbstractMongoClientConfiguration { + + // ... + + @Autowired ApplicationContext appContext; + + @Bean + MongoEncryptionConverter encryptingConverter() { + + ClientEncryptionSettings encryptionSettings = ClientEncryptionSettings.builder() + // ... + + Encryption encryption = MongoClientEncryption.just(ClientEncryptions.create(encryptionSettings)) <1> + EncryptionKeyResolver keyResolver = EncryptionKeyResolver.annotationBased((ctx) -> ...); <2> + + return new MongoEncryptionConverter(encryption, keyResolver); <3> + } + + @Override + protected void configureConverters(MongoConverterConfigurationAdapter adapter) { + + adapter + .registerPropertyValueConverterFactory(PropertyValueConverterFactory.beanFactoryAware(appContext)); <4> + } +} +---- +<1> Set up a `com.mongodb.client.vault.ClientEncryption` specific `Encryption` engine. +<2> Read the `EncryptionKey` from annotations on the field. +<3> Create the `MongoEncryptionConverter`. +<4> Enable for a `PropertyValueConverter` within the `BeanFactory`. +==== diff --git a/src/main/asciidoc/reference/mongo-json-schema.adoc b/src/main/asciidoc/reference/mongo-json-schema.adoc index 26e8f7f09..369a7171d 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