diff --git a/src/MongoDB.Driver.Encryption/CsfleSchemaBuilder.cs b/src/MongoDB.Driver.Encryption/CsfleSchemaBuilder.cs new file mode 100644 index 00000000000..b49eacb8147 --- /dev/null +++ b/src/MongoDB.Driver.Encryption/CsfleSchemaBuilder.cs @@ -0,0 +1,368 @@ +/* Copyright 2010-present MongoDB Inc. + * + * 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 + * + * http://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. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; + +namespace MongoDB.Driver.Encryption +{ + /// + /// A builder class for creating Client-Side Field Level Encryption (CSFLE) schemas. + /// + public class CsfleSchemaBuilder + { + private readonly Dictionary _schemas = new(); + + private CsfleSchemaBuilder() + { + } + + /// + /// Creates a new instance of the and configures it using the provided action. + /// + /// An action to configure the schema builder. + public static CsfleSchemaBuilder Create(Action configure) + { + var builder = new CsfleSchemaBuilder(); + configure(builder); + return builder; + } + + /// + /// Adds an encrypted collection schema for a specific collection namespace. + /// + /// The type of the document in the collection. + /// The namespace of the collection. + /// An action to configure the encrypted collection builder. + /// The current instance. + public CsfleSchemaBuilder Encrypt(CollectionNamespace collectionNamespace, Action> configure) + { + var builder = new EncryptedCollectionBuilder(); + configure(builder); + _schemas.Add(collectionNamespace.FullName, builder.Build()); + return this; + } + + /// + /// Builds and returns the resulting CSFLE schema. + /// + public IDictionary Build() + { + if (!_schemas.Any()) + { + throw new InvalidOperationException("No schemas were added. Use Encrypt to add a schema."); + } + + return _schemas; + } + } + + /// + /// A builder class for creating encrypted collection schemas. + /// + /// The type of the document in the collection. + public class EncryptedCollectionBuilder + { + private readonly BsonDocument _schema = new("bsonType", "object"); + private readonly RenderArgs _args = new(BsonSerializer.LookupSerializer(), BsonSerializer.SerializerRegistry); + + internal EncryptedCollectionBuilder() + { + } + + /// + /// Configures encryption metadata for the collection. + /// + /// The key ID to use for encryption. + /// The encryption algorithm to use. + /// The current instance. + public EncryptedCollectionBuilder EncryptMetadata(Guid? keyId = null, EncryptionAlgorithm? algorithm = null) + { + if (keyId is null && algorithm is null) + { + throw new ArgumentException("At least one of keyId or algorithm must be specified."); + } + + _schema["encryptMetadata"] = new BsonDocument + { + { "keyId", () => new BsonArray { new BsonBinaryData(keyId!.Value, GuidRepresentation.Standard) }, keyId is not null }, + { "algorithm", () => MapCsfleEncryptionAlgorithmToString(algorithm!.Value), algorithm is not null } + }; + return this; + } + + /// + /// Adds a pattern property to the schema with encryption settings. + /// + /// The regex pattern for the property. + /// The BSON type of the property. + /// The encryption algorithm to use. + /// The key ID to use for encryption. + /// The current instance. + public EncryptedCollectionBuilder PatternProperty( + string pattern, + BsonType bsonType, + EncryptionAlgorithm? algorithm = null, + Guid? keyId = null) + => PatternProperty(pattern, [bsonType], algorithm, keyId); + + /// + /// Adds a pattern property to the schema with encryption settings. + /// + /// The regex pattern for the property. + /// The BSON types of the property. + /// The encryption algorithm to use. + /// The key ID to use for encryption. + /// The current instance. + public EncryptedCollectionBuilder PatternProperty( + string pattern, + IEnumerable bsonTypes = null, + EncryptionAlgorithm? algorithm = null, + Guid? keyId = null) + { + AddToPatternProperties(pattern, CreateEncryptDocument(bsonTypes, algorithm, keyId)); + return this; + } + + /// + /// Adds a nested pattern property to the schema. + /// + /// The type of the nested field. + /// The field. + /// An action to configure the nested builder. + /// The current instance. + public EncryptedCollectionBuilder PatternProperty( + Expression> path, + Action> configure) + => PatternProperty(new ExpressionFieldDefinition(path), configure); + + /// + /// Adds a nested pattern property to the schema. + /// + /// The type of the nested field. + /// The field. + /// An action to configure the nested builder. + /// The current instance. + public EncryptedCollectionBuilder PatternProperty( + FieldDefinition path, + Action> configure) + { + var nestedBuilder = new EncryptedCollectionBuilder(); + configure(nestedBuilder); + + var fieldName = path.Render(_args).FieldName; + + AddToPatternProperties(fieldName, nestedBuilder.Build()); + return this; + } + + /// + /// Adds a property to the schema with encryption settings. + /// + /// The type of the field. + /// The field. + /// The BSON type of the property. + /// The encryption algorithm to use. + /// The key ID to use for encryption. + /// The current instance. + public EncryptedCollectionBuilder Property( + Expression> path, + BsonType bsonType, + EncryptionAlgorithm? algorithm = null, + Guid? keyId = null) + => Property(path, [bsonType], algorithm, keyId); + + /// + /// Adds a property to the schema with encryption settings. + /// + /// The type of the field. + /// The field. + /// The BSON types of the property. + /// The encryption algorithm to use. + /// The key ID to use for encryption. + /// The current instance. + public EncryptedCollectionBuilder Property( + Expression> path, + IEnumerable bsonTypes = null, + EncryptionAlgorithm? algorithm = null, + Guid? keyId = null) + => Property(new ExpressionFieldDefinition(path), bsonTypes, algorithm, keyId); + + /// + /// Adds a property to the schema with encryption settings. + /// + /// The field. + /// The BSON type of the property. + /// The encryption algorithm to use. + /// The key ID to use for encryption. + /// The current instance. + public EncryptedCollectionBuilder Property( + FieldDefinition path, + BsonType bsonType, + EncryptionAlgorithm? algorithm = null, + Guid? keyId = null) + => Property(path, [bsonType], algorithm, keyId); + + /// + /// Adds a property to the schema with encryption settings. + /// + /// The field. + /// The BSON types of the property. + /// The encryption algorithm to use. + /// The key ID to use for encryption. + /// The current instance. + public EncryptedCollectionBuilder Property( + FieldDefinition path, + IEnumerable bsonTypes = null, + EncryptionAlgorithm? algorithm = null, + Guid? keyId = null) + { + var fieldName = path.Render(_args).FieldName; + AddToProperties(fieldName, CreateEncryptDocument(bsonTypes, algorithm, keyId)); + return this; + } + + /// + /// Adds a nested property to the schema. + /// + /// The type of the nested field. + /// The field. + /// An action to configure the nested builder. + /// The current instance. + public EncryptedCollectionBuilder Property( + Expression> path, + Action> configure) + => Property(new ExpressionFieldDefinition(path), configure); + + + /// + /// Adds a nested property to the schema. + /// + /// The type of the nested field. + /// The field. + /// An action to configure the nested builder. + /// The current instance. + public EncryptedCollectionBuilder Property( + FieldDefinition path, + Action> configure) + { + var nestedBuilder = new EncryptedCollectionBuilder(); + configure(nestedBuilder); + + var fieldName = path.Render(_args).FieldName; + AddToProperties(fieldName, nestedBuilder.Build()); + return this; + } + + internal BsonDocument Build() => _schema; + + private static BsonDocument CreateEncryptDocument( + IEnumerable bsonTypes = null, + EncryptionAlgorithm? algorithm = null, + Guid? keyId = null) + { + BsonValue bsonTypeVal = null; + + if (bsonTypes != null) + { + var convertedBsonTypes = bsonTypes.Select(MapBsonTypeToString).ToList(); + + if (convertedBsonTypes.Count == 0) + { + throw new ArgumentException("At least one BSON type must be specified.", nameof(bsonTypes)); + } + + bsonTypeVal = convertedBsonTypes.Count == 1 + ? convertedBsonTypes[0] + : new BsonArray(convertedBsonTypes); + } + + return new BsonDocument + { + { "encrypt", new BsonDocument + { + { "bsonType", () => bsonTypeVal, bsonTypeVal is not null }, + { "algorithm", () => MapCsfleEncryptionAlgorithmToString(algorithm!.Value), algorithm is not null }, + { + "keyId", + () => new BsonArray(new[] { new BsonBinaryData(keyId!.Value, GuidRepresentation.Standard) }), + keyId is not null + }, + } + } + }; + } + + private void AddToPatternProperties(string field, BsonDocument document) + { + if (!_schema.TryGetValue("patternProperties", out var value)) + { + value = new BsonDocument(); + _schema["patternProperties"] = value; + } + var patternProperties = value.AsBsonDocument; + patternProperties[field] = document; + } + + private void AddToProperties(string field, BsonDocument document) + { + if (!_schema.TryGetValue("properties", out var value)) + { + value = new BsonDocument(); + _schema["properties"] = value; + } + var properties = value.AsBsonDocument; + properties[field] = document; + } + + private static string MapBsonTypeToString(BsonType type) + { + return type switch + { + BsonType.Array => "array", + BsonType.Binary => "binData", + BsonType.Boolean => "bool", + BsonType.DateTime => "date", + BsonType.Decimal128 => "decimal", + BsonType.Document => "object", + BsonType.Double => "double", + BsonType.Int32 => "int", + BsonType.Int64 => "long", + BsonType.JavaScript => "javascript", + BsonType.JavaScriptWithScope => "javascriptWithScope", + BsonType.ObjectId => "objectId", + BsonType.RegularExpression => "regex", + BsonType.String => "string", + BsonType.Symbol => "symbol", + BsonType.Timestamp => "timestamp", + _ => throw new ArgumentException($"Unsupported BSON type: {type}.", nameof(type)) + }; + } + + private static string MapCsfleEncryptionAlgorithmToString(EncryptionAlgorithm algorithm) + { + return algorithm switch + { + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random => "AEAD_AES_256_CBC_HMAC_SHA_512-Random", + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic => "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic", + _ => throw new ArgumentException($"Unexpected algorithm type: {algorithm}.", nameof(algorithm)) + }; + } + } +} \ No newline at end of file diff --git a/tests/MongoDB.Driver.Tests/Encryption/CsfleSchemaBuilderTests.cs b/tests/MongoDB.Driver.Tests/Encryption/CsfleSchemaBuilderTests.cs new file mode 100644 index 00000000000..90d8364b712 --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Encryption/CsfleSchemaBuilderTests.cs @@ -0,0 +1,710 @@ +/* Copyright 2010-present MongoDB Inc. + * + * 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 + * + * http://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. + */ + +using System; +using System.Collections.Generic; +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver.Encryption; +using Xunit; + +namespace MongoDB.Driver.Tests.Encryption +{ + public class CsfleSchemaBuilderTests + { + private readonly CollectionNamespace _collectionNamespace = CollectionNamespace.FromFullName("medicalRecords.patients"); + private const string _keyIdString = "6f4af470-00d1-401f-ac39-f45902a0c0c8"; + private static Guid _keyId = Guid.Parse(_keyIdString); + + [Fact] + public void CsfleSchemaBuilder_works_as_expected() + { + var builder = CsfleSchemaBuilder.Create(schemaBuilder => + { + schemaBuilder.Encrypt(_collectionNamespace, builder => + { + builder + .EncryptMetadata(keyId: _keyId) + .Property(p => p.MedicalRecords, BsonType.Array, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random) + .Property("bloodType", BsonType.String, + algorithm: EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random) + .Property(p => p.Ssn, BsonType.Int32, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) + .Property(p => p.Insurance, innerBuilder => + { + innerBuilder + .Property(i => i.PolicyNumber, BsonType.Int32, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic); + }) + .PatternProperty("_PIIString$", BsonType.String, EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) + .PatternProperty("_PIIArray$", BsonType.Array, EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random) + .PatternProperty(p => p.Insurance, innerBuilder => + { + innerBuilder + .PatternProperty("_PIIString$", BsonType.String, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) + .PatternProperty("_PIINumber$", BsonType.Int32, + algorithm: EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic); + }); + + } ); + }); + + var expected = new Dictionary + { + [_collectionNamespace.FullName] = """ + { + "bsonType": "object", + "encryptMetadata": { + "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] + }, + "properties": { + "insurance": { + "bsonType": "object", + "properties": { + "policyNumber": { + "encrypt": { + "bsonType": "int", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" + } + } + } + }, + "medicalRecords": { + "encrypt": { + "bsonType": "array", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" + } + }, + "bloodType": { + "encrypt": { + "bsonType": "string", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" + } + }, + "ssn": { + "encrypt": { + "bsonType": "int", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" + } + } + }, + "patternProperties": { + "_PIIString$": { + "encrypt": { + "bsonType": "string", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic", + }, + }, + "_PIIArray$": { + "encrypt": { + "bsonType": "array", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random", + }, + }, + "insurance": { + "bsonType": "object", + "patternProperties": { + "_PIINumber$": { + "encrypt": { + "bsonType": "int", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic", + }, + }, + "_PIIString$": { + "encrypt": { + "bsonType": "string", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic", + }, + }, + }, + }, + }, + } + """ + }; + + AssertOutcomeCsfleSchemaBuilder(builder, expected); + } + + [Fact] + public void CsfleSchemaBuilder_with_multiple_types_works_as_expected() + { + var testCollectionNamespace = CollectionNamespace.FromFullName("test.class"); + + var builder = CsfleSchemaBuilder.Create(schemaBuilder => + { + schemaBuilder.Encrypt(_collectionNamespace, builder => + { + builder + .EncryptMetadata(keyId: _keyId) + .Property(p => p.MedicalRecords, BsonType.Array, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random); + }); + + schemaBuilder.Encrypt(_collectionNamespace, builder => + { + builder.Property(t => t.TestString, BsonType.String); + }); + }); + + var expected = new Dictionary + { + [_collectionNamespace.FullName] = """ + { + "bsonType": "object", + "encryptMetadata": { + "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] + }, + "properties": { + "medicalRecords": { + "encrypt": { + "bsonType": "array", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" + } + }, + }, + } + """, + [testCollectionNamespace.FullName] = """ + { + "bsonType": "object", + "properties": { + "TestString": { + "encrypt": { + "bsonType": "string", + } + }, + } + } + """ + }; + + AssertOutcomeCsfleSchemaBuilder(builder, expected); + } + + [Fact] + public void CsfleSchemaBuilder_with_no_schemas_throws() + { + var builder = CsfleSchemaBuilder.Create(_ => + { + // No schemas added + }); + + var exception = Record.Exception(() => builder.Build()); + + exception.Should().NotBeNull(); + exception.Should().BeOfType(); + } + + [Theory] + [InlineData( + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + null, + """ "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" """)] + [InlineData( + null, + _keyIdString, + """ "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + public void EncryptedCollection_Metadata_works_as_expected(EncryptionAlgorithm? algorithm, string keyString, string expectedContent) + { + Guid? keyId = keyString is null ? null : Guid.Parse(keyString); + var builder = new EncryptedCollectionBuilder(); + + builder.EncryptMetadata(keyId, algorithm); + + var expected = $$""" + { + "bsonType": "object", + "encryptMetadata": { + {{expectedContent}} + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Theory] + [InlineData(BsonType.Array, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + null, + """ "bsonType": "array", "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" """)] + [InlineData(BsonType.Array, + null, + _keyIdString, + """ "bsonType": "array", "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + [InlineData(BsonType.Array, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + _keyIdString, + """ "bsonType": "array", "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random", "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + public void EncryptedCollection_PatternProperty_works_as_expected(BsonType bsonType, EncryptionAlgorithm? algorithm, string keyString, string expectedContent) + { + Guid? keyId = keyString is null ? null : Guid.Parse(keyString); + var builder = new EncryptedCollectionBuilder(); + + builder.PatternProperty("randomRegex*", bsonType, algorithm, keyId); + + var expected = $$""" + { + "bsonType": "object", + "patternProperties": { + "randomRegex*": { + "encrypt": { + {{expectedContent}} + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Theory] + [InlineData(null, + null, + null, + "")] + [InlineData(new[] {BsonType.Array, BsonType.String}, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + null, + """ "bsonType": ["array", "string"], "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" """)] + [InlineData(new[] {BsonType.Array, BsonType.String}, + null, + _keyIdString, + """ "bsonType": ["array", "string"], "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + [InlineData(new[] {BsonType.Array, BsonType.String}, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + _keyIdString, + """ "bsonType": ["array", "string"], "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random", "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + public void EncryptedCollection_PatternProperty_with_multiple_bson_types_works_as_expected(IEnumerable bsonTypes, EncryptionAlgorithm? algorithm, string keyString, string expectedContent) + { + Guid? keyId = keyString is null ? null : Guid.Parse(keyString); + var builder = new EncryptedCollectionBuilder(); + + builder.PatternProperty("randomRegex*", bsonTypes, algorithm, keyId); + + var expected = $$""" + { + "bsonType": "object", + "patternProperties": { + "randomRegex*": { + "encrypt": { + {{expectedContent}} + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Fact] + public void EncryptedCollection_PatternProperty_nested_works_as_expected() + { + Guid? keyId = Guid.Parse(_keyIdString); + var builder = new EncryptedCollectionBuilder(); + + builder.PatternProperty(p => p.Insurance, innerBuilder => + { + innerBuilder + .EncryptMetadata(keyId) + .Property("policyNumber", BsonType.Int32, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) + .PatternProperty("randomRegex*", BsonType.String, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random); + }); + + var expected = """ + { + "bsonType": "object", + "patternProperties": { + "insurance": { + "bsonType": "object", + "encryptMetadata": { + "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] + }, + "properties": { + "policyNumber": { + "encrypt": { + "bsonType": "int", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" + } + } + }, + "patternProperties": { + "randomRegex*": { + "encrypt": { + "bsonType": "string", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" + } + } + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Fact] + public void EncryptedCollection_PatternProperty_nested_with_string_works_as_expected() + { + Guid? keyId = Guid.Parse(_keyIdString); + var builder = new EncryptedCollectionBuilder(); + + builder.PatternProperty("insurance", innerBuilder => + { + innerBuilder + .EncryptMetadata(keyId) + .Property("policyNumber", BsonType.Int32, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) + .PatternProperty("randomRegex*", BsonType.String, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random); + }); + + var expected = """ + { + "bsonType": "object", + "patternProperties": { + "insurance": { + "bsonType": "object", + "encryptMetadata": { + "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] + }, + "properties": { + "policyNumber": { + "encrypt": { + "bsonType": "int", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" + } + } + }, + "patternProperties": { + "randomRegex*": { + "encrypt": { + "bsonType": "string", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" + } + } + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Theory] + [InlineData(BsonType.Array, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + null, + """ "bsonType": "array", "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" """)] + [InlineData(BsonType.Array, + null, + _keyIdString, + """ "bsonType": "array", "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + [InlineData(BsonType.Array, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + _keyIdString, + """ "bsonType": "array", "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random", "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + public void EncryptedCollection_Property_with_expression_works_as_expected(BsonType bsonType, EncryptionAlgorithm? algorithm, string keyString, string expectedContent) + { + Guid? keyId = keyString is null ? null : Guid.Parse(keyString); + var builder = new EncryptedCollectionBuilder(); + + builder.Property(p => p.MedicalRecords, bsonType, algorithm, keyId); + + var expected = $$""" + { + "bsonType": "object", + "properties": { + "medicalRecords": { + "encrypt": { + {{expectedContent}} + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Theory] + [InlineData(null, + null, + null, + "")] + [InlineData(new[] {BsonType.Array, BsonType.String}, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + null, + """ "bsonType": ["array", "string"], "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" """)] + [InlineData(new[] {BsonType.Array, BsonType.String}, + null, + _keyIdString, + """ "bsonType": ["array", "string"], "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + [InlineData(new[] {BsonType.Array, BsonType.String}, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + _keyIdString, + """ "bsonType": ["array", "string"], "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random", "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + public void EncryptedCollection_Property_with_multiple_bson_types_works_as_expected(IEnumerable bsonTypes, EncryptionAlgorithm? algorithm, string keyString, string expectedContent) + { + Guid? keyId = keyString is null ? null : Guid.Parse(keyString); + var builder = new EncryptedCollectionBuilder(); + + builder.Property(p => p.MedicalRecords, bsonTypes, algorithm, keyId); + + var expected = $$""" + { + "bsonType": "object", + "properties": { + "medicalRecords": { + "encrypt": { + {{expectedContent}} + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Theory] + [InlineData(BsonType.Array, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + null, + """ "bsonType": "array", "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" """)] + [InlineData(BsonType.Array, + null, + _keyIdString, + """ "bsonType": "array", "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + [InlineData(BsonType.Array, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + _keyIdString, + """ "bsonType": "array", "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random", "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] """)] + public void EncryptedCollection_Property_with_string_works_as_expected(BsonType bsonType, EncryptionAlgorithm? algorithm, string keyString, string expectedContent) + { + Guid? keyId = keyString is null ? null : Guid.Parse(keyString); + var builder = new EncryptedCollectionBuilder(); + + builder.Property("medicalRecords", bsonType, algorithm, keyId); + + var expected = $$""" + { + "bsonType": "object", + "properties": { + "medicalRecords": { + "encrypt": { + {{expectedContent}} + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Fact] + public void EncryptedCollection_Property_nested_works_as_expected() + { + Guid? keyId = Guid.Parse(_keyIdString); + var builder = new EncryptedCollectionBuilder(); + + builder.Property(p => p.Insurance, innerBuilder => + { + innerBuilder + .EncryptMetadata(keyId) + .Property("policyNumber", BsonType.Int32, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) + .PatternProperty("randomRegex*", BsonType.String, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random); + }); + + var expected = """ + { + "bsonType": "object", + "properties": { + "insurance": { + "bsonType": "object", + "encryptMetadata": { + "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] + }, + "properties": { + "policyNumber": { + "encrypt": { + "bsonType": "int", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" + } + } + }, + "patternProperties": { + "randomRegex*": { + "encrypt": { + "bsonType": "string", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" + } + } + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Fact] + public void EncryptedCollection_Property_nested_with_string_works_as_expected() + { + Guid? keyId = Guid.Parse(_keyIdString); + var builder = new EncryptedCollectionBuilder(); + + builder.Property("insurance", innerBuilder => + { + innerBuilder + .EncryptMetadata(keyId) + .Property("policyNumber", BsonType.Int32, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic) + .PatternProperty("randomRegex*", BsonType.String, + EncryptionAlgorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random); + }); + + var expected = """ + { + "bsonType": "object", + "properties": { + "insurance": { + "bsonType": "object", + "encryptMetadata": { + "keyId": [{ "$binary" : { "base64" : "b0r0cADRQB+sOfRZAqDAyA==", "subType" : "04" } }] + }, + "properties": { + "policyNumber": { + "encrypt": { + "bsonType": "int", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" + } + } + }, + "patternProperties": { + "randomRegex*": { + "encrypt": { + "bsonType": "string", + "algorithm": "AEAD_AES_256_CBC_HMAC_SHA_512-Random" + } + } + } + } + } + } + """; + + AssertOutcomeCollectionBuilder(builder, expected); + } + + [Fact] + public void EncryptedCollection_Property_with_empty_bson_types_throws() + { + var builder = new EncryptedCollectionBuilder(); + + var recordedException = Record.Exception(() => builder.Property("test", [])); + recordedException.Should().NotBeNull(); + recordedException.Should().BeOfType(); + } + + [Fact] + public void EncryptedCollection_Metadata_with_empty_algorithm_and_key_throws() + { + var builder = new EncryptedCollectionBuilder(); + + var recordedException = Record.Exception(() => builder.EncryptMetadata(null, null)); + recordedException.Should().NotBeNull(); + recordedException.Should().BeOfType(); + } + + private void AssertOutcomeCsfleSchemaBuilder(CsfleSchemaBuilder builder, Dictionary expectedSchema) + { + var builtSchema = builder.Build(); + expectedSchema.Should().HaveCount(builtSchema.Count); + foreach (var collectionNamespace in expectedSchema.Keys) + { + var parsed = BsonDocument.Parse(expectedSchema[collectionNamespace]); + builtSchema[collectionNamespace].Should().BeEquivalentTo(parsed); + } + } + + private void AssertOutcomeCollectionBuilder(EncryptedCollectionBuilder builder, string expected) + { + var builtSchema = builder.Build(); + var expectedSchema = BsonDocument.Parse(expected); + builtSchema.Should().BeEquivalentTo(expectedSchema); + } + + internal class TestClass + { + public ObjectId Id { get; set; } + + public string TestString { get; set; } + } + + internal class Patient + { + [BsonId] + public ObjectId Id { get; set; } + + [BsonElement("name")] + public string Name { get; set; } + + [BsonElement("ssn")] + public int Ssn { get; set; } + + [BsonElement("bloodType")] + public string BloodType { get; set; } + + [BsonElement("medicalRecords")] + public List MedicalRecords { get; set; } + + [BsonElement("insurance")] + public Insurance Insurance { get; set; } + } + + internal class MedicalRecord + { + [BsonElement("weight")] + public int Weight { get; set; } + + [BsonElement("bloodPressure")] + public string BloodPressure { get; set; } + } + + internal class Insurance + { + [BsonElement("provider")] + public string Provider { get; set; } + + [BsonElement("policyNumber")] + public int PolicyNumber { get; set; } + } + } +} \ No newline at end of file diff --git a/tests/MongoDB.Driver.Tests/Encryption/MongoEncryptionCreateCollectionExceptionTests.cs b/tests/MongoDB.Driver.Tests/Encryption/MongoEncryptionCreateCollectionExceptionTests.cs index 5f282702bb0..9e53c873b86 100644 --- a/tests/MongoDB.Driver.Tests/Encryption/MongoEncryptionCreateCollectionExceptionTests.cs +++ b/tests/MongoDB.Driver.Tests/Encryption/MongoEncryptionCreateCollectionExceptionTests.cs @@ -1 +1 @@ - \ No newline at end of file +//TODO Do we need to keep this empty file...? \ No newline at end of file