14 changed files with 829 additions and 26 deletions
@ -0,0 +1,373 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2023-2024 the original author or authors. |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
package org.springframework.data.mongodb.core.encryption; |
||||||
|
|
||||||
|
import static java.util.Arrays.*; |
||||||
|
import static org.assertj.core.api.Assertions.*; |
||||||
|
import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*; |
||||||
|
import static org.springframework.data.mongodb.core.query.Criteria.*; |
||||||
|
|
||||||
|
import java.security.SecureRandom; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.Objects; |
||||||
|
import java.util.concurrent.atomic.AtomicReference; |
||||||
|
import java.util.function.Supplier; |
||||||
|
import java.util.stream.Collectors; |
||||||
|
|
||||||
|
import com.mongodb.AutoEncryptionSettings; |
||||||
|
import com.mongodb.ClientEncryptionSettings; |
||||||
|
import com.mongodb.ConnectionString; |
||||||
|
import com.mongodb.MongoClientSettings; |
||||||
|
import com.mongodb.MongoNamespace; |
||||||
|
import com.mongodb.client.MongoClient; |
||||||
|
import com.mongodb.client.MongoClients; |
||||||
|
import com.mongodb.client.MongoCollection; |
||||||
|
import com.mongodb.client.MongoDatabase; |
||||||
|
import com.mongodb.client.model.CreateCollectionOptions; |
||||||
|
import com.mongodb.client.model.CreateEncryptedCollectionParams; |
||||||
|
import com.mongodb.client.model.Filters; |
||||||
|
import com.mongodb.client.model.IndexOptions; |
||||||
|
import com.mongodb.client.model.Indexes; |
||||||
|
import com.mongodb.client.vault.ClientEncryption; |
||||||
|
import com.mongodb.client.vault.ClientEncryptions; |
||||||
|
|
||||||
|
import org.assertj.core.api.Assumptions; |
||||||
|
import org.bson.BsonArray; |
||||||
|
import org.bson.BsonBinary; |
||||||
|
import org.bson.BsonDocument; |
||||||
|
import org.bson.BsonInt32; |
||||||
|
import org.bson.BsonInt64; |
||||||
|
import org.bson.BsonNull; |
||||||
|
import org.bson.BsonString; |
||||||
|
import org.bson.BsonValue; |
||||||
|
import org.bson.Document; |
||||||
|
import org.junit.jupiter.api.AfterEach; |
||||||
|
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; |
||||||
|
import org.springframework.context.annotation.Bean; |
||||||
|
import org.springframework.data.convert.PropertyValueConverterFactory; |
||||||
|
import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; |
||||||
|
import org.springframework.data.mongodb.core.MongoTemplate; |
||||||
|
import org.springframework.data.mongodb.core.convert.MongoCustomConversions.MongoConverterConfigurationAdapter; |
||||||
|
import org.springframework.data.mongodb.core.convert.encryption.MongoEncryptionConverter; |
||||||
|
import org.springframework.data.mongodb.core.mapping.ExplicitEncrypted; |
||||||
|
import org.springframework.data.mongodb.test.util.EnableIfMongoServerVersion; |
||||||
|
import org.springframework.data.mongodb.test.util.EnableIfReplicaSetAvailable; |
||||||
|
import org.springframework.data.mongodb.test.util.MongoClientExtension; |
||||||
|
import org.springframework.data.mongodb.util.MongoClientVersion; |
||||||
|
import org.springframework.data.util.Lazy; |
||||||
|
import org.springframework.test.context.ContextConfiguration; |
||||||
|
import org.springframework.test.context.junit.jupiter.SpringExtension; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author Ross Lawley |
||||||
|
*/ |
||||||
|
@ExtendWith({ MongoClientExtension.class, SpringExtension.class }) |
||||||
|
@EnableIfMongoServerVersion(isGreaterThanEqual = "8.0") |
||||||
|
@EnableIfReplicaSetAvailable |
||||||
|
@ContextConfiguration(classes = RangeEncryptionTests.EncryptionConfig.class) |
||||||
|
class RangeEncryptionTests { |
||||||
|
|
||||||
|
@Autowired MongoTemplate template; |
||||||
|
|
||||||
|
@BeforeEach |
||||||
|
void setUp() { |
||||||
|
Assumptions.assumeThat(MongoClientVersion.isVersion5orNewer()).isTrue(); |
||||||
|
} |
||||||
|
|
||||||
|
@AfterEach |
||||||
|
void tearDown() { |
||||||
|
template.getDb().getCollection("test").deleteMany(new BsonDocument()); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void canGreaterThanEqualMatchRangeEncryptedField() { |
||||||
|
Person source = createPerson(); |
||||||
|
template.insert(source); |
||||||
|
|
||||||
|
Person loaded = template.query(Person.class).matching(where("encryptedInt").gte(source.encryptedInt)).firstValue(); |
||||||
|
assertThat(loaded).isEqualTo(source); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void canLesserThanEqualMatchRangeEncryptedField() { |
||||||
|
Person source = createPerson(); |
||||||
|
template.insert(source); |
||||||
|
|
||||||
|
Person loaded = template.query(Person.class).matching(where("encryptedInt").lte(source.encryptedInt)).firstValue(); |
||||||
|
assertThat(loaded).isEqualTo(source); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void canRangeMatchRangeEncryptedField() { |
||||||
|
Person source = createPerson(); |
||||||
|
template.insert(source); |
||||||
|
|
||||||
|
Person loaded = template.query(Person.class).matching(where("encryptedLong").lte(1001L).gte(1001L)).firstValue(); |
||||||
|
assertThat(loaded).isEqualTo(source); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void canUpdateRangeEncryptedField() { |
||||||
|
Person source = createPerson(); |
||||||
|
template.insert(source); |
||||||
|
|
||||||
|
source.encryptedInt = 123; |
||||||
|
source.encryptedLong = 9999L; |
||||||
|
template.save(source); |
||||||
|
|
||||||
|
Person loaded = template.query(Person.class).matching(where("id").is(source.id)).firstValue(); |
||||||
|
assertThat(loaded).isEqualTo(source); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void errorsWhenUsingNonRangeOperatorEqOnRangeEncryptedField() { |
||||||
|
Person source = createPerson(); |
||||||
|
template.insert(source); |
||||||
|
|
||||||
|
assertThatThrownBy( |
||||||
|
() -> template.query(Person.class).matching(where("encryptedInt").is(source.encryptedInt)).firstValue()) |
||||||
|
.isInstanceOf(AssertionError.class) |
||||||
|
.hasMessageStartingWith("Not a valid range query. Querying a range encrypted field but " |
||||||
|
+ "the query operator '$eq' for field path 'encryptedInt' is not a range query."); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void errorsWhenUsingNonRangeOperatorInOnRangeEncryptedField() { |
||||||
|
Person source = createPerson(); |
||||||
|
template.insert(source); |
||||||
|
|
||||||
|
assertThatThrownBy( |
||||||
|
() -> template.query(Person.class).matching(where("encryptedLong").in(1001L, 9999L)).firstValue()) |
||||||
|
.isInstanceOf(AssertionError.class) |
||||||
|
.hasMessageStartingWith("Not a valid range query. Querying a range encrypted field but " |
||||||
|
+ "the query operator '$in' for field path 'encryptedLong' is not a range query."); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private Person createPerson() { |
||||||
|
Person source = new Person(); |
||||||
|
source.id = "id-1"; |
||||||
|
source.encryptedInt = 101; |
||||||
|
source.encryptedLong = 1001L; |
||||||
|
return source; |
||||||
|
} |
||||||
|
|
||||||
|
protected static class EncryptionConfig extends AbstractMongoClientConfiguration { |
||||||
|
|
||||||
|
private static final String LOCAL_KMS_PROVIDER = "local"; |
||||||
|
|
||||||
|
private static final Lazy<Map<String, Map<String, Object>>> LAZY_KMS_PROVIDERS = Lazy.of(() -> { |
||||||
|
byte[] localMasterKey = new byte[96]; |
||||||
|
new SecureRandom().nextBytes(localMasterKey); |
||||||
|
return Map.of(LOCAL_KMS_PROVIDER, Map.of("key", localMasterKey)); |
||||||
|
}); |
||||||
|
|
||||||
|
@Autowired ApplicationContext applicationContext; |
||||||
|
|
||||||
|
@Override |
||||||
|
protected String getDatabaseName() { |
||||||
|
return "qe-test"; |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
public MongoClient mongoClient() { |
||||||
|
return super.mongoClient(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
protected void configureConverters(MongoConverterConfigurationAdapter converterConfigurationAdapter) { |
||||||
|
converterConfigurationAdapter |
||||||
|
.registerPropertyValueConverterFactory(PropertyValueConverterFactory.beanFactoryAware(applicationContext)) |
||||||
|
.useNativeDriverJavaTimeCodecs(); |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
MongoEncryptionConverter encryptingConverter(MongoClientEncryption mongoClientEncryption) { |
||||||
|
Lazy<Map<String, BsonBinary>> lazyDataKeyMap = Lazy.of(() -> { |
||||||
|
try (MongoClient client = mongoClient()) { |
||||||
|
MongoDatabase database = client.getDatabase(getDatabaseName()); |
||||||
|
database.getCollection("test").drop(); |
||||||
|
|
||||||
|
ClientEncryption clientEncryption = mongoClientEncryption.getClientEncryption(); |
||||||
|
BsonDocument encryptedFields = new BsonDocument().append("fields", |
||||||
|
new BsonArray(asList( |
||||||
|
new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedInt")) |
||||||
|
.append("bsonType", new BsonString("int")) |
||||||
|
.append("queries", |
||||||
|
new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) |
||||||
|
.append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) |
||||||
|
.append("min", new BsonInt32(0)).append("max", new BsonInt32(200))), |
||||||
|
new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedLong")) |
||||||
|
.append("bsonType", new BsonString("long")).append("queries", |
||||||
|
new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) |
||||||
|
.append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) |
||||||
|
.append("min", new BsonInt64(1000)).append("max", new BsonInt64(9999)))))); |
||||||
|
|
||||||
|
BsonDocument local = clientEncryption.createEncryptedCollection(database, "test", |
||||||
|
new CreateCollectionOptions().encryptedFields(encryptedFields), |
||||||
|
new CreateEncryptedCollectionParams(LOCAL_KMS_PROVIDER)); |
||||||
|
|
||||||
|
return local.getArray("fields").stream().map(BsonValue::asDocument).collect( |
||||||
|
Collectors.toMap(field -> field.getString("path").getValue(), field -> field.getBinary("keyId"))); |
||||||
|
} |
||||||
|
}); |
||||||
|
return new MongoEncryptionConverter(mongoClientEncryption, EncryptionKeyResolver |
||||||
|
.annotated((ctx) -> EncryptionKey.keyId(lazyDataKeyMap.get().get(ctx.getProperty().getFieldName())))); |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
CachingMongoClientEncryption clientEncryption(ClientEncryptionSettings encryptionSettings) { |
||||||
|
return new CachingMongoClientEncryption(() -> ClientEncryptions.create(encryptionSettings)); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
protected void configureClientSettings(MongoClientSettings.Builder builder) { |
||||||
|
try (MongoClient client = MongoClients.create()) { |
||||||
|
ClientEncryptionSettings clientEncryptionSettings = encryptionSettings(client); |
||||||
|
|
||||||
|
builder.autoEncryptionSettings(AutoEncryptionSettings.builder() //
|
||||||
|
.kmsProviders(clientEncryptionSettings.getKmsProviders()) //
|
||||||
|
.keyVaultNamespace(clientEncryptionSettings.getKeyVaultNamespace()) //
|
||||||
|
.bypassQueryAnalysis(true).build()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
ClientEncryptionSettings encryptionSettings(MongoClient mongoClient) { |
||||||
|
MongoNamespace keyVaultNamespace = new MongoNamespace("encryption.testKeyVault"); |
||||||
|
MongoCollection<Document> keyVaultCollection = mongoClient.getDatabase(keyVaultNamespace.getDatabaseName()) |
||||||
|
.getCollection(keyVaultNamespace.getCollectionName()); |
||||||
|
keyVaultCollection.drop(); |
||||||
|
// Ensure that two data keys cannot share the same keyAltName.
|
||||||
|
keyVaultCollection.createIndex(Indexes.ascending("keyAltNames"), |
||||||
|
new IndexOptions().unique(true).partialFilterExpression(Filters.exists("keyAltNames"))); |
||||||
|
|
||||||
|
mongoClient.getDatabase(getDatabaseName()).getCollection("test").drop(); // Clear old data
|
||||||
|
|
||||||
|
// Create the ClientEncryption instance
|
||||||
|
return ClientEncryptionSettings.builder() //
|
||||||
|
.keyVaultMongoClientSettings( |
||||||
|
MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://localhost")).build()) //
|
||||||
|
.keyVaultNamespace(keyVaultNamespace.getFullName()) //
|
||||||
|
.kmsProviders(LAZY_KMS_PROVIDERS.get()) //
|
||||||
|
.build(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static class CachingMongoClientEncryption extends MongoClientEncryption implements DisposableBean { |
||||||
|
|
||||||
|
static final AtomicReference<ClientEncryption> cache = new AtomicReference<>(); |
||||||
|
|
||||||
|
CachingMongoClientEncryption(Supplier<ClientEncryption> source) { |
||||||
|
super(() -> { |
||||||
|
ClientEncryption clientEncryption = cache.get(); |
||||||
|
if (clientEncryption == null) { |
||||||
|
clientEncryption = source.get(); |
||||||
|
cache.set(clientEncryption); |
||||||
|
} |
||||||
|
|
||||||
|
return clientEncryption; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void destroy() { |
||||||
|
ClientEncryption clientEncryption = cache.get(); |
||||||
|
if (clientEncryption != null) { |
||||||
|
clientEncryption.close(); |
||||||
|
cache.set(null); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@org.springframework.data.mongodb.core.mapping.Document("test") |
||||||
|
static class Person { |
||||||
|
|
||||||
|
String id; |
||||||
|
String name; |
||||||
|
|
||||||
|
@ExplicitEncrypted(algorithm = RANGE, contentionFactor = 0L, |
||||||
|
rangeOptions = "{\"min\": 0, \"max\": 200, \"trimFactor\": 1, \"sparsity\": 1}") Integer encryptedInt; |
||||||
|
@ExplicitEncrypted(algorithm = RANGE, contentionFactor = 0L, |
||||||
|
rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") Long encryptedLong; |
||||||
|
|
||||||
|
public String getId() { |
||||||
|
return this.id; |
||||||
|
} |
||||||
|
|
||||||
|
public void setId(String id) { |
||||||
|
this.id = id; |
||||||
|
} |
||||||
|
|
||||||
|
public String getName() { |
||||||
|
return this.name; |
||||||
|
} |
||||||
|
|
||||||
|
public void setName(String name) { |
||||||
|
this.name = name; |
||||||
|
} |
||||||
|
|
||||||
|
public Integer getEncryptedInt() { |
||||||
|
return this.encryptedInt; |
||||||
|
} |
||||||
|
|
||||||
|
public void setEncryptedInt(Integer encryptedInt) { |
||||||
|
this.encryptedInt = encryptedInt; |
||||||
|
} |
||||||
|
|
||||||
|
public Long getEncryptedLong() { |
||||||
|
return this.encryptedLong; |
||||||
|
} |
||||||
|
|
||||||
|
public void setEncryptedLong(Long encryptedLong) { |
||||||
|
this.encryptedLong = encryptedLong; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean equals(Object o) { |
||||||
|
if (this == o) |
||||||
|
return true; |
||||||
|
if (o == null || getClass() != o.getClass()) |
||||||
|
return false; |
||||||
|
|
||||||
|
Person person = (Person) o; |
||||||
|
return Objects.equals(id, person.id) && Objects.equals(name, person.name) |
||||||
|
&& Objects.equals(encryptedInt, person.encryptedInt) && Objects.equals(encryptedLong, person.encryptedLong); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public int hashCode() { |
||||||
|
int result = Objects.hashCode(id); |
||||||
|
result = 31 * result + Objects.hashCode(name); |
||||||
|
result = 31 * result + Objects.hashCode(encryptedInt); |
||||||
|
result = 31 * result + Objects.hashCode(encryptedLong); |
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String toString() { |
||||||
|
return "Person{" + "id='" + id + '\'' + ", name='" + name + '\'' + ", encryptedInt=" + encryptedInt |
||||||
|
+ ", encryptedLong=" + encryptedLong + '}'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue