Skip to content

Commit b7d7d45

Browse files
christophstroblmp911de
authored andcommitted
Add support for non-queryable encrypted fields in CollectionOptions.
Closes #4988 Original pull request: #4992
1 parent 1a3e8aa commit b7d7d45

File tree

5 files changed

+124
-40
lines changed

5 files changed

+124
-40
lines changed

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.springframework.data.mongodb.core.mapping.Field;
3434
import org.springframework.data.mongodb.core.query.Collation;
3535
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty;
36+
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty;
3637
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.QueryableJsonSchemaProperty;
3738
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
3839
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
@@ -678,17 +679,16 @@ public static class EncryptedFieldsOptions {
678679
private static final EncryptedFieldsOptions NONE = new EncryptedFieldsOptions();
679680

680681
private final @Nullable MongoJsonSchema schema;
681-
private final List<QueryableJsonSchemaProperty> queryableProperties;
682+
private final List<JsonSchemaProperty> properties;
682683

683684
EncryptedFieldsOptions() {
684685
this(null, List.of());
685686
}
686687

687-
private EncryptedFieldsOptions(@Nullable MongoJsonSchema schema,
688-
List<QueryableJsonSchemaProperty> queryableProperties) {
688+
private EncryptedFieldsOptions(@Nullable MongoJsonSchema schema, List<JsonSchemaProperty> queryableProperties) {
689689

690690
this.schema = schema;
691-
this.queryableProperties = queryableProperties;
691+
this.properties = queryableProperties;
692692
}
693693

694694
/**
@@ -708,7 +708,7 @@ public static EncryptedFieldsOptions fromSchema(MongoJsonSchema schema) {
708708
/**
709709
* @return new instance of {@link EncryptedFieldsOptions}.
710710
*/
711-
public static EncryptedFieldsOptions fromProperties(List<QueryableJsonSchemaProperty> properties) {
711+
public static EncryptedFieldsOptions fromProperties(List<JsonSchemaProperty> properties) {
712712
return new EncryptedFieldsOptions(null, List.copyOf(properties));
713713
}
714714

@@ -728,13 +728,50 @@ public static EncryptedFieldsOptions fromProperties(List<QueryableJsonSchemaProp
728728
@CheckReturnValue
729729
public EncryptedFieldsOptions queryable(JsonSchemaProperty property, QueryCharacteristic... characteristics) {
730730

731-
List<QueryableJsonSchemaProperty> targetPropertyList = new ArrayList<>(queryableProperties.size() + 1);
732-
targetPropertyList.addAll(queryableProperties);
731+
List<JsonSchemaProperty> targetPropertyList = new ArrayList<>(properties.size() + 1);
732+
targetPropertyList.addAll(properties);
733733
targetPropertyList.add(JsonSchemaProperty.queryable(property, List.of(characteristics)));
734734

735735
return new EncryptedFieldsOptions(schema, targetPropertyList);
736736
}
737737

738+
/**
739+
* Add an {@link EncryptedJsonSchemaProperty encrypted property} that should not be queryable.
740+
*
741+
* @param property must not be {@literal null}.
742+
* @return new instance of {@link EncryptedFieldsOptions}.
743+
*/
744+
@Contract("_ -> new")
745+
@CheckReturnValue
746+
public EncryptedFieldsOptions with(EncryptedJsonSchemaProperty property) {
747+
return encrypted(property, null);
748+
}
749+
750+
/**
751+
* Add a {@link JsonSchemaProperty property} that should not be encrypted but not queryable.
752+
*
753+
* @param property must not be {@literal null}.
754+
* @param key can be {@literal null}.
755+
* @return new instance of {@link EncryptedFieldsOptions}.
756+
*/
757+
@Contract("_, _ -> new")
758+
@CheckReturnValue
759+
public EncryptedFieldsOptions encrypted(JsonSchemaProperty property, @Nullable Object key) {
760+
761+
List<JsonSchemaProperty> targetPropertyList = new ArrayList<>(properties.size() + 1);
762+
targetPropertyList.addAll(properties);
763+
if (property instanceof IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty) {
764+
targetPropertyList.add(property);
765+
} else {
766+
EncryptedJsonSchemaProperty encryptedJsonSchemaProperty = new EncryptedJsonSchemaProperty(property);
767+
if (key != null) {
768+
targetPropertyList.add(encryptedJsonSchemaProperty.keyId(key));
769+
}
770+
}
771+
772+
return new EncryptedFieldsOptions(schema, targetPropertyList);
773+
}
774+
738775
public Document toDocument() {
739776
return new Document("fields", selectPaths());
740777
}
@@ -753,20 +790,20 @@ private List<Document> selectPaths() {
753790

754791
private List<Document> fromProperties() {
755792

756-
if (queryableProperties.isEmpty()) {
793+
if (properties.isEmpty()) {
757794
return List.of();
758795
}
759796

760-
List<Document> converted = new ArrayList<>(queryableProperties.size());
761-
for (QueryableJsonSchemaProperty property : queryableProperties) {
797+
List<Document> converted = new ArrayList<>(properties.size());
798+
for (JsonSchemaProperty property : properties) {
762799

763800
Document field = new Document("path", property.getIdentifier());
764801

765802
if (!property.getTypes().isEmpty()) {
766803
field.append("bsonType", property.getTypes().iterator().next().toBsonType().value());
767804
}
768805

769-
if (property
806+
if (property instanceof QueryableJsonSchemaProperty qproperty && qproperty
770807
.getTargetProperty() instanceof IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty encrypted) {
771808
if (encrypted.getKeyId() != null) {
772809
if (encrypted.getKeyId() instanceof String stringKey) {
@@ -776,11 +813,21 @@ private List<Document> fromProperties() {
776813
field.append("keyId", encrypted.getKeyId());
777814
}
778815
}
816+
} else if (property instanceof IdentifiableJsonSchemaProperty.EncryptedJsonSchemaProperty encrypted) {
817+
if (encrypted.getKeyId() != null) {
818+
if (encrypted.getKeyId() instanceof String stringKey) {
819+
field.append("keyId",
820+
new BsonBinary(BsonBinarySubType.UUID_STANDARD, stringKey.getBytes(StandardCharsets.UTF_8)));
821+
} else {
822+
field.append("keyId", encrypted.getKeyId());
823+
}
824+
}
779825
}
780826

781-
field.append("queries", StreamSupport.stream(property.getCharacteristics().spliterator(), false)
782-
.map(QueryCharacteristic::toDocument).toList());
783-
827+
if (property instanceof QueryableJsonSchemaProperty qproperty) {
828+
field.append("queries", StreamSupport.stream(qproperty.getCharacteristics().spliterator(), false)
829+
.map(QueryCharacteristic::toDocument).toList());
830+
}
784831
if (!field.containsKey("keyId")) {
785832
field.append("keyId", BsonNull.VALUE);
786833
}
@@ -809,7 +856,9 @@ private List<Document> fromSchema() {
809856
if (entry.getValue().containsKey("bsonType")) {
810857
field.append("bsonType", entry.getValue().get("bsonType"));
811858
}
812-
field.put("queries", entry.getValue().get("queries"));
859+
if (entry.getValue().containsKey("queries")) {
860+
field.put("queries", entry.getValue().get("queries"));
861+
}
813862
fields.add(field);
814863
}
815864
}

spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,25 @@ void validatorEquals() {
8989
.isNotEqualTo(empty().validator(Validator.document(new Document("one", "two"))).moderateValidation());
9090
}
9191

92-
@Test // GH-4185
92+
@Test // GH-4185, GH-4988
9393
@SuppressWarnings("unchecked")
9494
void queryableEncryptionOptionsFromSchemaRenderCorrectly() {
9595

9696
MongoJsonSchema schema = MongoJsonSchema.builder()
9797
.property(JsonSchemaProperty.object("spring")
9898
.properties(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int32("data")), List.of())))
99-
.property(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("mongodb")), List.of())).build();
99+
.property(queryable(JsonSchemaProperty.encrypted(JsonSchemaProperty.int64("mongodb")), List.of()))
100+
.property(JsonSchemaProperty.encrypted(JsonSchemaProperty.string("rocks"))).build();
100101

101102
EncryptedFieldsOptions encryptionOptions = EncryptedFieldsOptions.fromSchema(schema);
102103

103-
assertThat(encryptionOptions.toDocument().get("fields", List.class)).hasSize(2)
104+
assertThat(encryptionOptions.toDocument().get("fields", List.class)).hasSize(3)
104105
.contains(new Document("path", "mongodb").append("bsonType", "long").append("queries", List.of())
105106
.append("keyId", BsonNull.VALUE))
106107
.contains(new Document("path", "spring.data").append("bsonType", "int").append("queries", List.of())
107-
.append("keyId", BsonNull.VALUE));
108+
.append("keyId", BsonNull.VALUE))
109+
.contains(new Document("path", "rocks").append("bsonType", "string").append("keyId", BsonNull.VALUE));
110+
108111
}
109112

110113
@Test // GH-4185

spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/MongoQueryableEncryptionCollectionCreationTests.java

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.junit.jupiter.params.ParameterizedTest;
3232
import org.junit.jupiter.params.provider.Arguments;
3333
import org.junit.jupiter.params.provider.MethodSource;
34+
3435
import org.springframework.beans.factory.annotation.Autowired;
3536
import org.springframework.context.annotation.Configuration;
3637
import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration;
@@ -93,12 +94,16 @@ public void createsCollectionWithEncryptedFieldsCorrectly(CollectionOptions coll
9394
assertThat(encryptedFields).containsKey("fields");
9495

9596
List<Document> fields = encryptedFields.get("fields", List.of());
96-
assertThat(fields.get(0)).containsEntry("path", "encryptedInt") //
97+
assertThat(fields.get(0)).containsEntry("path", "encrypted-but-not-queryable") //
98+
.containsEntry("bsonType", "int") //
99+
.doesNotContainKey("queries");
100+
101+
assertThat(fields.get(1)).containsEntry("path", "encryptedInt") //
97102
.containsEntry("bsonType", "int") //
98103
.containsEntry("queries", List
99104
.of(Document.parse("{'queryType': 'range', 'contention': { '$numberLong' : '1' }, 'min': 5, 'max': 100}")));
100105

101-
assertThat(fields.get(1)).containsEntry("path", "nested.encryptedLong") //
106+
assertThat(fields.get(2)).containsEntry("path", "nested.encryptedLong") //
102107
.containsEntry("bsonType", "long") //
103108
.containsEntry("queries", List.of(Document.parse(
104109
"{'queryType': 'range', 'contention': { '$numberLong' : '0' }, 'min': { '$numberLong' : '-1' }, 'max': { '$numberLong' : '1' }}")));
@@ -116,16 +121,18 @@ private static Stream<Arguments> collectionOptions() {
116121
BsonBinary key3 = new BsonBinary(UUID.randomUUID(), UuidRepresentation.STANDARD);
117122

118123
CollectionOptions manualOptions = CollectionOptions.encryptedCollection(options -> options //
119-
.queryable(encrypted(int32("encryptedInt")).keys(key1), range().min(5).max(100).contention(1)) //
120-
.queryable(encrypted(JsonSchemaProperty.int64("nested.encryptedLong")).keys(key2),
124+
.encrypted(int32("encrypted-but-not-queryable"), key1) //
125+
.queryable(encrypted(int32("encryptedInt")).keyId(key2), range().min(5).max(100).contention(1)) //
126+
.queryable(encrypted(JsonSchemaProperty.int64("nested.encryptedLong")).keyId(key3),
121127
range().min(-1L).max(1L).contention(0)) //
122128
.queryable(encrypted(JsonSchemaProperty.float64("encryptedDouble")).keys(key3),
123129
range().min(-1.123D).max(1.123D).precision(5).contention(1)));
124130

125-
CollectionOptions schemaOptions = CollectionOptions.encryptedCollection(MongoJsonSchema.builder()
131+
CollectionOptions schemaOptions = CollectionOptions.encryptedCollection(MongoJsonSchema.builder() //
132+
.property(encrypted(int32("encrypted-but-not-queryable")).keyId(key1)) //
126133
.property(
127-
queryable(encrypted(int32("encryptedInt")).keyId(key1), List.of(range().min(5).max(100).contention(1))))
128-
.property(queryable(encrypted(int64("nested.encryptedLong")).keyId(key2),
134+
queryable(encrypted(int32("encryptedInt")).keyId(key2), List.of(range().min(5).max(100).contention(1))))
135+
.property(queryable(encrypted(int64("nested.encryptedLong")).keyId(key3),
129136
List.of(range().min(-1L).max(1L).contention(0))))
130137
.property(queryable(encrypted(float64("encryptedDouble")).keyId(key3),
131138
List.of(range().min(-1.123D).max(1.123D).precision(5).contention(1))))

spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
*/
1616
package org.springframework.data.mongodb.core.encryption;
1717

18-
import static org.assertj.core.api.Assertions.*;
19-
import static org.springframework.data.mongodb.core.query.Criteria.*;
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
20+
import static org.springframework.data.mongodb.core.query.Criteria.where;
2021

2122
import java.security.SecureRandom;
2223
import java.util.LinkedHashMap;
@@ -32,13 +33,10 @@
3233
import org.bson.BsonInt32;
3334
import org.bson.BsonString;
3435
import org.bson.Document;
35-
import org.junit.Before;
3636
import org.junit.jupiter.api.AfterEach;
37-
import org.junit.jupiter.api.BeforeAll;
3837
import org.junit.jupiter.api.BeforeEach;
3938
import org.junit.jupiter.api.Test;
4039
import org.junit.jupiter.api.extension.ExtendWith;
41-
4240
import org.springframework.beans.factory.DisposableBean;
4341
import org.springframework.beans.factory.annotation.Autowired;
4442
import org.springframework.context.ApplicationContext;
@@ -125,18 +123,19 @@ void manuallyEncryptedValuesCanBeSavedAndRetrievedCorrectly() {
125123

126124
EncryptOptions equalityEncOptions = new EncryptOptions("Indexed").contentionFactor(0L)
127125
.keyId(keyHolder.getEncryptionKey("age"));
128-
;
129126

130127
EncryptOptions equalityEncOptionsString = new EncryptOptions("Indexed").contentionFactor(0L)
131128
.keyId(keyHolder.getEncryptionKey("name"));
132-
;
129+
130+
EncryptOptions justEncryptOptions = new EncryptOptions("Unindexed").keyId(keyHolder.getEncryptionKey("ssn"));
133131

134132
Document source = new Document("_id", "id-1");
135133

136134
source.put("name",
137135
clientEncryption.getClientEncryption().encrypt(new BsonString("It's a Me, Mario!"), equalityEncOptionsString));
138136
source.put("age", clientEncryption.getClientEncryption().encrypt(new BsonInt32(101), equalityEncOptions));
139137
source.put("encryptedInt", clientEncryption.getClientEncryption().encrypt(new BsonInt32(101), encryptOptions));
138+
source.put("ssn", clientEncryption.getClientEncryption().encrypt(new BsonString("6-4-20"), justEncryptOptions));
140139
source.put("_class", Person.class.getName());
141140

142141
template.execute(Person.class, col -> col.insertOne(source));
@@ -151,6 +150,8 @@ void manuallyEncryptedValuesCanBeSavedAndRetrievedCorrectly() {
151150
});
152151

153152
assertThat(result).containsEntry("encryptedInt", 101);
153+
assertThat(result).containsEntry("age", 101);
154+
assertThat(result).containsEntry("ssn", "6-4-20");
154155
}
155156

156157
@Test // GH-4185
@@ -283,6 +284,7 @@ private Person createPerson() {
283284
source.encryptedLong = 1001L;
284285
source.nested = new NestedWithQEFields();
285286
source.nested.value = "Luigi time!";
287+
source.ssn = "6-4-20";
286288
return source;
287289
}
288290

@@ -480,6 +482,10 @@ static class Person {
480482
rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") //
481483
Long encryptedLong;
482484

485+
@ValueConverter(MongoEncryptionConverter.class)
486+
@Encrypted(algorithm = "Unindexed") // encrypted, nothing else!
487+
String ssn;
488+
483489
NestedWithQEFields nested;
484490

485491
public String getId() {
@@ -514,6 +520,14 @@ public void setEncryptedLong(Long encryptedLong) {
514520
this.encryptedLong = encryptedLong;
515521
}
516522

523+
public String getSsn() {
524+
return ssn;
525+
}
526+
527+
public void setSsn(String ssn) {
528+
this.ssn = ssn;
529+
}
530+
517531
@Override
518532
public boolean equals(Object o) {
519533
if (o == this) {
@@ -525,18 +539,20 @@ public boolean equals(Object o) {
525539
Person person = (Person) o;
526540
return Objects.equals(id, person.id) && Objects.equals(unencryptedValue, person.unencryptedValue)
527541
&& Objects.equals(name, person.name) && Objects.equals(age, person.age)
528-
&& Objects.equals(encryptedInt, person.encryptedInt) && Objects.equals(encryptedLong, person.encryptedLong);
542+
&& Objects.equals(encryptedInt, person.encryptedInt) && Objects.equals(encryptedLong, person.encryptedLong)
543+
&& Objects.equals(ssn, person.ssn);
529544
}
530545

531546
@Override
532547
public int hashCode() {
533-
return Objects.hash(id, unencryptedValue, name, age, encryptedInt, encryptedLong);
548+
return Objects.hash(id, unencryptedValue, name, age, encryptedInt, encryptedLong, ssn);
534549
}
535550

536551
@Override
537552
public String toString() {
538553
return "Person{" + "id='" + id + '\'' + ", unencryptedValue='" + unencryptedValue + '\'' + ", name='" + name
539-
+ '\'' + ", age=" + age + ", encryptedInt=" + encryptedInt + ", encryptedLong=" + encryptedLong + '}';
554+
+ '\'' + ", age=" + age + ", encryptedInt=" + encryptedInt + ", encryptedLong=" + encryptedLong + ", ssn="
555+
+ ssn + '}';
540556
}
541557
}
542558

src/main/antora/modules/ROOT/pages/mongodb/mongo-encryption.adoc

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,10 @@ Manual Collection Setup::
141141
[source,java,indent=0,subs="verbatim,quotes",role="primary"]
142142
----
143143
CollectionOptions collectionOptions = CollectionOptions.encryptedCollection(options -> options
144-
.queryable(encrypted(string("ssn")).algorithm("Indexed"), equality().contention(0))
145-
.queryable(encrypted(int32("age")).algorithm("Range"), range().contention(8).min(0).max(150))
146-
.queryable(encrypted(int64("address.sign")).algorithm("Range"), range().contention(2).min(-10L).max(10L))
144+
.queryable(encrypted(string("ssn")).algorithm("Indexed"), equality().contention(0))
145+
.queryable(encrypted(int32("age")).algorithm("Range"), range().contention(8).min(0).max(150))
146+
.encrypted(string("pin"))
147+
.queryable(encrypted(int64("address.sign")).algorithm("Range"), range().contention(2).min(-10L).max(10L))
147148
);
148149
149150
mongoTemplate.createCollection(Patient.class, collectionOptions); <1>
@@ -160,13 +161,16 @@ class Patient {
160161
161162
@Id String id;
162163
163-
@Encrypted(algorithm = "Indexed") //
164+
@Encrypted(algorithm = "Indexed")
164165
@Queryable(queryType = "equality", contentionFactor = 0)
165166
String ssn;
166167
167168
@RangeEncrypted(contentionFactor = 8, rangeOptions = "{ 'min' : 0, 'max' : 150 }")
168169
Integer age;
169170
171+
@Encrypted(algorithm = "Unindexed")
172+
String pin;
173+
170174
Address address;
171175
}
172176
@@ -210,6 +214,11 @@ MongoDB Collection Info::
210214
bsonType: 'int',
211215
queries: [ { queryType: 'range', contention: Long('8'), min: 0, max: 150 } ]
212216
},
217+
{
218+
keyId: ...,
219+
path: 'pin',
220+
bsonType: 'string'
221+
},
213222
{
214223
keyId: ...,
215224
path: 'address.sign',

0 commit comments

Comments
 (0)