Add support for explicit field encryption.

We now support explicit field encryption using mapped entities through the `@ExplicitEncrypted` annotation.

class Person {
  ObjectId id;

  @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic, altKeyName = "my-secret-key")
  String socialSecurityNumber;
}

Encryption is applied transparently to all mapped entities leveraging the existing converter infrastructure.

Original pull request: #4302
Closes: #4284
This commit is contained in:
Christoph Strobl
2022-11-11 09:09:05 +01:00
committed by Mark Paluch
parent 3b7b1ace8b
commit 3b33f90e5c
27 changed files with 2414 additions and 48 deletions

View File

@@ -112,6 +112,13 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-crypt</artifactId>
<version>1.6.1</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>

View File

@@ -68,6 +68,8 @@ public class MongoExceptionTranslator implements PersistenceExceptionTranslator
private static final Set<String> DATA_INTEGRITY_EXCEPTIONS = new HashSet<>(
Arrays.asList("WriteConcernException", "MongoWriteException", "MongoBulkWriteException"));
private static final Set<String> 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);

View File

@@ -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<MongoPersistentProperty> 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<MongoPersistentProperty>() {
@Nullable
@Override
public <T> 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<MongoPersistentProperty>() {
@Nullable
@Override
public <T> 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

View File

@@ -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<MongoPersistentProperty> {
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<MongoPersi
return persistentProperty;
}
public Object getValue(String propertyPath) {
return accessor.getPropertyValue(persistentProperty.getOwner().getRequiredPersistentProperty(propertyPath));
}
@Override
public <T> T write(@Nullable Object value, TypeInformation<T> target) {
return (T) mongoConverter.convertToMongoType(value, target);
@@ -53,4 +74,9 @@ public class MongoConversionContext implements ValueConversionContext<MongoPersi
return value instanceof Bson ? mongoConverter.read(target.getType(), (Bson) value)
: ValueConversionContext.super.read(value, target);
}
@Nullable
public SpELContext getSpELContext() {
return spELContext;
}
}

View File

@@ -439,7 +439,7 @@ public class QueryMapper {
&& converter.getCustomConversions().hasValueConverter(documentField.getProperty())) {
return converter.getCustomConversions().getPropertyValueConversions()
.getValueConverter(documentField.getProperty())
.write(value, new MongoConversionContext(documentField.getProperty(), converter));
.write(value, new MongoConversionContext(null, documentField.getProperty(), converter));
}
if (documentField.isIdField() && !documentField.isAssociation()) {

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.mongodb.core.convert.encryption;
import org.springframework.data.mongodb.core.convert.MongoConversionContext;
import org.springframework.data.mongodb.core.convert.MongoValueConverter;
import org.springframework.data.mongodb.core.encryption.EncryptionContext;
/**
* A specialized {@link MongoValueConverter} for {@literal en-/decrypting} properties.
*
* @author Christoph Strobl
* @since 4.1
*/
public interface EncryptingConverter<S, T> extends MongoValueConverter<S, T> {
@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);
}

View File

@@ -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> T read(@Nullable Object value, TypeInformation<T> target) {
return conversionContext.read(value, target);
}
@Override
public <T> T write(@Nullable Object value, TypeInformation<T> target) {
return conversionContext.write(value, target);
}
}

View File

@@ -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<Object, Object> {
private static final Log LOGGER = LogFactory.getLog(MongoEncryptionConverter.class);
private Encryption<BsonValue, BsonBinary> encryption;
private final EncryptionKeyResolver keyResolver;
public MongoEncryptionConverter(Encryption<BsonValue, BsonBinary> 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<Object> collection = CollectionFactory.createCollection(persistentProperty.getType(), 10);
iterable.forEach(it -> collection.add(BsonUtils.toJavaType((BsonValue) it)));
return collection;
} else {
Collection<Object> 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);
}
}

View File

