diff --git a/pom.xml b/pom.xml index 95fc8379d9..5e2ced9221 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 5.0.0-SNAPSHOT + 5.0.x-GH-4988-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index fc88571622..00f356a093 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -15,7 +15,7 @@ org.springframework.data spring-data-mongodb-parent - 5.0.0-SNAPSHOT + 5.0.x-GH-4988-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 6f34da5660..ec008323d3 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-mongodb-parent - 5.0.0-SNAPSHOT + 5.0.x-GH-4988-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java index f4d1891703..287c4bd206 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java @@ -34,6 +34,7 @@ import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty; import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.QueryableJsonSchemaProperty; import org.springframework.data.mongodb.core.schema.JsonSchemaProperty; import org.springframework.data.mongodb.core.schema.MongoJsonSchema; @@ -681,17 +682,16 @@ public static class EncryptedFieldsOptions { private static final EncryptedFieldsOptions NONE = new EncryptedFieldsOptions(); private final @Nullable MongoJsonSchema schema; - private final List queryableProperties; + private final List properties; EncryptedFieldsOptions() { this(null, List.of()); } - private EncryptedFieldsOptions(@Nullable MongoJsonSchema schema, - List queryableProperties) { + private EncryptedFieldsOptions(@Nullable MongoJsonSchema schema, List queryableProperties) { this.schema = schema; - this.queryableProperties = queryableProperties; + this.properties = queryableProperties; } /** @@ -711,7 +711,7 @@ public static EncryptedFieldsOptions fromSchema(MongoJsonSchema schema) { /** * @return new instance of {@link EncryptedFieldsOptions}. */ - public static EncryptedFieldsOptions fromProperties(List properties) { + public static EncryptedFieldsOptions fromProperties(List properties) { return new EncryptedFieldsOptions(null, List.copyOf(properties)); } @@ -731,13 +731,50 @@ public static EncryptedFieldsOptions fromProperties(List targetPropertyList = new ArrayList<>(queryableProperties.size() + 1); - targetPropertyList.addAll(queryableProperties); + List targetPropertyList = new ArrayList<>(properties.size() + 1); + targetPropertyList.addAll(properties); targetPropertyList.add(JsonSchemaProperty.queryable(property, List.of(characteristics))); return new EncryptedFieldsOptions(schema, targetPropertyList); } + /** + * Add an {@link EncryptedJsonSchemaProperty encrypted property} that should not be queryable. + * + * @param property must not be {@literal null}. + * @return new instance of {@link EncryptedFieldsOptions}. + */ + @Contract("_ -> new") + @CheckReturnValue + public EncryptedFieldsOptions with(EncryptedJsonSchemaProperty property) { + return encrypted(property, null); + } + + /** + * Add a {@link JsonSchemaProperty property} that should not be encrypted but not queryable. + * + * @param property must not be {@literal null}. + * @param key can be {@literal null}. + * @return new instance of {@link EncryptedFieldsOptions}. + */ + @Contract("_, _ -> new") + @CheckReturnValue + public EncryptedFieldsOptions encrypted(JsonSchemaProperty property, @Nullable Object key) { + + List targetPropertyList = new ArrayList<>(properties.size() + 1); + targetPropertyList.addAll(properties); + if (property instanceof IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty) { + targetPropertyList.add(property); + } else { + EncryptedJsonSchemaProperty encryptedJsonSchemaProperty = new EncryptedJsonSchemaProperty(property); + if (key != null) { + targetPropertyList.add(encryptedJsonSchemaProperty.keyId(key)); + } + } + + return new EncryptedFieldsOptions(schema, targetPropertyList); + } + public Document toDocument() { return new Document("fields", selectPaths()); } @@ -756,12 +793,12 @@ private List selectPaths() { private List fromProperties() { - if (queryableProperties.isEmpty()) { + if (properties.isEmpty()) { return List.of(); } - List converted = new ArrayList<>(queryableProperties.size()); - for (QueryableJsonSchemaProperty property : queryableProperties) { + List converted = new ArrayList<>(properties.size()); + for (JsonSchemaProperty property : properties) { Document field = new Document("path", property.getIdentifier()); @@ -769,7 +806,7 @@ private List fromProperties() { field.append("bsonType", property.getTypes().iterator().next().toBsonType().value()); } - if (property + if (property instanceof QueryableJsonSchemaProperty qproperty && qproperty .getTargetProperty() instanceof IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty encrypted) { if (encrypted.getKeyId() != null) { if (encrypted.getKeyId() instanceof String stringKey) { @@ -779,11 +816,21 @@ private List fromProperties() { field.append("keyId", encrypted.getKeyId()); } } + } else if (property instanceof IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty encrypted) { + if (encrypted.getKeyId() != null) { + if (encrypted.getKeyId() instanceof String stringKey) { + field.append("keyId", + new BsonBinary(BsonBinarySubType.UUID_STANDARD, stringKey.getBytes(StandardCharsets.UTF_8))); + } else { + field.append("keyId", encrypted.getKeyId()); + } + } } - field.append("queries", StreamSupport.stream(property.getCharacteristics().spliterator(), false) - .map(QueryCharacteristic::toDocument).toList()); - + if (property instanceof QueryableJsonSchemaProperty qproperty) { + field.append("queries", StreamSupport.stream(qproperty.getCharacteristics().spliterator(), false) + .map(QueryCharacteristic::toDocument).toList()); + } if (!field.containsKey("keyId")) { field.append("keyId", BsonNull.VALUE); } @@ -812,7 +859,9 @@ private List fromSchema() { if (entry.getValue().containsKey("bsonType")) { field.append("bsonType", entry.getValue().get("bsonType")); } - field.put("queries", entry.getValue().get("queries")); + if (entry.getValue().containsKey("queries")) { + field.put("queries", entry.getValue().get("queries")); + } fields.add(field); } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java index 9de0863cd2..fd8c9fb972 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java @@ -89,22 +89,25 @@ void validatorEquals() { .isNotEqualTo(empty().validator(Validator.document(new Document("one", "two"))).moderateValidation()); } - @Test // GH-4185 + @Test // GH-4185, GH-4988 @SuppressWarnings("unchecked") void queryableEncryptionOptionsFromSchemaRenderCorrectly() { MongoJsonSchema schema = MongoJsonSchema.builder() .property(JsonSchemaProperty.object("spring") .properties(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int32("data")), List.of()))) - .property(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("mongodb")), List.of())).build(); + .property(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("mongodb")), List.of())) + .property(JsonSchemaProperty.encrypted(JsonSchemaProperty.string("rocks"))).build(); EncryptedFieldsOptions encryptionOptions = EncryptedFieldsOptions.fromSchema(schema); - assertThat(encryptionOptions.toDocument().get("fields", List.class)).hasSize(2) + assertThat(encryptionOptions.toDocument().get("fields", List.class)).hasSize(3) .contains(new Document("path", "mongodb").append("bsonType", "long").append("queries", List.of()) .append("keyId", BsonNull.VALUE)) .contains(new Document("path", "spring.data").append("bsonType", "int").append("queries", List.of()) - .append("keyId", BsonNull.VALUE)); + .append("keyId", BsonNull.VALUE)) + .contains(new Document("path", "rocks").append("bsonType", "string").append("keyId", BsonNull.VALUE)); + } @Test // GH-4185 diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java index b801d1770b..fc2b5426b8 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java @@ -15,9 +15,12 @@ */ package org.springframework.data.mongodb.core.encryption; -import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.*; -import static org.springframework.data.mongodb.core.schema.QueryCharacteristics.*; -import static org.springframework.data.mongodb.test.util.Assertions.*; +import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.encrypted; +import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.int32; +import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.int64; +import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.queryable; +import static org.springframework.data.mongodb.core.schema.QueryCharacteristics.range; +import static org.springframework.data.mongodb.test.util.Assertions.assertThat; import java.util.List; import java.util.UUID; @@ -31,7 +34,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; @@ -94,12 +96,16 @@ public void createsCollectionWithEncryptedFieldsCorrectly(CollectionOptions coll assertThat(encryptedFields).containsKey("fields"); List fields = encryptedFields.get("fields", List.of()); - assertThat(fields.get(0)).containsEntry("path", "encryptedInt") // + assertThat(fields.get(0)).containsEntry("path", "encrypted-but-not-queryable") // + .containsEntry("bsonType", "int") // + .doesNotContainKey("queries"); + + assertThat(fields.get(1)).containsEntry("path", "encryptedInt") // .containsEntry("bsonType", "int") // .containsEntry("queries", List .of(Document.parse("{'queryType': 'range', 'contention': { '$numberLong' : '1' }, 'min': 5, 'max': 100}"))); - assertThat(fields.get(1)).containsEntry("path", "nested.encryptedLong") // + assertThat(fields.get(2)).containsEntry("path", "nested.encryptedLong") // .containsEntry("bsonType", "long") // .containsEntry("queries", List.of(Document.parse( "{'queryType': 'range', 'contention': { '$numberLong' : '0' }, 'min': { '$numberLong' : '-1' }, 'max': { '$numberLong' : '1' }}"))); @@ -109,16 +115,19 @@ private static Stream collectionOptions() { BsonBinary key1 = new BsonBinary(UUID.randomUUID(), UuidRepresentation.STANDARD); BsonBinary key2 = new BsonBinary(UUID.randomUUID(), UuidRepresentation.STANDARD); + BsonBinary key3 = new BsonBinary(UUID.randomUUID(), UuidRepresentation.STANDARD); CollectionOptions manualOptions = CollectionOptions.encryptedCollection(options -> options // - .queryable(encrypted(int32("encryptedInt")).keys(key1), range().min(5).max(100).contention(1)) // - .queryable(encrypted(JsonSchemaProperty.int64("nested.encryptedLong")).keys(key2), + .encrypted(int32("encrypted-but-not-queryable"), key1) // + .queryable(encrypted(int32("encryptedInt")).keyId(key2), range().min(5).max(100).contention(1)) // + .queryable(encrypted(JsonSchemaProperty.int64("nested.encryptedLong")).keyId(key3), range().min(-1L).max(1L).contention(0))); - CollectionOptions schemaOptions = CollectionOptions.encryptedCollection(MongoJsonSchema.builder() + CollectionOptions schemaOptions = CollectionOptions.encryptedCollection(MongoJsonSchema.builder() // + .property(encrypted(int32("encrypted-but-not-queryable")).keyId(key1)) // .property( - queryable(encrypted(int32("encryptedInt")).keyId(key1), List.of(range().min(5).max(100).contention(1)))) - .property(queryable(encrypted(int64("nested.encryptedLong")).keyId(key2), + queryable(encrypted(int32("encryptedInt")).keyId(key2), List.of(range().min(5).max(100).contention(1)))) + .property(queryable(encrypted(int64("nested.encryptedLong")).keyId(key3), List.of(range().min(-1L).max(1L).contention(0)))) .build()); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java index e4e760cc91..01cbdec9d5 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java @@ -15,8 +15,9 @@ */ package org.springframework.data.mongodb.core.encryption; -import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.mongodb.core.query.Criteria.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.data.mongodb.core.query.Criteria.where; import java.security.SecureRandom; import java.util.LinkedHashMap; @@ -32,13 +33,10 @@ import org.bson.BsonInt32; import org.bson.BsonString; import org.bson.Document; -import org.junit.Before; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; 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; @@ -125,11 +123,11 @@ void manuallyEncryptedValuesCanBeSavedAndRetrievedCorrectly() { EncryptOptions equalityEncOptions = new EncryptOptions("Indexed").contentionFactor(0L) .keyId(keyHolder.getEncryptionKey("age")); - ; EncryptOptions equalityEncOptionsString = new EncryptOptions("Indexed").contentionFactor(0L) .keyId(keyHolder.getEncryptionKey("name")); - ; + + EncryptOptions justEncryptOptions = new EncryptOptions("Unindexed").keyId(keyHolder.getEncryptionKey("ssn")); Document source = new Document("_id", "id-1"); @@ -137,6 +135,7 @@ void manuallyEncryptedValuesCanBeSavedAndRetrievedCorrectly() { clientEncryption.getClientEncryption().encrypt(new BsonString("It's a Me, Mario!"), equalityEncOptionsString)); source.put("age", clientEncryption.getClientEncryption().encrypt(new BsonInt32(101), equalityEncOptions)); source.put("encryptedInt", clientEncryption.getClientEncryption().encrypt(new BsonInt32(101), encryptOptions)); + source.put("ssn", clientEncryption.getClientEncryption().encrypt(new BsonString("6-4-20"), justEncryptOptions)); source.put("_class", Person.class.getName()); template.execute(Person.class, col -> col.insertOne(source)); @@ -151,6 +150,8 @@ void manuallyEncryptedValuesCanBeSavedAndRetrievedCorrectly() { }); assertThat(result).containsEntry("encryptedInt", 101); + assertThat(result).containsEntry("age", 101); + assertThat(result).containsEntry("ssn", "6-4-20"); } @Test // GH-4185 @@ -283,6 +284,7 @@ private Person createPerson() { source.encryptedLong = 1001L; source.nested = new NestedWithQEFields(); source.nested.value = "Luigi time!"; + source.ssn = "6-4-20"; return source; } @@ -480,6 +482,10 @@ static class Person { rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") // Long encryptedLong; + @ValueConverter(MongoEncryptionConverter.class) + @Encrypted(algorithm = "Unindexed") // encrypted, nothing else! + String ssn; + NestedWithQEFields nested; public String getId() { @@ -514,6 +520,14 @@ public void setEncryptedLong(Long encryptedLong) { this.encryptedLong = encryptedLong; } + public String getSsn() { + return ssn; + } + + public void setSsn(String ssn) { + this.ssn = ssn; + } + @Override public boolean equals(Object o) { if (o == this) { @@ -525,18 +539,20 @@ public boolean equals(Object o) { Person person = (Person) o; return Objects.equals(id, person.id) && Objects.equals(unencryptedValue, person.unencryptedValue) && Objects.equals(name, person.name) && Objects.equals(age, person.age) - && Objects.equals(encryptedInt, person.encryptedInt) && Objects.equals(encryptedLong, person.encryptedLong); + && Objects.equals(encryptedInt, person.encryptedInt) && Objects.equals(encryptedLong, person.encryptedLong) + && Objects.equals(ssn, person.ssn); } @Override public int hashCode() { - return Objects.hash(id, unencryptedValue, name, age, encryptedInt, encryptedLong); + return Objects.hash(id, unencryptedValue, name, age, encryptedInt, encryptedLong, ssn); } @Override public String toString() { return "Person{" + "id='" + id + '\'' + ", unencryptedValue='" + unencryptedValue + '\'' + ", name='" + name - + '\'' + ", age=" + age + ", encryptedInt=" + encryptedInt + ", encryptedLong=" + encryptedLong + '}'; + + '\'' + ", age=" + age + ", encryptedInt=" + encryptedInt + ", encryptedLong=" + encryptedLong + ", ssn=" + + ssn + '}'; } } diff --git a/src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc b/src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc index 4c34c3831a..5a92168c2c 100644 --- a/src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc +++ b/src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc @@ -141,9 +141,10 @@ Manual Collection Setup:: [source,java,indent=0,subs="verbatim,quotes",role="primary"] ---- CollectionOptions collectionOptions = CollectionOptions.encryptedCollection(options -> options - .queryable(encrypted(string("ssn")).algorithm("Indexed"), equality().contention(0)) - .queryable(encrypted(int32("age")).algorithm("Range"), range().contention(8).min(0).max(150)) - .queryable(encrypted(int64("address.sign")).algorithm("Range"), range().contention(2).min(-10L).max(10L)) + .queryable(encrypted(string("ssn")).algorithm("Indexed"), equality().contention(0)) + .queryable(encrypted(int32("age")).algorithm("Range"), range().contention(8).min(0).max(150)) + .encrypted(string("pin")) + .queryable(encrypted(int64("address.sign")).algorithm("Range"), range().contention(2).min(-10L).max(10L)) ); mongoTemplate.createCollection(Patient.class, collectionOptions); <1> @@ -160,13 +161,16 @@ class Patient { @Id String id; - @Encrypted(algorithm = "Indexed") // + @Encrypted(algorithm = "Indexed") @Queryable(queryType = "equality", contentionFactor = 0) String ssn; @RangeEncrypted(contentionFactor = 8, rangeOptions = "{ 'min' : 0, 'max' : 150 }") Integer age; + @Encrypted(algorithm = "Unindexed") + String pin; + Address address; } @@ -210,6 +214,11 @@ MongoDB Collection Info:: bsonType: 'int', queries: [ { queryType: 'range', contention: Long('8'), min: 0, max: 150 } ] }, + { + keyId: ..., + path: 'pin', + bsonType: 'string' + }, { keyId: ..., path: 'address.sign',