From 99203b397a27f9cd595eebb028161b9c054dfd68 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 24 Aug 2021 07:06:17 +0200 Subject: [PATCH] Add support for deriving json schema for encrypted properties. This commit introduces support for creating a MongoJsonSchema containing encrypted fields for a given type based on mapping metadata. Using the Encrypted annotation allows to derive required encryptMetadata and encrypt properties within a given (mapping)context. @Document @Encrypted(keyId = "...") static class Patient { // ... @Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic") private Integer ssn; } MongoJsonSchemaCreator schemaCreator = MongoJsonSchemaCreator.create(mappingContext); MongoJsonSchema patientSchema = schemaCreator .filter(MongoJsonSchemaCreator.encryptedOnly()) .createSchemaFor(Patient.class); Closes: #3800 Original pull request: #3801. --- .../mongodb/core/EncryptionAlgorithms.java | 29 ++ .../core/MappingMongoJsonSchemaCreator.java | 109 ++++++- .../mongodb/core/MongoJsonSchemaCreator.java | 136 +++++++++ .../mapping/BasicMongoPersistentEntity.java | 38 +++ .../mapping/BasicMongoPersistentProperty.java | 48 ++++ .../data/mongodb/core/mapping/Encrypted.java | 112 ++++++++ .../core/mapping/MongoMappingContext.java | 6 + .../core/mapping/MongoPersistentEntity.java | 9 + .../core/mapping/MongoPersistentProperty.java | 9 + .../UnwrappedMongoPersistentEntity.java | 6 + .../UnwrappedMongoPersistentProperty.java | 6 + .../core/schema/DefaultMongoJsonSchema.java | 38 ++- .../core/schema/DocumentJsonSchema.java | 6 +- .../IdentifiableJsonSchemaProperty.java | 16 +- .../mongodb/core/schema/MongoJsonSchema.java | 32 ++- .../core/schema/TypedJsonSchemaObject.java | 4 + .../util/encryption/EncryptionUtils.java | 67 +++++ .../mongodb/util/spel/ExpressionUtils.java | 52 ++++ ...appingMongoJsonSchemaCreatorUnitTests.java | 272 +++++++++++++++++- .../asciidoc/reference/mongo-json-schema.adoc | 103 +++++++ 20 files changed, 1075 insertions(+), 23 deletions(-) create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Encrypted.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/spel/ExpressionUtils.java diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java new file mode 100644 index 000000000..0ed7340aa --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EncryptionAlgorithms.java @@ -0,0 +1,29 @@ +/* + * Copyright 2021 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 algorithms supported by MongoDB Client Side Field Level Encryption. + * + * @author Christoph Strobl + * @since 3.3 + */ +public final class EncryptionAlgorithms { + + public static final String AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic"; + public static final String AEAD_AES_256_CBC_HMAC_SHA_512_Random = "AEAD_AES_256_CBC_HMAC_SHA_512-Random"; + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java index ecbf8a4f0..a53ff8f5a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java @@ -20,13 +20,19 @@ import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.bson.Document; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.data.mongodb.core.mapping.BasicMongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.Encrypted; import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty; import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.ObjectJsonSchemaProperty; import org.springframework.data.mongodb.core.schema.JsonSchemaObject; import org.springframework.data.mongodb.core.schema.JsonSchemaObject.Type; @@ -34,10 +40,12 @@ import org.springframework.data.mongodb.core.schema.JsonSchemaProperty; import org.springframework.data.mongodb.core.schema.MongoJsonSchema; import org.springframework.data.mongodb.core.schema.MongoJsonSchema.MongoJsonSchemaBuilder; import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; /** * {@link MongoJsonSchemaCreator} implementation using both {@link MongoConverter} and {@link MappingContext} to obtain @@ -52,6 +60,7 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator { private final MongoConverter converter; private final MappingContext, MongoPersistentProperty> mappingContext; + private final Predicate filter; /** * Create a new instance of {@link MappingMongoJsonSchemaCreator}. @@ -61,10 +70,24 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator { @SuppressWarnings("unchecked") MappingMongoJsonSchemaCreator(MongoConverter converter) { + this(converter, (MappingContext, MongoPersistentProperty>) converter.getMappingContext(), + (property) -> true); + } + + @SuppressWarnings("unchecked") + MappingMongoJsonSchemaCreator(MongoConverter converter, + MappingContext, MongoPersistentProperty> mappingContext, + Predicate filter) { + Assert.notNull(converter, "Converter must not be null!"); this.converter = converter; - this.mappingContext = (MappingContext, MongoPersistentProperty>) converter - .getMappingContext(); + this.mappingContext = mappingContext; + this.filter = filter; + } + + @Override + public MongoJsonSchemaCreator filter(Predicate filter) { + return new MappingMongoJsonSchemaCreator(converter, mappingContext, filter); } /* @@ -77,11 +100,29 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator { MongoPersistentEntity entity = mappingContext.getRequiredPersistentEntity(type); MongoJsonSchemaBuilder schemaBuilder = MongoJsonSchema.builder(); + { + Encrypted encrypted = entity.findAnnotation(Encrypted.class); + if (encrypted != null) { + + Document encryptionMetadata = new Document(); + + Collection encryptionKeyIds = entity.getEncryptionKeyIds(); + if (!CollectionUtils.isEmpty(encryptionKeyIds)) { + encryptionMetadata.append("keyId", encryptionKeyIds); + } + + if (StringUtils.hasText(encrypted.algorithm())) { + encryptionMetadata.append("algorithm", encrypted.algorithm()); + } + + schemaBuilder.encryptionMetadata(encryptionMetadata); + } + } + List schemaProperties = computePropertiesForEntity(Collections.emptyList(), entity); schemaBuilder.properties(schemaProperties.toArray(new JsonSchemaProperty[0])); return schemaBuilder.build(); - } private List computePropertiesForEntity(List path, @@ -93,6 +134,11 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator { List currentPath = new ArrayList<>(path); + if (!filter.test(new PropertyContext( + currentPath.stream().map(PersistentProperty::getName).collect(Collectors.joining(".")), nested))) { + continue; + } + if (path.contains(nested)) { // cycle guard schemaProperties.add(createSchemaProperty(computePropertyFieldName(CollectionUtils.lastElement(currentPath)), Object.class, false)); @@ -120,15 +166,38 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator { String fieldName = computePropertyFieldName(property); + JsonSchemaProperty schemaProperty; if (property.isCollectionLike()) { - return createSchemaProperty(fieldName, targetType, required); + schemaProperty = createSchemaProperty(fieldName, targetType, required); } else if (property.isMap()) { - return createSchemaProperty(fieldName, Type.objectType(), required); + schemaProperty = createSchemaProperty(fieldName, Type.objectType(), required); } else if (ClassUtils.isAssignable(Enum.class, targetType)) { - return createEnumSchemaProperty(fieldName, targetType, required); + schemaProperty = createEnumSchemaProperty(fieldName, targetType, required); + } else { + schemaProperty = createSchemaProperty(fieldName, targetType, required); } - return createSchemaProperty(fieldName, targetType, required); + return applyEncryptionDataIfNecessary(property, schemaProperty); + } + + @Nullable + private JsonSchemaProperty applyEncryptionDataIfNecessary(MongoPersistentProperty property, + JsonSchemaProperty schemaProperty) { + + Encrypted encrypted = property.findAnnotation(Encrypted.class); + if (encrypted == null) { + return schemaProperty; + } + + EncryptedJsonSchemaProperty enc = new EncryptedJsonSchemaProperty(schemaProperty); + if (StringUtils.hasText(encrypted.algorithm())) { + enc = enc.algorithm(encrypted.algorithm()); + } + if (!ObjectUtils.isEmpty(encrypted.keyId())) { + enc = enc.keys(property.getEncryptionKeyIds()); + } + return enc; + } private JsonSchemaProperty createObjectSchemaPropertyForEntity(List path, @@ -207,4 +276,30 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator { return JsonSchemaProperty.required(property); } + + class PropertyContext implements JsonSchemaPropertyContext { + + private String path; + private MongoPersistentProperty property; + + public PropertyContext(String path, MongoPersistentProperty property) { + this.path = path; + this.property = property; + } + + @Override + public String getPath() { + return path; + } + + @Override + public MongoPersistentProperty getProperty() { + return property; + } + + @Override + public MongoPersistentEntity resolveEntity(MongoPersistentProperty property) { + return (MongoPersistentEntity) mappingContext.getPersistentEntity(property); + } + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoJsonSchemaCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoJsonSchemaCreator.java index f3c0dcd62..5e5bc5064 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoJsonSchemaCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoJsonSchemaCreator.java @@ -15,7 +15,23 @@ */ package org.springframework.data.mongodb.core; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Predicate; + +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; +import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; +import org.springframework.data.mongodb.core.mapping.Encrypted; +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.mongodb.core.mapping.MongoSimpleTypes; +import org.springframework.data.mongodb.core.mapping.Unwrapped.Nullable; +import org.springframework.data.mongodb.core.schema.JsonSchemaProperty; import org.springframework.data.mongodb.core.schema.MongoJsonSchema; import org.springframework.util.Assert; @@ -46,6 +62,7 @@ import org.springframework.util.Assert; * {@link org.bson.types.ObjectId} like {@link String} will be mapped to {@code type : 'object'} unless there is more * specific information available via the {@link org.springframework.data.mongodb.core.mapping.MongoId} annotation. *

+ * {@link Encrypted} properties will contain {@literal encrypt} information. * * @author Christoph Strobl * @since 2.2 @@ -60,6 +77,88 @@ public interface MongoJsonSchemaCreator { */ MongoJsonSchema createSchemaFor(Class type); + /** + * Filter matching {@link JsonSchemaProperty properties}. + * + * @param filter the {@link Predicate} to evaluate for inclusion. Must not be {@literal null}. + * @return new instance of {@link MongoJsonSchemaCreator}. + * @since 3.3 + */ + MongoJsonSchemaCreator filter(Predicate filter); + + /** + * The context in which a specific {@link #getProperty()} is encountered during schema creation. + * + * @since 3.3 + */ + interface JsonSchemaPropertyContext { + + /** + * The path to a given field/property in dot notation. + * + * @return never {@literal null}. + */ + String getPath(); + + /** + * The current property. + * + * @return never {@literal null}. + */ + MongoPersistentProperty getProperty(); + + /** + * Obtain the {@link MongoPersistentEntity} for a given property. + * + * @param property must not be {@literal null}. + * @param + * @return {@literal null} if the property is not an entity. It is nevertheless recommend to check + * {@link PersistentProperty#isEntity()} first. + */ + @Nullable + MongoPersistentEntity resolveEntity(MongoPersistentProperty property); + + } + + /** + * A filter {@link Predicate} that matches {@link Encrypted encrypted properties} and those having nested ones. + * + * @return new instance of {@link Predicate}. + * @since 3.3 + */ + static Predicate encryptedOnly() { + + return new Predicate() { + + // cycle guard + private final Set seen = new HashSet<>(); + + @Override + public boolean test(JsonSchemaPropertyContext context) { + return extracted(context.getProperty(), context); + } + + private boolean extracted(MongoPersistentProperty property, JsonSchemaPropertyContext context) { + if (property.isAnnotationPresent(Encrypted.class)) { + return true; + } + + if (!property.isEntity() || seen.contains(property)) { + return false; + } + + seen.add(property); + + for (MongoPersistentProperty nested : context.resolveEntity(property)) { + if (extracted(nested, context)) { + return true; + } + } + return false; + } + }; + } + /** * Creates a new {@link MongoJsonSchemaCreator} that is aware of conversions applied by the given * {@link MongoConverter}. @@ -72,4 +171,41 @@ public interface MongoJsonSchemaCreator { Assert.notNull(mongoConverter, "MongoConverter must not be null!"); return new MappingMongoJsonSchemaCreator(mongoConverter); } + + /** + * Creates a new {@link MongoJsonSchemaCreator} that is aware of type mappings and potential + * {@link org.springframework.data.spel.spi.EvaluationContextExtension extensions}. + * + * @param mappingContext must not be {@literal null}. + * @return new instance of {@link MongoJsonSchemaCreator}. + * @since 3.3 + */ + static MongoJsonSchemaCreator create(MappingContext mappingContext) { + + MappingMongoConverter converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, mappingContext); + converter.setCustomConversions(MongoCustomConversions.create(config -> {})); + converter.afterPropertiesSet(); + + return create(converter); + } + + /** + * Creates a new {@link MongoJsonSchemaCreator} that does not consider potential extensions - suitable for testing. We + * recommend to use {@link #create(MappingContext)}. + * + * @return new instance of {@link MongoJsonSchemaCreator}. + * @since 3.3 + */ + static MongoJsonSchemaCreator create() { + + MongoMappingContext mappingContext = new MongoMappingContext(); + mappingContext.setSimpleTypeHolder(MongoSimpleTypes.HOLDER); + mappingContext.afterPropertiesSet(); + + MappingMongoConverter converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, mappingContext); + converter.setCustomConversions(MongoCustomConversions.create(config -> {})); + converter.afterPropertiesSet(); + + return create(converter); + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java index 7bf8214ae..6840fce5b 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java @@ -17,8 +17,12 @@ package org.springframework.data.mongodb.core.mapping; import java.lang.reflect.Field; import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.data.annotation.Id; @@ -28,6 +32,9 @@ import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.model.BasicPersistentEntity; import org.springframework.data.mongodb.MongoCollectionUtils; +import org.springframework.data.mongodb.util.encryption.EncryptionUtils; +import org.springframework.data.spel.ExpressionDependencies; +import org.springframework.data.util.Lazy; import org.springframework.data.util.TypeInformation; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; @@ -212,6 +219,11 @@ public class BasicMongoPersistentEntity extends BasicPersistentEntity extends BasicPersistentEntity getEncryptionKeyIds() { + + Encrypted encrypted = findAnnotation(Encrypted.class); + if (encrypted == null) { + return null; + } + + if (ObjectUtils.isEmpty(encrypted.keyId())) { + return Collections.emptySet(); + } + + Lazy evaluationContext = Lazy.of(() -> { + + EvaluationContext ctx = getEvaluationContext(null); + ctx.setVariable("target", getType().getSimpleName()); + return ctx; + }); + + List target = new ArrayList<>(); + for (String keyId : encrypted.keyId()) { + target.add(EncryptionUtils.resolveKeyId(keyId, evaluationContext)); + } + return target; + } + /** * @author Christoph Strobl * @since 1.6 diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java index 131575789..cf74d696a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java @@ -16,7 +16,11 @@ package org.springframework.data.mongodb.core.mapping; import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.bson.types.ObjectId; @@ -29,7 +33,12 @@ import org.springframework.data.mapping.model.FieldNamingStrategy; import org.springframework.data.mapping.model.Property; import org.springframework.data.mapping.model.PropertyNameFieldNamingStrategy; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.mongodb.util.encryption.EncryptionUtils; +import org.springframework.data.util.Lazy; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** @@ -299,4 +308,43 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope return isAnnotationPresent(TextScore.class); } + /** + * Obtain the {@link EvaluationContext} for a specific root object. + * + * @param rootObject can be {@literal null}. + * @return never {@literal null}. + * @since 3.3 + */ + public EvaluationContext getEvaluationContext(@Nullable Object rootObject) { + + if (getOwner() instanceof BasicMongoPersistentEntity) { + return ((BasicMongoPersistentEntity) getOwner()).getEvaluationContext(rootObject); + } + return rootObject != null ? new StandardEvaluationContext(rootObject) : new StandardEvaluationContext(); + } + + @Override + public Collection getEncryptionKeyIds() { + + Encrypted encrypted = findAnnotation(Encrypted.class); + if (encrypted == null) { + return null; + } + + if (ObjectUtils.isEmpty(encrypted.keyId())) { + return Collections.emptySet(); + } + + Lazy evaluationContext = Lazy.of(() -> { + EvaluationContext ctx = getEvaluationContext(null); + ctx.setVariable("target", getOwner().getType().getSimpleName() + "." + getName()); + return ctx; + }); + + List target = new ArrayList<>(); + for (String keyId : encrypted.keyId()) { + target.add(EncryptionUtils.resolveKeyId(keyId, evaluationContext)); + } + return target; + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Encrypted.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Encrypted.java new file mode 100644 index 000000000..8bd0f99c4 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Encrypted.java @@ -0,0 +1,112 @@ +/* + * Copyright 2021 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.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@link Encrypted} provides data required for MongoDB Client Side Field Level Encryption that is applied during schema + * resolution. It can be applied on top level (typically those types annotated with {@link Document} to provide the + * {@literal encryptMetadata}. + * + *
+ * @Document
+ * @Encrypted(keyId = "4fPYFM9qSgyRAjgQ2u+IMQ==")
+ * public class Patient {
+ * 	 private ObjectId id;
+ * 	 private String name;
+ *
+ * 	 @Field("publisher_ac")
+ * 	 @DocumentReference(lookup = "{ 'acronym' : ?#{#target} }") private Publisher publisher;
+ * }
+ *
+ * "encryptMetadata": {
+ *    "keyId": [
+ *      {
+ *        "$binary": {
+ *          "base64": "4fPYFM9qSgyRAjgQ2u+IMQ==",
+ *          "subType": "04"
+ *        }
+ *      }
+ *    ]
+ *  }
+ * 
+ * + *
+ * On property level it is used for deriving field specific {@literal encrypt} settings. + * + *
+ * public class Patient {
+ * 	 private ObjectId id;
+ * 	 private String name;
+ *
+ * 	 @Encrypted(keyId = "4fPYFM9qSgyRAjgQ2u+IMQ==", algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic")
+ * 	 private String ssn;
+ * }
+ *
+ * "ssn" : {
+ *   "encrypt": {
+ *      "keyId": [
+ *        {
+ *          "$binary": {
+ *            "base64": "4fPYFM9qSgyRAjgQ2u+IMQ==",
+ *            "subType": "04"
+ *          }
+ *        }
+ *      ],
+ *      "algorithm" : "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic",
+ *      "bsonType" : "string"
+ *    }
+ *  }
+ * 
+ * + * @author Christoph Strobl + * @since 3.3 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.FIELD }) +public @interface Encrypted { + + /** + * Get the {@code keyId} to use. The value must resolve to either the UUID representation of the key or a base64 + * encoded value representing the UUID value. + *

+ * On {@link ElementType#TYPE} level the {@link #keyId()} can be left empty if explicitly set for fields.
+ * On {@link ElementType#FIELD} level the {@link #keyId()} can be left empty if inherited from + * {@literal encryptMetadata}. + * + * @return the key id to use. May contain a parsable {@link org.springframework.expression.Expression expression}. In + * this case the {@code #target} variable will hold the target element name. + */ + String[] keyId() default {}; + + /** + * Set the algorithm to use. + *

+ * On {@link ElementType#TYPE} level the {@link #algorithm()} can be left empty if explicitly set for fields.
+ * On {@link ElementType#FIELD} level the {@link #algorithm()} can be left empty if inherited from + * {@literal encryptMetadata}. + * + * @return the encryption algorithm. + * @see org.springframework.data.mongodb.core.EncryptionAlgorithms + */ + String algorithm() default ""; +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoMappingContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoMappingContext.java index 121658b06..674ea74f3 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoMappingContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoMappingContext.java @@ -46,6 +46,9 @@ public class MongoMappingContext extends AbstractMappingContext(entity, new UnwrapEntityContext(persistentProperty)); } + } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentEntity.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentEntity.java index deb69eab3..d9b5ae0bd 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentEntity.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentEntity.java @@ -15,6 +15,8 @@ */ package org.springframework.data.mongodb.core.mapping; +import java.util.Collection; + import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.model.MutablePersistentEntity; import org.springframework.lang.Nullable; @@ -102,4 +104,11 @@ public interface MongoPersistentEntity extends MutablePersistentEntity getEncryptionKeyIds(); } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java index 2bd387d74..8dc89e03f 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java @@ -15,6 +15,8 @@ */ package org.springframework.data.mongodb.core.mapping; +import java.util.Collection; + import org.springframework.core.convert.converter.Converter; import org.springframework.data.annotation.Id; import org.springframework.data.mapping.PersistentEntity; @@ -160,6 +162,13 @@ public interface MongoPersistentProperty extends PersistentProperty getEncryptionKeyIds(); + /** * Simple {@link Converter} implementation to transform a {@link MongoPersistentProperty} into its field name. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentEntity.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentEntity.java index 6a60168e9..f85c73cae 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentEntity.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentEntity.java @@ -17,6 +17,7 @@ package org.springframework.data.mongodb.core.mapping; import java.lang.annotation.Annotation; import java.util.ArrayList; +import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Spliterator; @@ -323,4 +324,9 @@ class UnwrappedMongoPersistentEntity implements MongoPersistentEntity { public boolean isUnwrapped() { return context.getProperty().isUnwrapped(); } + + @Override + public Collection getEncryptionKeyIds() { + return delegate.getEncryptionKeyIds(); + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java index a2194c173..24e4ae057 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java @@ -18,6 +18,7 @@ package org.springframework.data.mongodb.core.mapping; import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.Collection; import org.springframework.data.mapping.Association; import org.springframework.data.mapping.PersistentEntity; @@ -268,6 +269,11 @@ class UnwrappedMongoPersistentProperty implements MongoPersistentProperty { return delegate.isUnwrapped(); } + @Override + public Collection getEncryptionKeyIds() { + return delegate.getEncryptionKeyIds(); + } + @Override @Nullable public Class getComponentType() { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DefaultMongoJsonSchema.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DefaultMongoJsonSchema.java index 1b0584091..f77e4290e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DefaultMongoJsonSchema.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DefaultMongoJsonSchema.java @@ -16,7 +16,9 @@ package org.springframework.data.mongodb.core.schema; import org.bson.Document; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; /** * Value object representing a MongoDB-specific JSON schema which is the default {@link MongoJsonSchema} implementation. @@ -29,18 +31,44 @@ class DefaultMongoJsonSchema implements MongoJsonSchema { private final JsonSchemaObject root; - DefaultMongoJsonSchema(JsonSchemaObject root) { + @Nullable // + private final Document encryptionMetadata; + + DefaultMongoJsonSchema(JsonSchemaObject root) { + this(root, null); + } + + /** + * Create new instance of {@link DefaultMongoJsonSchema}. + * + * @param root the schema root element. + * @param encryptionMetadata can be {@literal null}. + * @since 3.3 + */ + DefaultMongoJsonSchema(JsonSchemaObject root, @Nullable Document encryptionMetadata) { + + Assert.notNull(root, "Root schema object must not be null!"); - Assert.notNull(root, "Root must not be null!"); this.root = root; + this.encryptionMetadata = encryptionMetadata; } /* * (non-Javadoc) - * @see org.springframework.data.mongodb.core.schema.MongoJsonSchema#toDocument() + * @see org.springframework.data.mongodb.core.schema.MongoJsonSchema#schema() */ @Override - public Document toDocument() { - return new Document("$jsonSchema", root.toDocument()); + public Document schemaDocument() { + + Document schemaDocument = new Document(); + + // we want this to be the first element rendered, so it reads nice when printed to json + if (!CollectionUtils.isEmpty(encryptionMetadata)) { + schemaDocument.append("encryptMetadata", encryptionMetadata); + } + + schemaDocument.putAll(root.toDocument()); + + return schemaDocument; } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DocumentJsonSchema.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DocumentJsonSchema.java index 2788dd59e..787e94903 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DocumentJsonSchema.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DocumentJsonSchema.java @@ -36,10 +36,10 @@ class DocumentJsonSchema implements MongoJsonSchema { /* * (non-Javadoc) - * @see org.springframework.data.mongodb.core.schema.MongoJsonSchema#toDocument() + * @see org.springframework.data.mongodb.core.schema.MongoJsonSchema#schema() */ @Override - public Document toDocument() { - return new Document("$jsonSchema", new Document(document)); + public Document schemaDocument() { + return new Document(document); } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java index 28116e1ba..97b3cc6b4 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java @@ -523,6 +523,10 @@ public class IdentifiableJsonSchemaProperty implemen public ObjectJsonSchemaProperty generatedDescription() { return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.generatedDescription()); } + + public List getProperties() { + return jsonSchemaObjectDelegate.getProperties(); + } } /** @@ -1060,7 +1064,7 @@ public class IdentifiableJsonSchemaProperty implemen private final JsonSchemaProperty targetProperty; private final @Nullable String algorithm; private final @Nullable String keyId; - private final @Nullable List keyIds; + private final @Nullable List keyIds; /** * Create new instance of {@link EncryptedJsonSchemaProperty} wrapping the given {@link JsonSchemaProperty target}. @@ -1072,7 +1076,7 @@ public class IdentifiableJsonSchemaProperty implemen } private EncryptedJsonSchemaProperty(JsonSchemaProperty target, @Nullable String algorithm, @Nullable String keyId, - @Nullable List keyIds) { + @Nullable List keyIds) { Assert.notNull(target, "Target must not be null!"); this.targetProperty = target; @@ -1134,6 +1138,14 @@ public class IdentifiableJsonSchemaProperty implemen return new EncryptedJsonSchemaProperty(targetProperty, algorithm, null, Arrays.asList(keyId)); } + /** + * @param keyId must not be {@literal null}. + * @return new instance of {@link EncryptedJsonSchemaProperty}. + */ + public EncryptedJsonSchemaProperty keys(Object... keyId) { + return new EncryptedJsonSchemaProperty(targetProperty, algorithm, null, Arrays.asList(keyId)); + } + /* * (non-Javadoc) * @see org.springframework.data.mongodb.core.schema.JsonSchemaObject#toDocument() diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java index d45dcd09e..a14cde2d3 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java @@ -20,6 +20,7 @@ import java.util.Set; import org.bson.Document; import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ObjectJsonSchemaObject; +import org.springframework.lang.Nullable; /** * Interface defining MongoDB-specific JSON schema object. New objects can be built with {@link #builder()}, for @@ -62,13 +63,25 @@ import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.Object public interface MongoJsonSchema { /** - * Create the {@link Document} containing the specified {@code $jsonSchema}.
+ * Create the {@code $jsonSchema} {@link Document} containing the specified {@link #schemaDocument()}.
* Property and field names need to be mapped to the domain type ones by running the {@link Document} through a * {@link org.springframework.data.mongodb.core.convert.JsonSchemaMapper} to apply field name customization. * * @return never {@literal null}. */ - Document toDocument(); + default Document toDocument() { + return new Document("$jsonSchema", schemaDocument()); + } + + /** + * Create the {@link Document} defining the schema.
+ * Property and field names need to be mapped to the domain type ones by running the {@link Document} through a + * {@link org.springframework.data.mongodb.core.convert.JsonSchemaMapper} to apply field name customization. + * + * @return never {@literal null}. + * @since 3.3 + */ + Document schemaDocument(); /** * Create a new {@link MongoJsonSchema} for a given root object. @@ -108,6 +121,9 @@ public interface MongoJsonSchema { private ObjectJsonSchemaObject root; + @Nullable // + private Document encryptionMetadata; + MongoJsonSchemaBuilder() { root = new ObjectJsonSchemaObject(); } @@ -266,13 +282,23 @@ public interface MongoJsonSchema { return this; } + /** + * Define the {@literal encryptMetadata} element of the schema. + * + * @param encryptionMetadata can be {@literal null}. + * @since 3.3 + */ + public void encryptionMetadata(@Nullable Document encryptionMetadata) { + this.encryptionMetadata = encryptionMetadata; + } + /** * Obtain the {@link MongoJsonSchema}. * * @return new instance of {@link MongoJsonSchema}. */ public MongoJsonSchema build() { - return MongoJsonSchema.of(root); + return new DefaultMongoJsonSchema(root, encryptionMetadata); } } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypedJsonSchemaObject.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypedJsonSchemaObject.java index 2486e98e0..59a367a9d 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypedJsonSchemaObject.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypedJsonSchemaObject.java @@ -437,6 +437,10 @@ public class TypedJsonSchemaObject extends UntypedJsonSchemaObject { return newInstance(description, true, restrictions); } + public List getProperties() { + return properties; + } + /* * (non-Javadoc) * @see org.springframework.data.mongodb.core.schema.JsonSchemaObject#toDocument() 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 new file mode 100644 index 000000000..809f83fdc --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/encryption/EncryptionUtils.java @@ -0,0 +1,67 @@ +/* + * Copyright 2021 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.util.encryption; + +import java.util.UUID; +import java.util.function.Supplier; + +import org.springframework.data.mongodb.util.spel.ExpressionUtils; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Internal utility class for dealing with encryption related matters. + * + * @author Christoph Strobl + * @since 3.3 + */ +public final class EncryptionUtils { + + /** + * Resolve a given plain {@link String} value into the store native {@literal keyId} format, considering potential + * {@link Expression expressions}.
+ * The potential keyId is probed against an {@link UUID#fromString(String) UUID value} and the {@literal base64} + * encoded {@code $binary} representation. + * + * @param value the source value to resolve the keyId for. Must not be {@literal null}. + * @param evaluationContext a {@link Supplier} used to provide the {@link EvaluationContext} in case an + * {@link Expression} is {@link ExpressionUtils#detectExpression(String) detected}. + * @return can be {@literal null}. + * @throws IllegalArgumentException if one of the required arguments is {@literal null}. + */ + @Nullable + public static Object resolveKeyId(String value, Supplier evaluationContext) { + + Assert.notNull(value, "Value must not be null!"); + + Object potentialKeyId = value; + Expression expression = ExpressionUtils.detectExpression(value); + if (expression != null) { + potentialKeyId = expression.getValue(evaluationContext.get()); + if (!(potentialKeyId instanceof String)) { + return potentialKeyId; + } + } + try { + return UUID.fromString(potentialKeyId.toString()); + } catch (IllegalArgumentException e) { + return org.bson.Document.parse("{ val : { $binary : { base64 : '" + potentialKeyId + "', subType : '04'} } }") + .get("val"); + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/spel/ExpressionUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/spel/ExpressionUtils.java new file mode 100644 index 000000000..b41961e6e --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/spel/ExpressionUtils.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021 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.util.spel; + +import org.springframework.expression.Expression; +import org.springframework.expression.ParserContext; +import org.springframework.expression.common.LiteralExpression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +/** + * Internal utility class for dealing with {@link Expression} and potential ones. + * + * @author Christoph Strobl + * @since 3.3 + */ +public final class ExpressionUtils { + + private static final SpelExpressionParser PARSER = new SpelExpressionParser(); + + /** + * Returns a SpEL {@link Expression} if the given {@link String} is actually an expression that does not evaluate to a + * {@link LiteralExpression} (indicating that no subsequent evaluation is necessary). + * + * @param potentialExpression can be {@literal null} + * @return can be {@literal null}. + */ + @Nullable + public static Expression detectExpression(@Nullable String potentialExpression) { + + if (!StringUtils.hasText(potentialExpression)) { + return null; + } + + Expression expression = PARSER.parseExpression(potentialExpression, ParserContext.TEMPLATE_EXPRESSION); + return expression instanceof LiteralExpression ? null : expression; + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java index 9c52bbe62..9fd19189c 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java @@ -19,23 +19,27 @@ import static org.springframework.data.mongodb.test.util.Assertions.*; import java.util.Collections; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - +import org.springframework.context.support.GenericApplicationContext; import org.springframework.data.annotation.Transient; import org.springframework.data.convert.WritingConverter; import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.data.mongodb.core.convert.MongoCustomConversions; import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; +import org.springframework.data.mongodb.core.mapping.Encrypted; import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.FieldType; import org.springframework.data.mongodb.core.mapping.MongoId; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.schema.MongoJsonSchema; +import org.springframework.data.spel.spi.EvaluationContextExtension; +import org.springframework.data.spel.spi.Function; /** * Unit tests for {@link MappingMongoJsonSchemaCreator}. @@ -95,6 +99,64 @@ public class MappingMongoJsonSchemaCreatorUnitTests { "{ 'type' : 'object', 'properties' : { '_id' : { 'type' : 'object' }, 'nested' : { 'type' : 'object' } } }"); } + @Test // GH-3800 + public void csfle/*encryptedFieldsOnly*/() { + + MongoJsonSchema schema = MongoJsonSchemaCreator.create() // + .filter(MongoJsonSchemaCreator.encryptedOnly()) // filter non encrypted fields + .createSchemaFor(Patient.class); + + Document targetSchema = schema.schemaDocument(); + assertThat(targetSchema).isEqualTo(Document.parse(PATIENT)); + } + + @Test // GH-3800 + public void csfleCyclic/*encryptedFieldsOnly*/() { + + MongoJsonSchema schema = MongoJsonSchemaCreator.create() // + .filter(MongoJsonSchemaCreator.encryptedOnly()) // filter non encrypted fields + .createSchemaFor(Cyclic.class); + + Document targetSchema = schema.schemaDocument(); + assertThat(targetSchema).isNotNull(); + } + + @Test // GH-3800 + public void csfleWithKeyFromProperties() { + + GenericApplicationContext applicationContext = new GenericApplicationContext(); + applicationContext.registerBean("encryptionExtension", EncryptionExtension.class, () -> new EncryptionExtension()); + applicationContext.refresh(); + + MongoMappingContext mappingContext = new MongoMappingContext(); + mappingContext.setApplicationContext(applicationContext); + mappingContext.afterPropertiesSet(); + + MongoJsonSchema schema = MongoJsonSchemaCreator.create(mappingContext) // + .filter(MongoJsonSchemaCreator.encryptedOnly()) // + .createSchemaFor(EncryptionMetadataFromProperty.class); + + assertThat(schema.schemaDocument()).isEqualTo(Document.parse(ENC_FROM_PROPERTY_SCHEMA)); + } + + @Test // GH-3800 + public void csfleWithKeyFromMethod() { + + GenericApplicationContext applicationContext = new GenericApplicationContext(); + applicationContext.registerBean("encryptionExtension", EncryptionExtension.class, () -> new EncryptionExtension()); + applicationContext.refresh(); + + MongoMappingContext mappingContext = new MongoMappingContext(); + mappingContext.setApplicationContext(applicationContext); + mappingContext.afterPropertiesSet(); + + MongoJsonSchema schema = MongoJsonSchemaCreator.create(mappingContext) // + .filter(MongoJsonSchemaCreator.encryptedOnly()) // + .createSchemaFor(EncryptionMetadataFromMethod.class); + + assertThat(schema.schemaDocument()).isEqualTo(Document.parse(ENC_FROM_METHOD_SCHEMA)); + } + // --> TYPES AND JSON // --> ENUM @@ -125,8 +187,7 @@ public class MappingMongoJsonSchemaCreatorUnitTests { " 'collectionProperty' : { 'type' : 'array' }," + // " 'mapProperty' : { 'type' : 'object' }," + // " 'objectProperty' : { 'type' : 'object' }," + // - " 'enumProperty' : " + JUST_SOME_ENUM + // - " }" + // + " 'enumProperty' : " + JUST_SOME_ENUM + " }" + // "}"; static class VariousFieldTypes { @@ -249,4 +310,209 @@ public class MappingMongoJsonSchemaCreatorUnitTests { } } + static final String PATIENT = "{" + // + " 'type': 'object'," + // + " 'encryptMetadata': {" + // + " 'keyId': [" + // + " {" + // + " '$binary': {" + // + " 'base64': 'xKVup8B1Q+CkHaVRx+qa+g=='," + // + " 'subType': '04'" + // + " }" + // + " }" + // + " ]" + // + " }," + // + " 'properties': {" + // + " 'ssn': {" + // + " 'encrypt': {" + // + " 'bsonType': 'int'," + // + " 'algorithm': 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'" + // + " }" + // + " }," + // + " 'bloodType': {" + // + " 'encrypt': {" + // + " 'bsonType': 'string'," + // + " 'algorithm': 'AEAD_AES_256_CBC_HMAC_SHA_512-Random'" + // + " }" + // + " }," + // + " 'medicalRecords': {" + // + " 'encrypt': {" + // + " 'bsonType': 'array'," + // + " 'algorithm': 'AEAD_AES_256_CBC_HMAC_SHA_512-Random'" + // + " }" + // + " }," + // + " 'insurance': {" + // + " 'type': 'object'," + // + " 'properties': {" + // + " 'policyNumber': {" + // + " 'encrypt': {" + // + " 'bsonType': 'int'," + // + " 'algorithm': 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'" + // + " }" + // + " }" + // + " }" + // + " }" + // + " }" + // + "}"; + + @Encrypted(keyId = "xKVup8B1Q+CkHaVRx+qa+g==") + static class Patient { + String name; + + @Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic") // + Integer ssn; + + @Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Random") // + String bloodType; + + String keyAltNameField; + + @Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Random") // + List> medicalRecords; + + Insurance insurance; + } + + static class Insurance { + + @Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic") // + Integer policyNumber; + + String provider; + } + + static final String ENC_FROM_PROPERTY_ENTITY_KEY = "C5a5aMB7Ttq4wSJTFeRn8g=="; + static final String ENC_FROM_PROPERTY_PROPOERTY_KEY = "Mw6mdTVPQfm4quqSCLVB3g="; + static final String ENC_FROM_PROPERTY_SCHEMA = "{" + // + " 'encryptMetadata': {" + // + " 'keyId': [" + // + " {" + // + " '$binary': {" + // + " 'base64': '" + ENC_FROM_PROPERTY_ENTITY_KEY + "'," + // + " 'subType': '04'" + // + " }" + // + " }" + // + " ]" + // + " }," + // + " 'type': 'object'," + // + " 'properties': {" + // + " 'policyNumber': {" + // + " 'encrypt': {" + // + " 'keyId': [" + // + " [" + // + " {" + // + " '$binary': {" + // + " 'base64': '" + ENC_FROM_PROPERTY_PROPOERTY_KEY + "'," + // + " 'subType': '04'" + // + " }" + // + " }" + // + " ]" + // + " ]," + // + " 'bsonType': 'int'," + // + " 'algorithm': 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'" + // + " }" + // + " }" + // + " }" + // + "}"; + + @Encrypted(keyId = "#{entityKey}") + static class EncryptionMetadataFromProperty { + + @Encrypted(keyId = "#{propertyKey}", algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic") // + Integer policyNumber; + + String provider; + } + + static final String ENC_FROM_METHOD_ENTITY_KEY = "4fPYFM9qSgyRAjgQ2u+IMQ=="; + static final String ENC_FROM_METHOD_PROPOERTY_KEY = "+idiseKwTVCJfSKC3iUeYQ=="; + static final String ENC_FROM_METHOD_SCHEMA = "{" + // + " 'encryptMetadata': {" + // + " 'keyId': [" + // + " {" + // + " '$binary': {" + // + " 'base64': '" + ENC_FROM_METHOD_ENTITY_KEY + "'," + // + " 'subType': '04'" + // + " }" + // + " }" + // + " ]" + // + " }," + // + " 'type': 'object'," + // + " 'properties': {" + // + " 'policyNumber': {" + // + " 'encrypt': {" + // + " 'keyId': [" + // + " [" + // + " {" + // + " '$binary': {" + // + " 'base64': '" + ENC_FROM_METHOD_PROPOERTY_KEY + "'," + // + " 'subType': '04'" + // + " }" + // + " }" + // + " ]" + // + " ]," + // + " 'bsonType': 'int'," + // + " 'algorithm': 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'" + // + " }" + // + " }" + // + " }" + // + "}"; + + @Encrypted(keyId = "#{mongocrypt.keyId(#target)}") + static class EncryptionMetadataFromMethod { + + @Encrypted(keyId = "#{mongocrypt.keyId(#target)}", algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic") // + Integer policyNumber; + + String provider; + } + + public static class EncryptionExtension implements EvaluationContextExtension { + + /* + * (non-Javadoc) + * @see org.springframework.data.spel.spi.EvaluationContextExtension#getExtensionId() + */ + @Override + public String getExtensionId() { + return "mongocrypt"; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.spel.spi.EvaluationContextExtension#getProperties() + */ + @Override + public Map getProperties() { + + Map properties = new LinkedHashMap<>(); + properties.put("entityKey", ENC_FROM_PROPERTY_ENTITY_KEY); + properties.put("propertyKey", ENC_FROM_PROPERTY_PROPOERTY_KEY); + return properties; + } + + @Override + public Map getFunctions() { + try { + return Collections.singletonMap("keyId", + new Function(EncryptionExtension.class.getMethod("keyId", String.class), this)); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } + return Collections.emptyMap(); + } + + public String keyId(String target) { + + if (target.equals("EncryptionMetadataFromMethod")) { + return ENC_FROM_METHOD_ENTITY_KEY; + } + + if (target.equals("EncryptionMetadataFromMethod.policyNumber")) { + return ENC_FROM_METHOD_PROPOERTY_KEY; + } + + return "xKVup8B1Q+CkHaVRx+qa+g=="; + } + } } diff --git a/src/main/asciidoc/reference/mongo-json-schema.adoc b/src/main/asciidoc/reference/mongo-json-schema.adoc index 5a426061a..36c85f6fb 100644 --- a/src/main/asciidoc/reference/mongo-json-schema.adoc +++ b/src/main/asciidoc/reference/mongo-json-schema.adoc @@ -225,6 +225,109 @@ MongoJsonSchema schema = MongoJsonSchema.builder() ---- ==== +Instead of defining encrypted fields manually it is possible leverage the `@Encrypted` annotation as shown in the snippet below. + +.Client-Side Field Level Encryption via Json Schema +==== +[source,java] +---- +@Document +@Encrypted(keyId = "xKVup8B1Q+CkHaVRx+qa+g==", algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Random") <1> +static class Patient { + + @Id String id; + String name; + + @Encrypted <2> + String bloodType; + + @Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic") <3> + Integer ssn; +} +---- +<1> Default encryption settings that will be set for `encryptMetadata`. +<2> Encrypted field using default encryption settings. +<3> Encrypted field overriding the default encryption algorithm. +==== + +[TIP] +==== +The `@Encrypted` Annoation supports resolving keyIds via SpEL Expressions. +To do so additional environment metadata (via the `MappingContext`) is required and must be provided. + +[source,java] +---- +@Document +@Encrypted(keyId = "#{mongocrypt.keyId(#target)}") +static class Patient { + + @Id String id; + String name; + + @Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Random") + String bloodType; + + @Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic") + Integer ssn; +} + +MongoJsonSchemaCreator schemaCreator = MongoJsonSchemaCreator.create(mappingContext); +MongoJsonSchema patientSchema = schemaCreator + .filter(MongoJsonSchemaCreator.encryptedOnly()) + .createSchemaFor(Patient.class); +---- + +The `mongocrypt.keyId` function is defined via an `EvaluationContextExtension` as shown in the snippet below. +Providing a custom extension provides the most flexible way of computing keyIds. + +[source,java] +---- +public class EncryptionExtension implements EvaluationContextExtension { + + @Override + public String getExtensionId() { + return "mongocrypt"; + } + + @Override + public Map getFunctions() { + return Collections.singletonMap("keyId", new Function(getMethod("computeKeyId", String.class), this)); + } + + public String computeKeyId(String target) { + // ... lookup via target element name + } +} +---- + +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]]