@@ -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<S, T> {
/**
* 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);
}

View File

@@ -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> 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> T read(@Nullable Object value, Class<T> 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> T read(@Nullable Object value, TypeInformation<T> 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> 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> T write(@Nullable Object value, Class<T> 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> T write(@Nullable Object value, TypeInformation<T> 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);
}

View File

@@ -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
}
}

View File

@@ -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}.
* <p>
* 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);
}
});
}
}

View File

@@ -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;
}
}

View File

@@ -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<BsonValue, BsonBinary> {
private final Supplier<ClientEncryption> source;
private final AtomicReference<ClientEncryption> cached;
private MongoClientEncryption(Supplier<ClientEncryption> 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> 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();
}
}

View File

@@ -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.
* <p>
* 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.
*
* <pre class="code">
* public class Patient {
* private ObjectId id;
* private String name;
*
* &#64;ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, altKeyName = "secred-key-alternative-name") //
* private String ssn;
* }
* </pre>
*
* @author Christoph Strobl
* @since 4.1
* @see ValueConverter
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Encrypted
@ValueConverter
public @interface ExplicitlyEncrypted {
/**
* Define the algorithm to use.
* <p>
* 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.
* <p>
* 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.
* <p>
* An empty String indicates that no alternative key name was configured.
* <p>
* 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<? extends PropertyValueConverter> value() default MongoEncryptionConverter.class;
}

View File

@@ -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"));
}

View File

@@ -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()));
}
}
}

View File

@@ -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())));

View File

@@ -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 <T> EncryptionContext prepareEncryptionContext(Class<T> type, Function<T, ?> 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;
}
}

View File

@@ -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***");
}
}

View File

@@ -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<Person> 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());
}
<T> SaveAndLoadAssert<T> verifyThat(T source) {
return new SaveAndLoadAssert<>(source);
}
class SaveAndLoadAssert<T> {
T source;
Function<T, ?> idProvider;
SaveAndLoadAssert(T source) {
this.source = source;
}
SaveAndLoadAssert<T> identifiedBy(Function<T, ?> idProvider) {
this.idProvider = idProvider;
return this;
}
SaveAndLoadAssert<T> wasSavedAs(Document expected) {
return wasSavedMatching(it -> Assertions.assertThat(it).isEqualTo(expected));
}
SaveAndLoadAssert<T> wasSavedMatching(Consumer<Document> saved) {
EncryptionTests.this.assertSaved(source, idProvider, saved);
return this;
}
SaveAndLoadAssert<T> loadedMatches(Consumer<T> expected) {
EncryptionTests.this.assertLoaded(source, idProvider, expected);
return this;
}
SaveAndLoadAssert<T> loadedIsEqualToSource() {
return loadedIsEqualTo(source);
}
SaveAndLoadAssert<T> loadedIsEqualTo(T expected) {
return loadedMatches(it -> Assertions.assertThat(it).isEqualTo(expected));
}
}
<T> void assertSaved(T source, Function<T, ?> idProvider, Consumer<Document> dbValue) {
Document savedDocument = template.execute(Person.class, collection -> {
return collection.find(new Document("_id", idProvider.apply(source))).first();
});
dbValue.accept(savedDocument);
}
<T> void assertLoaded(T source, Function<T, ?> idProvider, Consumer<T> loadedValue) {
T loaded = template.query((Class<T>) 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<BsonBinary> 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<Document> 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<Document> collection = mongoClient.getDatabase(getDatabaseName()).getCollection("test");
collection.drop(); // Clear old data
final byte[] localMasterKey = new byte[96];
new SecureRandom().nextBytes(localMasterKey);
Map<String, Map<String, Object>> kmsProviders = new HashMap<String, Map<String, Object>>() {
{
put("local", new HashMap<String, Object>() {
{
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<String> listOfString;
@ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random
List<Address> 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<String, String> mapOfString;
@ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
Map<String, Address> 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() + '\'' + '}';
}
}
}

View File

@@ -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<EncryptOptions> 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);
}
}

View File

@@ -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<BsonValue, BsonBinary> encryption;
@Mock //
EncryptionKeyResolver fallbackKeyResolver;
@Mock //
MongoConversionContext conversionContext;
MongoTestMappingContext mappingContext = MongoTestMappingContext.newTestContext();
EncryptionKeyResolver keyResolver;
MongoEncryptionConverter converter;
@Captor ArgumentCaptor<EncryptionOptions> encryptionOptions;
@Captor ArgumentCaptor<BsonValue> 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<String> 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<String> 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<String> 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<JustATypeWithAnUnencryptedField> listOfComplex;
// just as it was a domain type encrypt the entire thing here
@ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
Map<String, String> mapOfString;
@ExplicitlyEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
Map<String, JustATypeWithAnUnencryptedField> mapOfComplex;
RecordWithEncryptedValue recordWithEncryptedValue;
List<RecordWithEncryptedValue> 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) {
}
}

View File

@@ -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 <T> MongoPersistentProperty getPersistentPropertyFor(Class<T> type, Function<T, ?> 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();
}
}

View File

@@ -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]

View File

@@ -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 <<mongo.jsonSchema,JSON Schema>> that allows to perform encrypted read and write operations without the need to provide an explicit en-/decryption step.
Please refer to the <<mongo.jsonSchema.encrypted-fields,JSON Schema>> 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 <<mongo.jsonSchema.encrypted-fields,JSON Schema creation>> and a <<mongo.property-converters, Property Converter>>.
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 <<mongo.property-converters>> 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<BsonValue, BsonBinary> 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`.
====

View File

@@ -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