target);
+
+ /**
+ * Lookup the value for a given path within the current context.
+ *
+ * @param path the path/property name to resolve the current value for.
+ * @return can be {@literal null}.
+ */
+ @Nullable
+ Object lookupValue(String path);
+
+ EvaluationContext getEvaluationContext(Object source);
+
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKey.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKey.java
new file mode 100644
index 0000000000..f4eebd43ed
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKey.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import org.bson.BsonBinary;
+import org.springframework.util.Assert;
+
+/**
+ * The {@link EncryptionKey} represents a {@literal Data Encryption Key} reference that can be either direct via the
+ * {@link KeyId key id} or its {@link KeyAltName Key Alternative Name}.
+ *
+ * @author Christoph Strobl
+ * @since 4.1
+ */
+public interface EncryptionKey {
+
+ /**
+ * Create a new {@link EncryptionKey} that uses the keys id for reference.
+ *
+ * @param key must not be {@literal null}.
+ * @return new instance of {@link EncryptionKey KeyId}.
+ */
+ static EncryptionKey keyId(BsonBinary key) {
+
+ Assert.notNull(key, "KeyId must not be null");
+
+ return new KeyId(key);
+ }
+
+ /**
+ * Create a new {@link EncryptionKey} that uses an {@literal Key Alternative Name} for reference.
+ *
+ * @param keyAltName must not be {@literal null} or empty.
+ * @return new instance of {@link EncryptionKey KeyAltName}.
+ */
+ static EncryptionKey keyAltName(String keyAltName) {
+
+ Assert.hasText(keyAltName, "Key Alternative Name must not be empty");
+
+ return new KeyAltName(keyAltName);
+ }
+
+ /**
+ * @return the value that allows to reference a specific key.
+ */
+ Object value();
+
+ /**
+ * @return the {@link Type} of reference.
+ */
+ Type type();
+
+ /**
+ * The key reference type.
+ */
+ enum Type {
+
+ /**
+ * Key referenced via its {@literal id}.
+ */
+ ID,
+
+ /**
+ * Key referenced via an {@literal Key Alternative Name}.
+ */
+ ALT
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolver.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolver.java
new file mode 100644
index 0000000000..c9bc2b07ec
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolver.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import org.bson.BsonBinary;
+import org.bson.types.Binary;
+import org.springframework.data.mongodb.core.mapping.Encrypted;
+import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted;
+import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
+import org.springframework.data.mongodb.util.BsonUtils;
+import org.springframework.data.mongodb.util.encryption.EncryptionUtils;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+
+/**
+ * Interface to obtain a {@link EncryptionKey Data Encryption Key} that is valid in a given {@link EncryptionContext
+ * context}.
+ *
+ * Use the {@link #annotated(EncryptionKeyResolver) based} variant which will first try to resolve a potential
+ * {@link ExplicitEncrypted#keyAltName() Key Alternate Name} from annotations before calling the fallback resolver.
+ *
+ * @author Christoph Strobl
+ * @since 4.1
+ * @see EncryptionKey
+ */
+@FunctionalInterface
+public interface EncryptionKeyResolver {
+
+ /**
+ * Get the {@link EncryptionKey Data Encryption Key}.
+ *
+ * @param encryptionContext the current {@link EncryptionContext context}.
+ * @return never {@literal null}.
+ */
+ EncryptionKey getKey(EncryptionContext encryptionContext);
+
+ /**
+ * Obtain an {@link EncryptionKeyResolver} that evaluates {@link ExplicitEncrypted#keyAltName()} and only calls the
+ * fallback {@link EncryptionKeyResolver resolver} if no {@literal Key Alternate Name} is present.
+ *
+ * @param fallback must not be {@literal null}.
+ * @return new instance of {@link EncryptionKeyResolver}.
+ */
+ static EncryptionKeyResolver annotated(EncryptionKeyResolver fallback) {
+
+ Assert.notNull(fallback, "Fallback EncryptionKeyResolver must not be nul");
+
+ return ((encryptionContext) -> {
+
+ MongoPersistentProperty property = encryptionContext.getProperty();
+ ExplicitEncrypted annotation = property.findAnnotation(ExplicitEncrypted.class);
+ if (annotation == null || !StringUtils.hasText(annotation.keyAltName())) {
+
+ Encrypted encrypted = property.getOwner().findAnnotation(Encrypted.class);
+ if (encrypted == null) {
+ return fallback.getKey(encryptionContext);
+ }
+
+ Object o = EncryptionUtils.resolveKeyId(encrypted.keyId()[0],
+ () -> encryptionContext.getEvaluationContext(new Object()));
+ if (o instanceof BsonBinary binary) {
+ return EncryptionKey.keyId(binary);
+ }
+ if (o instanceof Binary binary) {
+ return EncryptionKey.keyId((BsonBinary) BsonUtils.simpleToBsonValue(binary));
+ }
+ if (o instanceof String string) {
+ return EncryptionKey.keyAltName(string);
+ }
+
+ throw new IllegalStateException(String.format("Cannot determine encryption key for %s.%s using key type %s",
+ property.getOwner().getName(), property.getName(), o == null ? "null" : o.getClass().getName()));
+ }
+
+ String keyAltName = annotation.keyAltName();
+ if (keyAltName.startsWith("/")) {
+ Object fieldValue = encryptionContext.lookupValue(keyAltName.replace("/", ""));
+ if (fieldValue == null) {
+ throw new IllegalStateException(String.format("Key Alternative Name for %s was null", keyAltName));
+ }
+ return new KeyAltName(fieldValue.toString());
+ } else {
+ return new KeyAltName(keyAltName);
+ }
+ });
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java
new file mode 100644
index 0000000000..e0480b6f1d
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/EncryptionOptions.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import org.springframework.util.Assert;
+import org.springframework.util.ObjectUtils;
+
+/**
+ * Options, like the {@link #algorithm()}, to apply when encrypting values.
+ *
+ * @author Christoph Strobl
+ * @since 4.1
+ */
+public class EncryptionOptions {
+
+ private final String algorithm;
+ private final EncryptionKey key;
+
+ public EncryptionOptions(String algorithm, EncryptionKey key) {
+
+ Assert.hasText(algorithm, "Algorithm must not be empty");
+ Assert.notNull(key, "EncryptionKey must not be empty");
+
+ this.key = key;
+ this.algorithm = algorithm;
+ }
+
+ public EncryptionKey key() {
+ return key;
+ }
+
+ public String algorithm() {
+ return algorithm;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ EncryptionOptions that = (EncryptionOptions) o;
+
+ if (!ObjectUtils.nullSafeEquals(algorithm, that.algorithm)) {
+ return false;
+ }
+ return ObjectUtils.nullSafeEquals(key, that.key);
+ }
+
+ @Override
+ public int hashCode() {
+
+ int result = ObjectUtils.nullSafeHashCode(algorithm);
+ result = 31 * result + ObjectUtils.nullSafeHashCode(key);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "EncryptionOptions{" + "algorithm='" + algorithm + '\'' + ", key=" + key + '}';
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyAltName.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyAltName.java
new file mode 100644
index 0000000000..d6f8430148
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyAltName.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import org.springframework.util.ObjectUtils;
+
+record KeyAltName(String value) implements EncryptionKey {
+
+ @Override
+ public Type type() {
+ return Type.ALT;
+ }
+
+ @Override
+ public String toString() {
+
+ if (value().length() <= 3) {
+ return "KeyAltName('***')";
+ }
+ return String.format("KeyAltName('%s***')", value.substring(0, 3));
+ }
+
+ @Override
+ public boolean equals(Object o) {
+
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ KeyAltName that = (KeyAltName) o;
+ return ObjectUtils.nullSafeEquals(value, that.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return ObjectUtils.nullSafeHashCode(value);
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyId.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyId.java
new file mode 100644
index 0000000000..7136b72497
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/KeyId.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import org.bson.BsonBinary;
+import org.bson.BsonBinarySubType;
+import org.springframework.util.ObjectUtils;
+
+record KeyId(BsonBinary value) implements EncryptionKey {
+
+ @Override
+ public Type type() {
+ return Type.ID;
+ }
+
+ @Override
+ public String toString() {
+
+ if (BsonBinarySubType.isUuid(value.getType())) {
+ String representation = value.asUuid().toString();
+ if (representation.length() > 6) {
+ return String.format("KeyId('%s***')", representation.substring(0, 6));
+ }
+ }
+ return "KeyId('***')";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ org.springframework.data.mongodb.core.encryption.KeyId that = (org.springframework.data.mongodb.core.encryption.KeyId) o;
+ return ObjectUtils.nullSafeEquals(value, that.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return ObjectUtils.nullSafeHashCode(value);
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java
new file mode 100644
index 0000000000..dc34515fe1
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryption.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import java.util.function.Supplier;
+
+import org.bson.BsonBinary;
+import org.bson.BsonValue;
+import org.springframework.data.mongodb.core.encryption.EncryptionKey.Type;
+import org.springframework.util.Assert;
+
+import com.mongodb.client.model.vault.EncryptOptions;
+import com.mongodb.client.vault.ClientEncryption;
+
+/**
+ * {@link ClientEncryption} based {@link Encryption} implementation.
+ *
+ * @author Christoph Strobl
+ * @since 4.1
+ */
+public class MongoClientEncryption implements Encryption {
+
+ private final Supplier source;
+
+ MongoClientEncryption(Supplier source) {
+ this.source = source;
+ }
+
+ /**
+ * Create a new {@link MongoClientEncryption} instance for the given {@link ClientEncryption}.
+ *
+ * @param clientEncryption must not be {@literal null}.
+ * @return new instance of {@link MongoClientEncryption}.
+ */
+ public static MongoClientEncryption just(ClientEncryption clientEncryption) {
+
+ Assert.notNull(clientEncryption, "ClientEncryption must not be null");
+
+ return new MongoClientEncryption(() -> clientEncryption);
+ }
+
+ @Override
+ public BsonValue decrypt(BsonBinary value) {
+ return getClientEncryption().decrypt(value);
+ }
+
+ @Override
+ public BsonBinary encrypt(BsonValue value, EncryptionOptions options) {
+
+ EncryptOptions encryptOptions = new EncryptOptions(options.algorithm());
+
+ if (Type.ALT.equals(options.key().type())) {
+ encryptOptions = encryptOptions.keyAltName(options.key().value().toString());
+ } else {
+ encryptOptions = encryptOptions.keyId((BsonBinary) options.key().value());
+ }
+
+ return getClientEncryption().encrypt(value, encryptOptions);
+ }
+
+ public ClientEncryption getClientEncryption() {
+ return source.get();
+ }
+
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/package-info.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/package-info.java
new file mode 100644
index 0000000000..f3906d89dd
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/encryption/package-info.java
@@ -0,0 +1,6 @@
+/**
+ * Infrastructure for explicit
+ * encryption mechanism of Client-Side Field Level Encryption.
+ */
+@org.springframework.lang.NonNullApi
+package org.springframework.data.mongodb.core.encryption;
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java
new file mode 100644
index 0000000000..3208b461b2
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/ExplicitEncrypted.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.mapping;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.core.annotation.AliasFor;
+import org.springframework.data.convert.PropertyValueConverter;
+import org.springframework.data.convert.ValueConverter;
+import org.springframework.data.mongodb.core.convert.encryption.EncryptingConverter;
+import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter;
+
+/**
+ * {@link ExplicitEncrypted} is a {@link ElementType#FIELD field} level {@link ValueConverter} annotation that indicates
+ * the target element is subject to encryption during the mapping process, in which a given domain type is converted
+ * into the store specific format.
+ *
+ * The {@link #value()} attribute, defines the bean type to look up within the
+ * {@link org.springframework.context.ApplicationContext} to obtain the {@link EncryptingConverter} responsible for the
+ * actual {@literal en-/decryption} while {@link #algorithm()} and {@link #keyAltName()} can be used to define aspects
+ * of the encryption process.
+ *
+ *
+ * public class Patient {
+ * private ObjectId id;
+ * private String name;
+ *
+ * @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "secred-key-alternative-name") //
+ * private String ssn;
+ * }
+ *
+ *
+ * @author Christoph Strobl
+ * @since 4.1
+ * @see ValueConverter
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+@Encrypted
+@ValueConverter
+public @interface ExplicitEncrypted {
+
+ /**
+ * Define the algorithm to use.
+ *
+ * A {@literal Deterministic} algorithm ensures that a given input value always encrypts to the same output while a
+ * {@literal randomized} one will produce different results every time.
+ *
+ * Please make sure to use an algorithm that is in line with MongoDB's encryption rules for simple types, complex
+ * objects and arrays as well as the query limitations that come with each of them.
+ *
+ * @return the string representation of the encryption algorithm to use.
+ * @see org.springframework.data.mongodb.core.EncryptionAlgorithms
+ */
+ @AliasFor(annotation = Encrypted.class, value = "algorithm")
+ String algorithm() default "";
+
+ /**
+ * Set the {@literal Key Alternate Name} that references the {@literal Data Encryption Key} to be used.
+ *
+ * An empty String indicates that no alternative key name was configured.
+ *
+ * It is possible to use the {@literal "/"} character as a prefix to access a particular field value in the same
+ * domain type. In this case {@code "/name"} references the value of the {@literal name} field. Please note that
+ * update operations will require the full object to resolve those values.
+ *
+ * @return the {@literal Key Alternate Name} if set or an empty {@link String}.
+ */
+ String keyAltName() default "";
+
+ /**
+ * The {@link EncryptingConverter} type handling the {@literal en-/decryption} of the annotated property.
+ *
+ * @return the configured {@link EncryptingConverter}. A {@link MongoEncryptionConverter} by default.
+ */
+ @AliasFor(annotation = ValueConverter.class, value = "value")
+ Class extends PropertyValueConverter> value() default MongoEncryptionConverter.class;
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java
index 1e1ebe4af8..b8d4093f75 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java
@@ -38,6 +38,7 @@
import org.bson.codecs.configuration.CodecRegistry;
import org.bson.conversions.Bson;
import org.bson.json.JsonParseException;
+import org.bson.types.Binary;
import org.bson.types.ObjectId;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.mongodb.CodecRegistryProvider;
@@ -370,6 +371,10 @@ public static BsonValue simpleToBsonValue(Object source) {
return new BsonDouble((Float) source);
}
+ if(source instanceof Binary binary) {
+ return new BsonBinary(binary.getType(), binary.getData());
+ }
+
throw new IllegalArgumentException(String.format("Unable to convert %s (%s) to BsonValue.", source,
source != null ? source.getClass().getName() : "null"));
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java
index 809d89a6a5..a0e51a106b 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java
@@ -15,6 +15,7 @@
*/
package org.springframework.data.mongodb.util.encryption;
+import java.util.Base64;
import java.util.UUID;
import java.util.function.Supplier;
@@ -67,7 +68,7 @@ public static Object resolveKeyId(String value, Supplier eval
new BsonBinary(UUID.fromString(potentialKeyId.toString())).getData());
} catch (IllegalArgumentException e) {
- return new Binary(BsonBinarySubType.UUID_STANDARD, Base64Utils.decodeFromString(potentialKeyId.toString()));
+ return new Binary(BsonBinarySubType.UUID_STANDARD, Base64.getDecoder().decode(potentialKeyId.toString()));
}
}
}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java
index ad6b5135ce..e211f2ce3b 100644
--- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java
@@ -2613,7 +2613,7 @@ void readsMapThatDoesNotComeAsDocument() {
doReturn(Person.class).when(persistentProperty).getType();
doReturn(Person.class).when(persistentProperty).getRawType();
- converter.writePropertyInternal(sourceValue, accessor, persistentProperty);
+ converter.writePropertyInternal(sourceValue, accessor, persistentProperty, null);
assertThat(accessor.getDocument())
.isEqualTo(new org.bson.Document("pName", new org.bson.Document("_id", id.toString())));
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolverUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolverUnitTests.java
new file mode 100644
index 0000000000..bfe69deaf7
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyResolverUnitTests.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*;
+
+import lombok.Data;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.function.Function;
+
+import org.bson.BsonBinary;
+import org.bson.BsonBinarySubType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.mongodb.core.mapping.Encrypted;
+import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted;
+import org.springframework.data.mongodb.test.util.MongoTestMappingContext;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+
+/**
+ * Unit tests for {@link EncryptionKeyResolver}.
+ *
+ * @author Christoph Strobl
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class EncryptionKeyResolverUnitTests {
+
+ @Mock //
+ EncryptionKeyResolver fallbackKeyResolver;
+
+ MongoTestMappingContext mappingContext = MongoTestMappingContext.newTestContext().init();
+
+ EncryptionKey defaultEncryptionKey = EncryptionKey
+ .keyId(new BsonBinary("super-secret".getBytes(StandardCharsets.UTF_8)));
+
+ @BeforeEach
+ void beforeEach() {
+ when(fallbackKeyResolver.getKey(any())).thenReturn(defaultEncryptionKey);
+ }
+
+ @Test // GH-4284
+ void usesDefaultKeyIfNoAnnotationPresent() {
+
+ EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class,
+ AnnotatedWithExplicitlyEncrypted::getNotAnnotated);
+
+ EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx);
+
+ assertThat(key).isSameAs(defaultEncryptionKey);
+ }
+
+ @Test // GH-4284
+ void usesDefaultKeyIfAnnotatedValueIsEmpty() {
+
+ EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class,
+ AnnotatedWithExplicitlyEncrypted::getAlgorithm);
+
+ EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx);
+
+ assertThat(key).isSameAs(defaultEncryptionKey);
+ }
+
+ @Test // GH-4284
+ void usesDefaultAltKeyNameIfPresent() {
+
+ EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class,
+ AnnotatedWithExplicitlyEncrypted::getAlgorithmAndAltKeyName);
+
+ EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx);
+
+ assertThat(key).isEqualTo(EncryptionKey.keyAltName("sec-key-name"));
+ }
+
+ @Test // GH-4284
+ void readsAltKeyNameFromContextIfReferencingPropertyValue() {
+
+ EncryptionContext ctx = prepareEncryptionContext(AnnotatedWithExplicitlyEncrypted.class,
+ AnnotatedWithExplicitlyEncrypted::getAlgorithmAndAltKeyNameFromPropertyValue);
+ when(ctx.lookupValue(eq("notAnnotated"))).thenReturn("born-to-be-wild");
+
+ EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx);
+
+ assertThat(key).isEqualTo(EncryptionKey.keyAltName("born-to-be-wild"));
+ }
+
+ @Test // GH-4284
+ void readsKeyIdFromEncryptedAnnotationIfNoBetterCandidateAvailable() {
+
+ EncryptionContext ctx = prepareEncryptionContext(
+ AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType.class,
+ AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType::getKeyIdFromDomainType);
+
+ EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx);
+
+ assertThat(key).isEqualTo(EncryptionKey.keyId(
+ new BsonBinary(BsonBinarySubType.UUID_STANDARD, Base64.getDecoder().decode("xKVup8B1Q+CkHaVRx+qa+g=="))));
+ }
+
+ @Test // GH-4284
+ void ignoresKeyIdFromEncryptedAnnotationWhenBetterCandidateAvailable() {
+
+ EncryptionContext ctx = prepareEncryptionContext(KeyIdFromSpel.class, KeyIdFromSpel::getKeyIdFromDomainType);
+
+ StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
+ evaluationContext.setVariable("myKeyId", "xKVup8B1Q+CkHaVRx+qa+g==");
+
+ when(ctx.getEvaluationContext(any())).thenReturn(evaluationContext);
+
+ EncryptionKey key = EncryptionKeyResolver.annotated(fallbackKeyResolver).getKey(ctx);
+
+ assertThat(key).isEqualTo(EncryptionKey.keyId(
+ new BsonBinary(BsonBinarySubType.UUID_STANDARD, Base64.getDecoder().decode("xKVup8B1Q+CkHaVRx+qa+g=="))));
+ }
+
+ private EncryptionContext prepareEncryptionContext(Class type, Function property) {
+
+ EncryptionContext encryptionContext = mock(EncryptionContext.class);
+ when(encryptionContext.getProperty()).thenReturn(mappingContext.getPersistentPropertyFor(type, property));
+ return encryptionContext;
+ }
+
+ @Data
+ class AnnotatedWithExplicitlyEncrypted {
+
+ String notAnnotated;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
+ String algorithm;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "sec-key-name") //
+ String algorithmAndAltKeyName;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "/notAnnotated") //
+ String algorithmAndAltKeyNameFromPropertyValue;
+ }
+
+ @Data
+ @Encrypted(keyId = "xKVup8B1Q+CkHaVRx+qa+g==")
+ class AnnotatedWithExplicitlyEncryptedHavingDefaultAlgorithmServedViaAnnotationOnType {
+
+ @ExplicitEncrypted //
+ String keyIdFromDomainType;
+
+ @ExplicitEncrypted(keyAltName = "sec-key-name") //
+ String altKeyNameFromPropertyIgnoringKeyIdFromDomainType;
+ }
+
+ @Data
+ @Encrypted(keyId = "#{#myKeyId}")
+ class KeyIdFromSpel {
+
+ @ExplicitEncrypted //
+ String keyIdFromDomainType;
+ }
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyUnitTests.java
new file mode 100644
index 0000000000..2083aa1274
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionKeyUnitTests.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.UUID;
+
+import org.bson.BsonBinary;
+import org.bson.UuidRepresentation;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link EncryptionKey}.
+ *
+ * @author Christoph Strobl
+ */
+class EncryptionKeyUnitTests {
+
+ @Test // GH-4284
+ void keyIdToStringDoesNotRevealEntireKey() {
+
+ UUID uuid = UUID.randomUUID();
+
+ assertThat(EncryptionKey.keyId(new BsonBinary(uuid, UuidRepresentation.STANDARD)).toString())
+ .contains(uuid.toString().substring(0, 6) + "***");
+ }
+
+ @Test // GH-4284
+ void altKeyNameToStringDoesNotRevealEntireKey() {
+
+ assertThat(EncryptionKey.keyAltName("s").toString()).contains("***");
+ assertThat(EncryptionKey.keyAltName("su").toString()).contains("***");
+ assertThat(EncryptionKey.keyAltName("sup").toString()).contains("***");
+ assertThat(EncryptionKey.keyAltName("super-secret-key").toString()).contains("sup***");
+ }
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionTests.java
new file mode 100644
index 0000000000..9cbe56ceeb
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/EncryptionTests.java
@@ -0,0 +1,576 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*;
+import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
+import static org.springframework.data.mongodb.core.query.Criteria.*;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.assertj.core.api.Assertions;
+import org.bson.BsonBinary;
+import org.bson.Document;
+import org.bson.types.Binary;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.dao.PermissionDeniedDataAccessException;
+import org.springframework.data.convert.PropertyValueConverterFactory;
+import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.aggregation.Aggregation;
+import org.springframework.data.mongodb.core.aggregation.AggregationResults;
+import org.springframework.data.mongodb.core.convert.MongoCustomConversions.MongoConverterConfigurationAdapter;
+import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter;
+import org.springframework.data.mongodb.core.encryption.EncryptionTests.Config;
+import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.data.util.Lazy;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import com.mongodb.ClientEncryptionSettings;
+import com.mongodb.ConnectionString;
+import com.mongodb.MongoClientSettings;
+import com.mongodb.MongoNamespace;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.model.Filters;
+import com.mongodb.client.model.IndexOptions;
+import com.mongodb.client.model.Indexes;
+import com.mongodb.client.model.vault.DataKeyOptions;
+import com.mongodb.client.vault.ClientEncryption;
+import com.mongodb.client.vault.ClientEncryptions;
+
+/**
+ * @author Christoph Strobl
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = Config.class)
+public class EncryptionTests {
+
+ @Autowired MongoTemplate template;
+
+ @Test // GH-4284
+ void encryptAndDecryptSimpleValue() {
+
+ Person source = new Person();
+ source.id = "id-1";
+ source.ssn = "mySecretSSN";
+
+ template.save(source);
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> assertThat(it.get("ssn")).isInstanceOf(Binary.class)) //
+ .loadedIsEqualToSource();
+ }
+
+ @Test // GH-4284
+ void encryptAndDecryptComplexValue() {
+
+ Person source = new Person();
+ source.id = "id-1";
+ source.address = new Address();
+ source.address.city = "NYC";
+ source.address.street = "4th Ave.";
+
+ template.save(source);
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> assertThat(it.get("address")).isInstanceOf(Binary.class)) //
+ .loadedIsEqualToSource();
+ }
+
+ @Test // GH-4284
+ void encryptAndDecryptValueWithinComplexOne() {
+
+ Person source = new Person();
+ source.id = "id-1";
+ source.encryptedZip = new AddressWithEncryptedZip();
+ source.encryptedZip.city = "Boston";
+ source.encryptedZip.street = "central square";
+ source.encryptedZip.zip = "1234567890";
+
+ template.save(source);
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> {
+ assertThat(it.get("encryptedZip")).isInstanceOf(Document.class);
+ assertThat(it.get("encryptedZip", Document.class).get("city")).isInstanceOf(String.class);
+ assertThat(it.get("encryptedZip", Document.class).get("street")).isInstanceOf(String.class);
+ assertThat(it.get("encryptedZip", Document.class).get("zip")).isInstanceOf(Binary.class);
+ }) //
+ .loadedIsEqualToSource();
+ }
+
+ @Test // GH-4284
+ void encryptAndDecryptListOfSimpleValue() {
+
+ Person source = new Person();
+ source.id = "id-1";
+ source.listOfString = Arrays.asList("spring", "data", "mongodb");
+
+ template.save(source);
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> assertThat(it.get("listOfString")).isInstanceOf(Binary.class)) //
+ .loadedIsEqualToSource();
+ }
+
+ @Test // GH-4284
+ void encryptAndDecryptListOfComplexValue() {
+
+ Person source = new Person();
+ source.id = "id-1";
+
+ Address address = new Address();
+ address.city = "SFO";
+ address.street = "---";
+
+ source.listOfComplex = Collections.singletonList(address);
+
+ template.save(source);
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> assertThat(it.get("listOfComplex")).isInstanceOf(Binary.class)) //
+ .loadedIsEqualToSource();
+ }
+
+ @Test // GH-4284
+ void encryptAndDecryptMapOfSimpleValues() {
+
+ Person source = new Person();
+ source.id = "id-1";
+ source.mapOfString = Map.of("k1", "v1", "k2", "v2");
+
+ template.save(source);
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> assertThat(it.get("mapOfString")).isInstanceOf(Binary.class)) //
+ .loadedIsEqualToSource();
+ }
+
+ @Test // GH-4284
+ void encryptAndDecryptMapOfComplexValues() {
+
+ Person source = new Person();
+ source.id = "id-1";
+
+ Address address1 = new Address();
+ address1.city = "SFO";
+ address1.street = "---";
+
+ Address address2 = new Address();
+ address2.city = "NYC";
+ address2.street = "---";
+
+ source.mapOfComplex = Map.of("a1", address1, "a2", address2);
+
+ template.save(source);
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> assertThat(it.get("mapOfComplex")).isInstanceOf(Binary.class)) //
+ .loadedIsEqualToSource();
+ }
+
+ @Test // GH-4284
+ void canQueryDeterministicallyEncrypted() {
+
+ Person source = new Person();
+ source.id = "id-1";
+ source.ssn = "mySecretSSN";
+
+ template.save(source);
+
+ Person loaded = template.query(Person.class).matching(where("ssn").is(source.ssn)).firstValue();
+ assertThat(loaded).isEqualTo(source);
+ }
+
+ @Test // GH-4284
+ void cannotQueryRandomlyEncrypted() {
+
+ Person source = new Person();
+ source.id = "id-1";
+ source.wallet = "secret-wallet-id";
+
+ template.save(source);
+
+ Person loaded = template.query(Person.class).matching(where("wallet").is(source.wallet)).firstValue();
+ assertThat(loaded).isNull();
+ }
+
+ @Test // GH-4284
+ void updateSimpleTypeEncryptedFieldWithNewValue() {
+
+ Person source = new Person();
+ source.id = "id-1";
+
+ template.save(source);
+
+ template.update(Person.class).matching(where("id").is(source.id)).apply(Update.update("ssn", "secret-value"))
+ .first();
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> assertThat(it.get("ssn")).isInstanceOf(Binary.class)) //
+ .loadedMatches(it -> assertThat(it.getSsn()).isEqualTo("secret-value"));
+ }
+
+ @Test // GH-4284
+ void updateComplexTypeEncryptedFieldWithNewValue() {
+
+ Person source = new Person();
+ source.id = "id-1";
+
+ template.save(source);
+
+ Address address = new Address();
+ address.city = "SFO";
+ address.street = "---";
+
+ template.update(Person.class).matching(where("id").is(source.id)).apply(Update.update("address", address)).first();
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> assertThat(it.get("address")).isInstanceOf(Binary.class)) //
+ .loadedMatches(it -> assertThat(it.getAddress()).isEqualTo(address));
+ }
+
+ @Test // GH-4284
+ void updateEncryptedFieldInNestedElementWithNewValue() {
+
+ Person source = new Person();
+ source.id = "id-1";
+ source.encryptedZip = new AddressWithEncryptedZip();
+ source.encryptedZip.city = "Boston";
+ source.encryptedZip.street = "central square";
+
+ template.save(source);
+
+ template.update(Person.class).matching(where("id").is(source.id)).apply(Update.update("encryptedZip.zip", "179"))
+ .first();
+
+ verifyThat(source) //
+ .identifiedBy(Person::getId) //
+ .wasSavedMatching(it -> {
+ assertThat(it.get("encryptedZip")).isInstanceOf(Document.class);
+ assertThat(it.get("encryptedZip", Document.class).get("city")).isInstanceOf(String.class);
+ assertThat(it.get("encryptedZip", Document.class).get("street")).isInstanceOf(String.class);
+ assertThat(it.get("encryptedZip", Document.class).get("zip")).isInstanceOf(Binary.class);
+ }) //
+ .loadedMatches(it -> assertThat(it.getEncryptedZip().getZip()).isEqualTo("179"));
+ }
+
+ @Test
+ void aggregationWithMatch() {
+
+ Person person = new Person();
+ person.id = "id-1";
+ person.name = "p1-name";
+ person.ssn = "mySecretSSN";
+
+ template.save(person);
+
+ AggregationResults aggregationResults = template.aggregateAndReturn(Person.class)
+ .by(newAggregation(Person.class, Aggregation.match(where("ssn").is(person.ssn)))).all();
+ assertThat(aggregationResults.getMappedResults()).containsExactly(person);
+ }
+
+ @Test
+ void altKeyDetection(@Autowired CachingMongoClientEncryption mongoClientEncryption) throws InterruptedException {
+
+ BsonBinary user1key = mongoClientEncryption.getClientEncryption().createDataKey("local",
+ new DataKeyOptions().keyAltNames(Collections.singletonList("user-1")));
+
+ BsonBinary user2key = mongoClientEncryption.getClientEncryption().createDataKey("local",
+ new DataKeyOptions().keyAltNames(Collections.singletonList("user-2")));
+
+ Person p1 = new Person();
+ p1.id = "id-1";
+ p1.name = "user-1";
+ p1.ssn = "ssn";
+ p1.viaAltKeyNameField = "value-1";
+
+ Person p2 = new Person();
+ p2.id = "id-2";
+ p2.name = "user-2";
+ p2.viaAltKeyNameField = "value-1";
+
+ Person p3 = new Person();
+ p3.id = "id-3";
+ p3.name = "user-1";
+ p3.viaAltKeyNameField = "value-1";
+
+ template.save(p1);
+ template.save(p2);
+ template.save(p3);
+
+ template.execute(Person.class, collection -> {
+ collection.find(new Document()).forEach(it -> System.out.println(it.toJson()));
+ return null;
+ });
+
+ // remove the key and invalidate encrypted data
+ mongoClientEncryption.getClientEncryption().deleteKey(user2key);
+
+ // clear the 60 second key cache within the mongo client
+ mongoClientEncryption.destroy();
+
+ assertThat(template.query(Person.class).matching(where("id").is(p1.id)).firstValue()).isEqualTo(p1);
+
+ assertThatExceptionOfType(PermissionDeniedDataAccessException.class)
+ .isThrownBy(() -> template.query(Person.class).matching(where("id").is(p2.id)).firstValue());
+ }
+
+ SaveAndLoadAssert verifyThat(T source) {
+ return new SaveAndLoadAssert<>(source);
+ }
+
+ class SaveAndLoadAssert {
+
+ T source;
+ Function idProvider;
+
+ SaveAndLoadAssert(T source) {
+ this.source = source;
+ }
+
+ SaveAndLoadAssert identifiedBy(Function idProvider) {
+ this.idProvider = idProvider;
+ return this;
+ }
+
+ SaveAndLoadAssert wasSavedAs(Document expected) {
+ return wasSavedMatching(it -> Assertions.assertThat(it).isEqualTo(expected));
+ }
+
+ SaveAndLoadAssert wasSavedMatching(Consumer saved) {
+ EncryptionTests.this.assertSaved(source, idProvider, saved);
+ return this;
+ }
+
+ SaveAndLoadAssert loadedMatches(Consumer expected) {
+ EncryptionTests.this.assertLoaded(source, idProvider, expected);
+ return this;
+ }
+
+ SaveAndLoadAssert loadedIsEqualToSource() {
+ return loadedIsEqualTo(source);
+ }
+
+ SaveAndLoadAssert loadedIsEqualTo(T expected) {
+ return loadedMatches(it -> Assertions.assertThat(it).isEqualTo(expected));
+ }
+
+ }
+
+ void assertSaved(T source, Function idProvider, Consumer dbValue) {
+
+ Document savedDocument = template.execute(Person.class, collection -> {
+ return collection.find(new Document("_id", idProvider.apply(source))).first();
+ });
+ dbValue.accept(savedDocument);
+ }
+
+ void assertLoaded(T source, Function idProvider, Consumer loadedValue) {
+
+ T loaded = template.query((Class) source.getClass()).matching(where("id").is(idProvider.apply(source)))
+ .firstValue();
+
+ loadedValue.accept(loaded);
+ }
+
+ @Configuration
+ static class Config extends AbstractMongoClientConfiguration {
+
+ @Autowired ApplicationContext applicationContext;
+
+ @Override
+ protected String getDatabaseName() {
+ return "fle-test";
+ }
+
+ @Bean
+ public MongoClient mongoClient() {
+ return super.mongoClient();
+ }
+
+ @Override
+ protected void configureConverters(MongoConverterConfigurationAdapter converterConfigurationAdapter) {
+
+ converterConfigurationAdapter
+ .registerPropertyValueConverterFactory(PropertyValueConverterFactory.beanFactoryAware(applicationContext));
+ }
+
+ @Bean
+ MongoEncryptionConverter encryptingConverter(MongoClientEncryption mongoClientEncryption) {
+
+ Lazy dataKey = Lazy.of(() -> mongoClientEncryption.getClientEncryption().createDataKey("local",
+ new DataKeyOptions().keyAltNames(Collections.singletonList("mySuperSecretKey"))));
+
+ return new MongoEncryptionConverter(mongoClientEncryption,
+ EncryptionKeyResolver.annotated((ctx) -> EncryptionKey.keyId(dataKey.get())));
+ }
+
+ @Bean
+ CachingMongoClientEncryption clientEncryption(ClientEncryptionSettings encryptionSettings) {
+ return new CachingMongoClientEncryption(() -> ClientEncryptions.create(encryptionSettings));
+ }
+
+ @Bean
+ ClientEncryptionSettings encryptionSettings(MongoClient mongoClient) {
+
+ MongoNamespace keyVaultNamespace = new MongoNamespace("encryption.testKeyVault");
+ MongoCollection keyVaultCollection = mongoClient.getDatabase(keyVaultNamespace.getDatabaseName())
+ .getCollection(keyVaultNamespace.getCollectionName());
+ keyVaultCollection.drop();
+ // Ensure that two data keys cannot share the same keyAltName.
+ keyVaultCollection.createIndex(Indexes.ascending("keyAltNames"),
+ new IndexOptions().unique(true).partialFilterExpression(Filters.exists("keyAltNames")));
+
+ MongoCollection collection = mongoClient.getDatabase(getDatabaseName()).getCollection("test");
+ collection.drop(); // Clear old data
+
+ final byte[] localMasterKey = new byte[96];
+ new SecureRandom().nextBytes(localMasterKey);
+ Map> kmsProviders = new HashMap<>() {
+ {
+ put("local", new HashMap<>() {
+ {
+ put("key", localMasterKey);
+ }
+ });
+ }
+ };
+
+ // Create the ClientEncryption instance
+ ClientEncryptionSettings clientEncryptionSettings = ClientEncryptionSettings.builder()
+ .keyVaultMongoClientSettings(
+ MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://localhost")).build())
+ .keyVaultNamespace(keyVaultNamespace.getFullName()).kmsProviders(kmsProviders).build();
+ return clientEncryptionSettings;
+ }
+
+ }
+
+ static class CachingMongoClientEncryption extends MongoClientEncryption implements DisposableBean {
+
+ static final AtomicReference cache = new AtomicReference<>();
+
+ CachingMongoClientEncryption(Supplier source) {
+ super(() -> {
+
+ if (cache.get() != null) {
+ return cache.get();
+ }
+
+ ClientEncryption clientEncryption = source.get();
+ cache.set(clientEncryption);
+
+ return clientEncryption;
+ });
+ }
+
+ @Override
+ public void destroy() {
+
+ ClientEncryption clientEncryption = cache.get();
+ if (clientEncryption != null) {
+ clientEncryption.close();
+ cache.set(null);
+ }
+ }
+ }
+
+ @Data
+ @org.springframework.data.mongodb.core.mapping.Document("test")
+ static class Person {
+
+ String id;
+ String name;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) //
+ String ssn;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "mySuperSecretKey") //
+ String wallet;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // full document must be random
+ Address address;
+
+ AddressWithEncryptedZip encryptedZip;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random
+ List listOfString;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random
+ List listOfComplex;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "/name") //
+ String viaAltKeyNameField;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
+ Map mapOfString;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
+ Map mapOfComplex;
+ }
+
+ @Data
+ static class Address {
+ String city;
+ String street;
+ }
+
+ @Getter
+ @Setter
+ static class AddressWithEncryptedZip extends Address {
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) String zip;
+
+ @Override
+ public String toString() {
+ return "AddressWithEncryptedZip{" + "zip='" + zip + '\'' + ", city='" + getCity() + '\'' + ", street='"
+ + getStreet() + '\'' + '}';
+ }
+ }
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryptionUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryptionUnitTests.java
new file mode 100644
index 0000000000..65ec88e400
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoClientEncryptionUnitTests.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*;
+
+import java.util.function.Supplier;
+
+import org.bson.BsonBinary;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.mongodb.client.model.vault.EncryptOptions;
+import com.mongodb.client.vault.ClientEncryption;
+
+/**
+ * Unit tests for {@link MongoClientEncryption}.
+ *
+ * @author Christoph Strobl
+ */
+@ExtendWith(MockitoExtension.class)
+class MongoClientEncryptionUnitTests {
+
+ @Mock //
+ ClientEncryption clientEncryption;
+
+ @Test // GH-4284
+ void delegatesDecrypt() {
+
+ MongoClientEncryption mce = MongoClientEncryption.just(clientEncryption);
+ mce.decrypt(new BsonBinary(new byte[0]));
+
+ verify(clientEncryption).decrypt(Mockito.any());
+ }
+
+ @Test // GH-4284
+ void delegatesEncrypt() {
+
+ MongoClientEncryption mce = MongoClientEncryption.just(clientEncryption);
+ mce.encrypt(new BsonBinary(new byte[0]),
+ new EncryptionOptions(AEAD_AES_256_CBC_HMAC_SHA_512_Random, EncryptionKey.keyAltName("sec-key-name")));
+
+ ArgumentCaptor options = ArgumentCaptor.forClass(EncryptOptions.class);
+ verify(clientEncryption).encrypt(any(), options.capture());
+ assertThat(options.getValue().getAlgorithm()).isEqualTo(AEAD_AES_256_CBC_HMAC_SHA_512_Random);
+ assertThat(options.getValue().getKeyAltName()).isEqualTo("sec-key-name");
+ }
+
+ @Test // GH-4284
+ void refreshObtainsNextInstanceFromSupplier() {
+
+ ClientEncryption next = mock(ClientEncryption.class);
+
+ MongoClientEncryption mce = new MongoClientEncryption(new Supplier<>() {
+
+ int counter = 0;
+
+ @Override
+ public ClientEncryption get() {
+ return counter++ % 2 == 0 ? clientEncryption : next;
+ }
+ });
+
+ assertThat(mce.getClientEncryption()).isSameAs(clientEncryption);
+ assertThat(mce.getClientEncryption()).isSameAs(next);
+ }
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoEncryptionConverterUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoEncryptionConverterUnitTests.java
new file mode 100644
index 0000000000..f94c088285
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoEncryptionConverterUnitTests.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core.encryption;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+import org.bson.BsonArray;
+import org.bson.BsonBinary;
+import org.bson.BsonString;
+import org.bson.BsonValue;
+import org.bson.Document;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.mongodb.core.convert.MongoConversionContext;
+import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter;
+import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted;
+import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
+import org.springframework.data.mongodb.test.util.MongoTestMappingContext;
+
+/**
+ * @author Christoph Strobl
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class MongoEncryptionConverterUnitTests {
+
+ @Mock //
+ Encryption encryption;
+
+ @Mock //
+ EncryptionKeyResolver fallbackKeyResolver;
+
+ @Mock //
+ MongoConversionContext conversionContext;
+
+ MongoTestMappingContext mappingContext = MongoTestMappingContext.newTestContext();
+ EncryptionKeyResolver keyResolver;
+ MongoEncryptionConverter converter;
+
+ @Captor ArgumentCaptor encryptionOptions;
+
+ @Captor ArgumentCaptor valueToBeEncrypted;
+
+ @BeforeEach
+ void beforeEach() {
+
+ when(fallbackKeyResolver.getKey(any())).thenReturn(EncryptionKey.keyAltName("default"));
+ when(encryption.encrypt(valueToBeEncrypted.capture(), encryptionOptions.capture()))
+ .thenReturn(new BsonBinary(new byte[0]));
+ keyResolver = EncryptionKeyResolver.annotated(fallbackKeyResolver);
+ converter = new MongoEncryptionConverter(encryption, keyResolver);
+ }
+
+ @Test // GH-4284
+ void delegatesConversionOfSimpleValueWithDefaultEncryptionKeyFromKeyResolver() {
+
+ when(conversionContext.getProperty())
+ .thenReturn(mappingContext.getPersistentPropertyFor(Type.class, Type::getStringValueWithAlgorithmOnly));
+
+ converter.write("foo", conversionContext);
+
+ assertThat(valueToBeEncrypted.getValue()).isEqualTo(new BsonString("foo"));
+ assertThat(encryptionOptions.getValue()).isEqualTo(
+ new EncryptionOptions(AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic, EncryptionKey.keyAltName("default")));
+ }
+
+ @Test // GH-4284
+ void favorsAltKeyNameIfPresent() {
+
+ when(conversionContext.getProperty()).thenReturn(
+ mappingContext.getPersistentPropertyFor(Type.class, Type::getStringValueWithAlgorithmAndAltKeyName));
+
+ converter.write("foo", conversionContext);
+
+ assertThat(encryptionOptions.getValue()).isEqualTo(
+ new EncryptionOptions(AEAD_AES_256_CBC_HMAC_SHA_512_Random, EncryptionKey.keyAltName("sec-key-name")));
+ }
+
+ @Test // GH-4284
+ void readsAltKeyNameFromProperty() {
+
+ when(conversionContext.getProperty()).thenReturn(mappingContext.getPersistentPropertyFor(Type.class,
+ Type::getStringValueWithAlgorithmAndAltKeyNameFromPropertyValue));
+
+ ArgumentCaptor path = ArgumentCaptor.forClass(String.class);
+ when(conversionContext.getValue(path.capture())).thenReturn("(ツ)");
+
+ converter.write("foo", conversionContext);
+ assertThat(path.getValue()).isEqualTo("notAnnotated");
+
+ assertThat(encryptionOptions.getValue())
+ .isEqualTo(new EncryptionOptions(AEAD_AES_256_CBC_HMAC_SHA_512_Random, EncryptionKey.keyAltName("(ツ)")));
+ }
+
+ @Test // GH-4284
+ void delegatesConversionOfEntityTypes() {
+
+ Document convertedValue = new Document("unencryptedValue", "nested-unencrypted");
+ MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class,
+ Type::getNestedFullyEncrypted);
+ when(conversionContext.getProperty()).thenReturn(property);
+ doReturn(convertedValue).when(conversionContext).write(any(), eq(property.getTypeInformation()));
+
+ ArgumentCaptor path = ArgumentCaptor.forClass(String.class);
+ when(conversionContext.getValue(path.capture())).thenReturn("(ツ)");
+
+ JustATypeWithAnUnencryptedField source = new JustATypeWithAnUnencryptedField();
+ source.unencryptedValue = "nested-unencrypted";
+
+ converter.write(source, conversionContext);
+
+ assertThat(valueToBeEncrypted.getValue()).isEqualTo(convertedValue.toBsonDocument());
+ }
+
+ @Test // GH-4284
+ void listsOfSimpleTypesAreConvertedEntirely() {
+
+ MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, Type::getListOfString);
+ when(conversionContext.getProperty()).thenReturn(property);
+
+ converter.write(List.of("one", "two"), conversionContext);
+
+ assertThat(valueToBeEncrypted.getValue())
+ .isEqualTo(new BsonArray(List.of(new BsonString("one"), new BsonString("two"))));
+ }
+
+ @Test // GH-4284
+ void listsOfComplexTypesAreConvertedEntirely() {
+
+ Document convertedValue1 = new Document("unencryptedValue", "nested-unencrypted-1");
+ Document convertedValue2 = new Document("unencryptedValue", "nested-unencrypted-2");
+
+ MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, Type::getListOfComplex);
+ when(conversionContext.getProperty()).thenReturn(property);
+ doReturn(convertedValue1, convertedValue2).when(conversionContext).write(any(), eq(property.getTypeInformation()));
+
+ JustATypeWithAnUnencryptedField source1 = new JustATypeWithAnUnencryptedField();
+ source1.unencryptedValue = "nested-unencrypted-1";
+
+ JustATypeWithAnUnencryptedField source2 = new JustATypeWithAnUnencryptedField();
+ source2.unencryptedValue = "nested-unencrypted-1";
+
+ converter.write(List.of(source1, source2), conversionContext);
+
+ assertThat(valueToBeEncrypted.getValue())
+ .isEqualTo(new BsonArray(List.of(convertedValue1.toBsonDocument(), convertedValue2.toBsonDocument())));
+ }
+
+ @Test // GH-4284
+ void simpleMapsAreConvertedEntirely() {
+
+ MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, Type::getMapOfString);
+ when(conversionContext.getProperty()).thenReturn(property);
+ doReturn(new Document("k1", "v1").append("k2", "v2")).when(conversionContext).write(any(),
+ eq(property.getTypeInformation()));
+
+ converter.write(Map.of("k1", "v1", "k2", "v2"), conversionContext);
+
+ assertThat(valueToBeEncrypted.getValue())
+ .isEqualTo(new Document("k1", new BsonString("v1")).append("k2", new BsonString("v2")).toBsonDocument());
+ }
+
+ @Test // GH-4284
+ void complexMapsAreConvertedEntirely() {
+
+ Document convertedValue1 = new Document("unencryptedValue", "nested-unencrypted-1");
+ Document convertedValue2 = new Document("unencryptedValue", "nested-unencrypted-2");
+
+ MongoPersistentProperty property = mappingContext.getPersistentPropertyFor(Type.class, Type::getMapOfComplex);
+ when(conversionContext.getProperty()).thenReturn(property);
+ doReturn(new Document("k1", convertedValue1).append("k2", convertedValue2)).when(conversionContext).write(any(),
+ eq(property.getTypeInformation()));
+
+ JustATypeWithAnUnencryptedField source1 = new JustATypeWithAnUnencryptedField();
+ source1.unencryptedValue = "nested-unencrypted-1";
+
+ JustATypeWithAnUnencryptedField source2 = new JustATypeWithAnUnencryptedField();
+ source2.unencryptedValue = "nested-unencrypted-1";
+
+ converter.write(Map.of("k1", source1, "k2", source2), conversionContext);
+
+ assertThat(valueToBeEncrypted.getValue()).isEqualTo(new Document("k1", convertedValue1.toBsonDocument())
+ .append("k2", convertedValue2.toBsonDocument()).toBsonDocument());
+ }
+
+ @Data
+ static class Type {
+
+ String notAnnotated;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) //
+ String stringValueWithAlgorithmOnly;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "sec-key-name") //
+ String stringValueWithAlgorithmAndAltKeyName;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random, keyAltName = "/notAnnotated") //
+ String stringValueWithAlgorithmAndAltKeyNameFromPropertyValue;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // full document must be random
+ JustATypeWithAnUnencryptedField nestedFullyEncrypted;
+
+ NestedWithEncryptedField nestedWithEncryptedField;
+
+ // Client-Side Field Level Encryption does not support encrypting individual array elements
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
+ List listOfString;
+
+ // Client-Side Field Level Encryption does not support encrypting individual array elements
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) // lists must be random
+ List listOfComplex;
+
+ // just as it was a domain type encrypt the entire thing here
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
+ Map mapOfString;
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Random) //
+ Map mapOfComplex;
+
+ RecordWithEncryptedValue recordWithEncryptedValue;
+
+ List listOfRecordWithEncryptedValue;
+ }
+
+ static class JustATypeWithAnUnencryptedField {
+
+ String unencryptedValue;
+ }
+
+ static class NestedWithEncryptedField extends JustATypeWithAnUnencryptedField {
+
+ @ExplicitEncrypted(algorithm = AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) //
+ String encryptedValue;
+ }
+
+ record RecordWithEncryptedValue(@ExplicitEncrypted String value) {
+ }
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestMappingContext.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestMappingContext.java
index 4c0142ece5..11e6612ff4 100644
--- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestMappingContext.java
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestMappingContext.java
@@ -16,10 +16,15 @@
package org.springframework.data.mongodb.test.util;
import java.util.Collections;
+import java.util.Optional;
import java.util.function.Consumer;
+import java.util.function.Function;
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
+import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
+import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
+import org.springframework.data.util.MethodInvocationRecorder;
/**
* @author Christoph Strobl
@@ -45,6 +50,12 @@ public MongoTestMappingContext(Consumer contextConfig)
contextConfig.accept(contextConfigurer);
}
+ public MongoPersistentProperty getPersistentPropertyFor(Class type, Function property) {
+
+ MongoPersistentEntity> persistentEntity = getRequiredPersistentEntity(type);
+ return persistentEntity.getPersistentProperty(MethodInvocationRecorder.forProxyOf(type).record(property).getPropertyPath().get());
+ }
+
public MongoTestMappingContext customConversions(MongoConverterConfigurer converterConfig) {
this.converterConfigurer = converterConfig;
@@ -75,4 +86,6 @@ public MongoTestMappingContext init() {
public void afterPropertiesSet() {
init();
}
+
+
}
diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc
index 2eb7f54327..93b7896d88 100644
--- a/src/main/asciidoc/index.adoc
+++ b/src/main/asciidoc/index.adoc
@@ -32,6 +32,7 @@ include::{spring-data-commons-docs}/auditing.adoc[leveloffset=+1]
include::reference/mongo-auditing.adoc[leveloffset=+1]
include::reference/mapping.adoc[leveloffset=+1]
include::reference/sharding.adoc[leveloffset=+1]
+include::reference/mongo-encryption.adoc[leveloffset=+1]
include::reference/kotlin.adoc[leveloffset=+1]
include::reference/jmx.adoc[leveloffset=+1]
diff --git a/src/main/asciidoc/reference/mongo-encryption.adoc b/src/main/asciidoc/reference/mongo-encryption.adoc
new file mode 100644
index 0000000000..41c470f763
--- /dev/null
+++ b/src/main/asciidoc/reference/mongo-encryption.adoc
@@ -0,0 +1,170 @@
+[[mongo.encryption]]
+= Client Side Field Level Encryption (CSFLE)
+
+Client Side Encryption is a feature that encrypts data in your application before it is sent to MongoDB.
+We recommend you get familiar with the concepts, ideally from the https://www.mongodb.com/docs/manual/core/csfle/[MongoDB Documentation] to learn more about its capabilities and restrictions before you continue applying Encryption through Spring Data.
+
+[NOTE]
+====
+Make sure to set the drivers `com.mongodb.AutoEncryptionSettings` to use client-side encryption.
+MongoDB does not support encryption for all field types.
+Specific data types require deterministic encryption to preserve equality comparison functionality.
+====
+
+[[mongo.encryption.automatic]]
+== Automatic Encryption
+
+MongoDB supports https://www.mongodb.com/docs/manual/core/csfle/[Client-Side Field Level Encryption] out of the box using the MongoDB driver with its Automatic Encryption feature.
+Automatic Encryption requires a <> that allows to perform encrypted read and write operations without the need to provide an explicit en-/decryption step.
+
+Please refer to the <> section for more information on defining a JSON Schema that holds encryption information.
+
+To make use of a the `MongoJsonSchema` it needs to be combined with `AutoEncryptionSettings` which can be done eg. via a `MongoClientSettingsBuilderCustomizer`.
+
+[source,java]
+----
+@Bean
+MongoClientSettingsBuilderCustomizer customizer(MappingContext mappingContext) {
+ return (builder) -> {
+
+ // ... keyVaultCollection, kmsProvider, ...
+
+ MongoJsonSchemaCreator schemaCreator = MongoJsonSchemaCreator.create(mappingContext);
+ MongoJsonSchema patientSchema = schemaCreator
+ .filter(MongoJsonSchemaCreator.encryptedOnly())
+ .createSchemaFor(Patient.class);
+
+ AutoEncryptionSettings autoEncryptionSettings = AutoEncryptionSettings.builder()
+ .keyVaultNamespace(keyVaultCollection)
+ .kmsProviders(kmsProviders)
+ .extraOptions(extraOpts)
+ .schemaMap(Collections.singletonMap("db.patient", patientSchema.schemaDocument().toBsonDocument()))
+ .build();
+
+ builder.autoEncryptionSettings(autoEncryptionSettings);
+ };
+}
+----
+
+[[mongo.encryption.explicit]]
+== Explicit Encryption
+
+Explicit encryption uses the MongoDB driver's encryption library (`org.mongodb:mongodb-crypt`) to perform encryption and decryption tasks.
+The `@ExplicitEncrypted` annotation is a combination of the `@Encrypted` annotation used for <> and a <>.
+In other words, `@ExplicitEncrypted` uses existing building blocks to combine them for simplified explicit encryption support.
+
+[NOTE]
+====
+Fields annotated with `@ExplicitEncrypted` are always encrypted as whole.
+Consider the following example:
+
+[source,java]
+----
+@ExplicitEncrypted(…)
+String simpleValue; <1>
+
+@ExplicitEncrypted(…)
+Address address; <2>
+
+@ExplicitEncrypted(…)
+List<...> list; <3>
+
+@ExplicitEncrypted(…)
+Map<..., ...> mapOfString; <4>
+----
+
+<1> Encrypts the value of the simple type such as a `String` if not `null`.
+<2> Encrypts the entire `Address` object and all its nested fields as `Document`.
+To only encrypt parts of the `Address`, like `Address#street` the `street` field within `Address` needs to be annotated with `@ExplicitEncrypted`.
+<3> ``Collection``-like fields are encrypted as single value and not per entry.
+<4> ``Map``-like fields are encrypted as single value and not as a key/value entry.
+====
+
+Depending on the encryption algorithm, MongoDB supports certain operations on an encrypted field using its https://www.mongodb.com/docs/manual/core/queryable-encryption/[Queryable Encryption] feature.
+To pick a certain algorithm use `@ExplicitEncrypted(algorithm)`, see `EncryptionAlgorithms` for algorithm constants.
+Please read the https://www.mongodb.com/docs/manual/core/csfle/fundamentals/encryption-algorithms[Encryption Types] manual for more information on algorithms and their usage.
+
+To perform the actual encryption we require a Data Encryption Key (DEK).
+Please refer to the https://www.mongodb.com/docs/manual/core/csfle/quick-start/#create-a-data-encryption-key[MongoDB Documentation] for more information on how to set up key management and create a Data Encryption Key.
+The DEK can be referenced directly via its `id` or a defined _alternative name_.
+The `@EncryptedField` annotation only allows referencing a DEK via an alternative name.
+It is possible to provide an `EncryptionKeyResolver`, which will be discussed later, to any DEK.
+
+.Reference the Data Encryption Key
+====
+[source,java]
+----
+@EncryptedField(algorithm=…, altKeyName = "secret-key") <1>
+String ssn;
+----
+
+[source,java]
+----
+@EncryptedField(algorithm=…, altKeyName = "/name") <2>
+String ssn;
+----
+
+<1> Use the DEK stored with the alternative name `secret-key`.
+<2> Uses a field reference that will read the actual field value and use that for key lookup.
+Always requires the full document to be present for save operations.
+Fields cannot be used in queries/aggregations.
+====
+
+By default, the `@ExplicitEncrypted(value=…)` attribute references a `MongoEncryptionConverter`.
+It is possible to change the default implementation and exchange it with any `PropertyValueConverter` implementation by providing the according type reference.
+To learn more about custom `PropertyValueConverters` and the required configuration, please refer to the <> section.
+
+[[mongo.encryption.explicit-setup]]
+=== MongoEncryptionConverter Setup
+
+The converter setup for `MongoEncryptionConverter` requires a few steps as several components are involved.
+The bean setup consists of the following:
+
+1. The `ClientEncryption` engine
+2. A `MongoEncryptionConverter` instance configured with `ClientEncryption` and a `EncryptionKeyResolver`.
+3. A `PropertyValueConverterFactory` that uses the registered `MongoEncryptionConverter` bean.
+
+A side effect of using annotated key resolution is that the `@ExplicitEncrypted` annotation does not need to specify an alt key name.
+The `EncryptionKeyResolver` uses an `EncryptionContext` providing access to the property allowing for dynamic DEK resolution.
+
+.Sample MongoEncryptionConverter Configuration
+====
+[source,java]
+----
+class Config extends AbstractMongoClientConfiguration {
+
+ @Autowired ApplicationContext appContext;
+
+ @Bean
+ ClientEncryption clientEncryption() { <1>
+ ClientEncryptionSettings encryptionSettings = ClientEncryptionSettings.builder();
+ // …
+
+ return ClientEncryptions.create(encryptionSettings);
+ }
+
+ @Bean
+ MongoEncryptionConverter encryptingConverter(ClientEncryption clientEncryption) {
+
+ Encryption encryption = MongoClientEncryption.just(clientEncryption);
+ EncryptionKeyResolver keyResolver = EncryptionKeyResolver.annotated((ctx) -> …); <2>
+
+ return new MongoEncryptionConverter(encryption, keyResolver); <3>
+ }
+
+ @Override
+ protected void configureConverters(MongoConverterConfigurationAdapter adapter) {
+
+ adapter
+ .registerPropertyValueConverterFactory(PropertyValueConverterFactory.beanFactoryAware(appContext)); <4>
+ }
+}
+----
+
+<1> Set up a `Encryption` engine using `com.mongodb.client.vault.ClientEncryption`.
+The instance is stateful and must be closed after usage.
+Spring takes care of this because `ClientEncryption` is ``Closeable``.
+<2> Set up an annotation-based `EncryptionKeyResolver` to determine the `EncryptionKey` from annotations.
+<3> Create the `MongoEncryptionConverter`.
+<4> Enable for a `PropertyValueConverter` lookup from the `BeanFactory`.
+====
diff --git a/src/main/asciidoc/reference/mongo-json-schema.adoc b/src/main/asciidoc/reference/mongo-json-schema.adoc
index 26e8f7f099..369a7171dd 100644
--- a/src/main/asciidoc/reference/mongo-json-schema.adoc
+++ b/src/main/asciidoc/reference/mongo-json-schema.adoc
@@ -396,37 +396,8 @@ public class EncryptionExtension implements EvaluationContextExtension {
}
}
----
-
-To combine derived encryption settings with `AutoEncryptionSettings` in a Spring Boot application use the `MongoClientSettingsBuilderCustomizer`.
-
-[source,java]
-----
-@Bean
-MongoClientSettingsBuilderCustomizer customizer(MappingContext mappingContext) {
- return (builder) -> {
-
- // ... keyVaultCollection, kmsProvider, ...
-
- MongoJsonSchemaCreator schemaCreator = MongoJsonSchemaCreator.create(mappingContext);
- MongoJsonSchema patientSchema = schemaCreator
- .filter(MongoJsonSchemaCreator.encryptedOnly())
- .createSchemaFor(Patient.class);
-
- AutoEncryptionSettings autoEncryptionSettings = AutoEncryptionSettings.builder()
- .keyVaultNamespace(keyVaultCollection)
- .kmsProviders(kmsProviders)
- .extraOptions(extraOpts)
- .schemaMap(Collections.singletonMap("db.patient", patientSchema.schemaDocument().toBsonDocument()))
- .build();
-
- builder.autoEncryptionSettings(autoEncryptionSettings);
- };
-}
-----
====
-NOTE: Make sure to set the drivers `com.mongodb.AutoEncryptionSettings` to use client-side encryption. MongoDB does not support encryption for all field types. Specific data types require deterministic encryption to preserve equality comparison functionality.
-
[[mongo.jsonSchema.types]]
==== JSON Schema Types