Add support for non-queryable encrypted fields in CollectionOptions.
Closes #4988 Original pull request: #4992
This commit is contained in:
committed by
Mark Paluch
parent
5d5e4748bf
commit
194746a9ed
@@ -34,6 +34,7 @@ import org.jspecify.annotations.Nullable;
|
||||
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 class CollectionOptions {
|
||||
private static final EncryptedFieldsOptions NONE = new EncryptedFieldsOptions();
|
||||
|
||||
private final @Nullable MongoJsonSchema schema;
|
||||
private final List<QueryableJsonSchemaProperty> queryableProperties;
|
||||
private final List<JsonSchemaProperty> properties;
|
||||
|
||||
EncryptedFieldsOptions() {
|
||||
this(null, List.of());
|
||||
}
|
||||
|
||||
private EncryptedFieldsOptions(@Nullable MongoJsonSchema schema,
|
||||
List<QueryableJsonSchemaProperty> queryableProperties) {
|
||||
private EncryptedFieldsOptions(@Nullable MongoJsonSchema schema, List<JsonSchemaProperty> queryableProperties) {
|
||||
|
||||
this.schema = schema;
|
||||
this.queryableProperties = queryableProperties;
|
||||
this.properties = queryableProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -711,7 +711,7 @@ public class CollectionOptions {
|
||||
/**
|
||||
* @return new instance of {@link EncryptedFieldsOptions}.
|
||||
*/
|
||||
public static EncryptedFieldsOptions fromProperties(List<QueryableJsonSchemaProperty> properties) {
|
||||
public static EncryptedFieldsOptions fromProperties(List<JsonSchemaProperty> properties) {
|
||||
return new EncryptedFieldsOptions(null, List.copyOf(properties));
|
||||
}
|
||||
|
||||
@@ -731,13 +731,50 @@ public class CollectionOptions {
|
||||
@CheckReturnValue
|
||||
public EncryptedFieldsOptions queryable(JsonSchemaProperty property, QueryCharacteristic... characteristics) {
|
||||
|
||||
List<QueryableJsonSchemaProperty> targetPropertyList = new ArrayList<>(queryableProperties.size() + 1);
|
||||
targetPropertyList.addAll(queryableProperties);
|
||||
List<JsonSchemaProperty> 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<JsonSchemaProperty> 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 @@ public class CollectionOptions {
|
||||
|
||||
private List<Document> fromProperties() {
|
||||
|
||||
if (queryableProperties.isEmpty()) {
|
||||
if (properties.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Document> converted = new ArrayList<>(queryableProperties.size());
|
||||
for (QueryableJsonSchemaProperty property : queryableProperties) {
|
||||
List<Document> converted = new ArrayList<>(properties.size());
|
||||
for (JsonSchemaProperty property : properties) {
|
||||
|
||||
Document field = new Document("path", property.getIdentifier());
|
||||
|
||||
@@ -769,7 +806,7 @@ public class CollectionOptions {
|
||||
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 @@ public class CollectionOptions {
|
||||
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 @@ public class CollectionOptions {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,22 +89,25 @@ class CollectionOptionsUnitTests {
|
||||
.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
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
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;
|
||||
@@ -93,12 +94,16 @@ public class MongoQueryableEncryptionCollectionCreationTests {
|
||||
assertThat(encryptedFields).containsKey("fields");
|
||||
|
||||
List<Document> 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' }}")));
|
||||
@@ -116,16 +121,18 @@ public class MongoQueryableEncryptionCollectionCreationTests {
|
||||
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)) //
|
||||
.queryable(encrypted(JsonSchemaProperty.float64("encryptedDouble")).keys(key3),
|
||||
range().min(-1.123D).max(1.123D).precision(5).contention(1)));
|
||||
|
||||
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))))
|
||||
.property(queryable(encrypted(float64("encryptedDouble")).keyId(key3),
|
||||
List.of(range().min(-1.123D).max(1.123D).precision(5).contention(1))))
|
||||
|
||||
@@ -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.BsonDocument;
|
||||
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 @@ class RangeEncryptionTests {
|
||||
|
||||
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 @@ class RangeEncryptionTests {
|
||||
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 @@ class RangeEncryptionTests {
|
||||
});
|
||||
|
||||
assertThat(result).containsEntry("encryptedInt", 101);
|
||||
assertThat(result).containsEntry("age", 101);
|
||||
assertThat(result).containsEntry("ssn", "6-4-20");
|
||||
}
|
||||
|
||||
@Test // GH-4185
|
||||
@@ -283,6 +284,7 @@ class RangeEncryptionTests {
|
||||
source.encryptedLong = 1001L;
|
||||
source.nested = new NestedWithQEFields();
|
||||
source.nested.value = "Luigi time!";
|
||||
source.ssn = "6-4-20";
|
||||
return source;
|
||||
}
|
||||
|
||||
@@ -480,6 +482,10 @@ class RangeEncryptionTests {
|
||||
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 @@ class RangeEncryptionTests {
|
||||
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 @@ class RangeEncryptionTests {
|
||||
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 + '}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user