From 35e768cafd370261290cc1356f8622cb1d220b64 Mon Sep 17 00:00:00 2001 From: MartinSarkany Date: Wed, 24 Sep 2025 17:32:56 +0200 Subject: [PATCH 1/4] Add sync initialization to CosmosDataEncryptionKeyProvider --- .../src/CosmosDataEncryptionKeyProvider.cs | 24 ++- .../CosmosDataEncryptionKeyProviderTests.cs | 180 ++++++++++++++++++ 2 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/CosmosDataEncryptionKeyProviderTests.cs diff --git a/Microsoft.Azure.Cosmos.Encryption.Custom/src/CosmosDataEncryptionKeyProvider.cs b/Microsoft.Azure.Cosmos.Encryption.Custom/src/CosmosDataEncryptionKeyProvider.cs index c6f1ffb66c..c5d537be87 100644 --- a/Microsoft.Azure.Cosmos.Encryption.Custom/src/CosmosDataEncryptionKeyProvider.cs +++ b/Microsoft.Azure.Cosmos.Encryption.Custom/src/CosmosDataEncryptionKeyProvider.cs @@ -143,10 +143,7 @@ public async Task InitializeAsync( string containerId, CancellationToken cancellationToken = default) { - if (this.container != null) - { - throw new InvalidOperationException($"{nameof(CosmosDataEncryptionKeyProvider)} has already been initialized."); - } + this.ThrowIfAlreadyInitialized(); #if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull(database); @@ -173,6 +170,25 @@ public async Task InitializeAsync( this.container = containerResponse.Container; } + /// + /// Initialize using an existing Cosmos DB container for storing wrapped DEKs. + /// + /// Existing Cosmos DB container. + public void Initialize(Container container) + { + this.ThrowIfAlreadyInitialized(); + + this.container = container ?? throw new ArgumentNullException(nameof(container)); + } + + private void ThrowIfAlreadyInitialized() + { + if (this.container != null) + { + throw new InvalidOperationException($"{nameof(CosmosDataEncryptionKeyProvider)} has already been initialized."); + } + } + /// public override async Task FetchDataEncryptionKeyWithoutRawKeyAsync( string id, diff --git a/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/CosmosDataEncryptionKeyProviderTests.cs b/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/CosmosDataEncryptionKeyProviderTests.cs new file mode 100644 index 0000000000..204cec6c70 --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/CosmosDataEncryptionKeyProviderTests.cs @@ -0,0 +1,180 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Cosmos; + using Microsoft.Azure.Cosmos.Encryption.Custom; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Moq; + using Microsoft.Data.Encryption.Cryptography; + + [TestClass] + public class CosmosDataEncryptionKeyProviderTests + { + private const string ContainerId = "dekContainer"; + + [TestMethod] + public async Task InitializeAsync_WithValidContainer_CreatesAndSetsContainer() + { + Mock mockContainer = new(MockBehavior.Strict); + Mock mockContainerResponse = new(MockBehavior.Strict); + mockContainerResponse.Setup(r => r.Container).Returns(mockContainer.Object); + mockContainerResponse.Setup(r => r.Resource).Returns(new ContainerProperties(ContainerId, partitionKeyPath: "/id")); + + Mock mockDatabase = new(MockBehavior.Strict); + mockDatabase + .Setup(db => db.CreateContainerIfNotExistsAsync( + It.Is(s => s == ContainerId), + It.Is(pk => pk == "/id"), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(mockContainerResponse.Object); + + CosmosDataEncryptionKeyProvider provider = CreateProvider(); + + await provider.InitializeAsync(mockDatabase.Object, ContainerId); + + Assert.AreSame(mockContainer.Object, provider.Container); + + mockDatabase.VerifyAll(); + mockContainerResponse.VerifyAll(); + } + + [TestMethod] + public async Task InitializeAsync_WithWrongPartitionKey_Throws() + { + Mock mockContainer = new(MockBehavior.Strict); + Mock mockContainerResponse = new(MockBehavior.Strict); + mockContainerResponse.Setup(r => r.Container).Returns(mockContainer.Object); + mockContainerResponse.Setup(r => r.Resource).Returns(new ContainerProperties("dekBad", partitionKeyPath: "/different-id")); + + Mock mockDatabase = new(MockBehavior.Strict); + mockDatabase + .Setup(db => db.CreateContainerIfNotExistsAsync( + It.Is(s => s == "dekBad"), + It.Is(pk => pk == "/id"), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(mockContainerResponse.Object); + + CosmosDataEncryptionKeyProvider provider = CreateProvider(); + + ArgumentException ex = await Assert.ThrowsExceptionAsync(() => provider.InitializeAsync(mockDatabase.Object, "dekBad")); + + StringAssert.Contains(ex.Message, "partition key definition"); + Assert.AreEqual("containerId", ex.ParamName); + + mockDatabase.VerifyAll(); + } + + [TestMethod] + public void Initialize_WithContainer_Succeeds() + { + Mock mockContainer = new(MockBehavior.Strict); + + CosmosDataEncryptionKeyProvider provider = CreateProvider(); + provider.Initialize(mockContainer.Object); + + Assert.AreSame(mockContainer.Object, provider.Container); + } + + [TestMethod] + public void Initialize_WithNullContainer_Throws() + { + CosmosDataEncryptionKeyProvider provider = CreateProvider(); + + ArgumentNullException ex = Assert.ThrowsException(() => provider.Initialize(null)); + + Assert.AreEqual("container", ex.ParamName); + } + + [TestMethod] + public void Initialize_Twice_Throws() + { + Mock mockContainer = new(MockBehavior.Strict); + CosmosDataEncryptionKeyProvider provider = CreateProvider(); + provider.Initialize(mockContainer.Object); + + InvalidOperationException ex = Assert.ThrowsException(() => provider.Initialize(mockContainer.Object)); + + StringAssert.Contains(ex.Message, nameof(CosmosDataEncryptionKeyProvider)); + } + + [TestMethod] + public async Task InitializeAsync_AfterInitializeContainer_Throws() + { + Mock mockContainer = new(MockBehavior.Strict); + Mock mockDatabase = new(MockBehavior.Strict); + CosmosDataEncryptionKeyProvider provider = CreateProvider(); + + provider.Initialize(mockContainer.Object); + + await Assert.ThrowsExceptionAsync(() => provider.InitializeAsync(mockDatabase.Object, "ignored")); + } + + [TestMethod] + public void AccessContainer_BeforeInitialization_Throws() + { + CosmosDataEncryptionKeyProvider provider = CreateProvider(); + + InvalidOperationException ex = Assert.ThrowsException(() => _ = provider.Container); + + StringAssert.Contains(ex.Message, nameof(CosmosDataEncryptionKeyProvider)); + } + + [TestMethod] + public void Constructor_EncryptionKeyWrapProvider_SetsProperties() + { +#pragma warning disable CS0618 + Mock wrapProviderMock = new(MockBehavior.Strict); + + CosmosDataEncryptionKeyProvider provider = new(wrapProviderMock.Object); + + Assert.AreSame(wrapProviderMock.Object, provider.EncryptionKeyWrapProvider); + Assert.IsNotNull(provider.DataEncryptionKeyContainer); + Assert.IsNotNull(provider.DekCache); +#pragma warning restore CS0618 + } + + [TestMethod] + public void Constructor_EncryptionKeyStoreProvider_SetsMdePropertiesAndTtl_DefaultInfinite() + { + TestEncryptionKeyStoreProvider keyStoreProvider = new(); + + CosmosDataEncryptionKeyProvider provider = new(keyStoreProvider); + + Assert.AreSame(keyStoreProvider, provider.EncryptionKeyStoreProvider); + Assert.IsNotNull(provider.DekCache); + Assert.IsNotNull(provider.DataEncryptionKeyContainer); + Assert.IsTrue(provider.PdekCacheTimeToLive.HasValue); + Assert.IsTrue(provider.PdekCacheTimeToLive.Value > TimeSpan.Zero); + } + + [TestMethod] + public void Constructor_KeyStoreProvider_SetsMdePropertiesAndTtl_Custom() + { + TimeSpan ttl = TimeSpan.FromMinutes(15); + TestEncryptionKeyStoreProvider keyStoreProvider = new() + { + DataEncryptionKeyCacheTimeToLive = ttl + }; + + CosmosDataEncryptionKeyProvider provider = new(keyStoreProvider); + + Assert.AreSame(keyStoreProvider, provider.EncryptionKeyStoreProvider); + Assert.AreEqual(ttl, provider.PdekCacheTimeToLive); + } + + private static CosmosDataEncryptionKeyProvider CreateProvider() + { + return new CosmosDataEncryptionKeyProvider(new TestEncryptionKeyStoreProvider()); + } + } +} From 9bcb7021c87b0b226fa09968fec6aee735e50523 Mon Sep 17 00:00:00 2001 From: MartinSarkany Date: Mon, 13 Oct 2025 12:54:34 +0200 Subject: [PATCH 2/4] Fix contract test --- .../Contracts/DotNetSDKEncryptionCustomAPI.net8.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.net8.json b/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.net8.json index 3eaecdb71c..6ad4cd667b 100644 --- a/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.net8.json +++ b/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.net8.json @@ -1306,6 +1306,11 @@ "Type": "Field", "Attributes": [], "MethodInfo": "Microsoft.Azure.Cosmos.Encryption.Custom.JsonProcessor Newtonsoft;IsInitOnly:False;IsStatic:True;" + }, + "Microsoft.Azure.Cosmos.Encryption.Custom.JsonProcessor Stream": { + "Type": "Field", + "Attributes": [], + "MethodInfo": "Microsoft.Azure.Cosmos.Encryption.Custom.JsonProcessor Stream;IsInitOnly:False;IsStatic:True;" } }, "NestedTypes": {} From 22c344ba4159387f27f667576f511264b22eb940 Mon Sep 17 00:00:00 2001 From: MartinSarkany Date: Tue, 21 Oct 2025 11:17:33 +0200 Subject: [PATCH 3/4] Fix contracts --- .../DotNetSDKEncryptionCustomAPI.json | 30 ++++++++++++------- .../DotNetSDKEncryptionCustomAPI.net8.json | 10 +++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.json b/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.json index 4ed89ffb52..ff60dada92 100644 --- a/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.json +++ b/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.json @@ -76,6 +76,11 @@ "Type": "Constructor", "Attributes": [], "MethodInfo": "[Void .ctor(Microsoft.Data.Encryption.Cryptography.EncryptionKeyStoreProvider, System.Nullable`1[System.TimeSpan]), Void .ctor(Microsoft.Data.Encryption.Cryptography.EncryptionKeyStoreProvider, System.Nullable`1[System.TimeSpan])]" + }, + "Void Initialize(Microsoft.Azure.Cosmos.Container)": { + "Type": "Method", + "Attributes": [], + "MethodInfo": "Void Initialize(Microsoft.Azure.Cosmos.Container);IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" } }, "NestedTypes": {} @@ -259,7 +264,7 @@ ], "MethodInfo": "Byte[] get_WrappedDataEncryptionKey();IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "Byte[] WrappedDataEncryptionKey[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"wrappedDataEncryptionKey\")]": { + "Byte[] WrappedDataEncryptionKey[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"wrappedDataEncryptionKey\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonPropertyAttribute" @@ -271,7 +276,7 @@ "Attributes": [], "MethodInfo": "Int32 GetHashCode();IsAbstract:False;IsStatic:False;IsVirtual:True;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "Microsoft.Azure.Cosmos.Encryption.Custom.EncryptionKeyWrapMetadata EncryptionKeyWrapMetadata[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"keyWrapMetadata\")]": { + "Microsoft.Azure.Cosmos.Encryption.Custom.EncryptionKeyWrapMetadata EncryptionKeyWrapMetadata[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"keyWrapMetadata\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonPropertyAttribute" @@ -285,7 +290,7 @@ ], "MethodInfo": "Microsoft.Azure.Cosmos.Encryption.Custom.EncryptionKeyWrapMetadata get_EncryptionKeyWrapMetadata();IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "System.Nullable`1[System.DateTime] CreatedTime[Newtonsoft.Json.JsonConverterAttribute(typeof(Microsoft.Azure.Cosmos.Encryption.Custom.UnixDateTimeConverter))]-[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"createTime\")]": { + "System.Nullable`1[System.DateTime] CreatedTime[Newtonsoft.Json.JsonConverterAttribute(typeof(Microsoft.Azure.Cosmos.Encryption.Custom.UnixDateTimeConverter))]-[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"createTime\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonConverterAttribute", @@ -307,7 +312,7 @@ ], "MethodInfo": "System.Nullable`1[System.DateTime] get_LastModified();IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "System.Nullable`1[System.DateTime] LastModified[Newtonsoft.Json.JsonConverterAttribute(typeof(Microsoft.Azure.Cosmos.Encryption.Custom.UnixDateTimeConverter))]-[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"_ts\")]": { + "System.Nullable`1[System.DateTime] LastModified[Newtonsoft.Json.JsonConverterAttribute(typeof(Microsoft.Azure.Cosmos.Encryption.Custom.UnixDateTimeConverter))]-[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"_ts\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonConverterAttribute", @@ -315,14 +320,14 @@ ], "MethodInfo": "System.Nullable`1[System.DateTime] LastModified;CanRead:True;CanWrite:True;System.Nullable`1[System.DateTime] get_LastModified();IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "System.String EncryptionAlgorithm[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"encryptionAlgorithm\")]": { + "System.String EncryptionAlgorithm[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"encryptionAlgorithm\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonPropertyAttribute" ], "MethodInfo": "System.String EncryptionAlgorithm;CanRead:True;CanWrite:True;System.String get_EncryptionAlgorithm();IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "System.String ETag[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"_etag\")]": { + "System.String ETag[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"_etag\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonPropertyAttribute" @@ -364,7 +369,7 @@ ], "MethodInfo": "System.String Id;CanRead:True;CanWrite:True;System.String get_Id();IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "System.String SelfLink[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"_self\")]": { + "System.String SelfLink[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"_self\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonPropertyAttribute" @@ -457,6 +462,11 @@ "Type": "Constructor", "Attributes": [], "MethodInfo": "[Void .ctor(Microsoft.Data.Encryption.Cryptography.EncryptionKeyStoreProvider, System.Nullable`1[System.TimeSpan]), Void .ctor(Microsoft.Data.Encryption.Cryptography.EncryptionKeyStoreProvider, System.Nullable`1[System.TimeSpan])]" + }, + "Void Initialize(Microsoft.Azure.Cosmos.Container)": { + "Type": "Method", + "Attributes": [], + "MethodInfo": "Void Initialize(Microsoft.Azure.Cosmos.Container);IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" } }, "NestedTypes": {} @@ -893,14 +903,14 @@ ], "MethodInfo": "System.String get_Value();IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "System.String Name[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"name\")]": { + "System.String Name[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"name\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonPropertyAttribute" ], "MethodInfo": "System.String Name;CanRead:True;CanWrite:True;System.String get_Name();IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" }, - "System.String Value[Newtonsoft.Json.JsonPropertyAttribute(NullValueHandling = 1, PropertyName = \"value\")]": { + "System.String Value[Newtonsoft.Json.JsonPropertyAttribute(PropertyName = \"value\", NullValueHandling = 1)]": { "Type": "Property", "Attributes": [ "JsonPropertyAttribute" @@ -1217,4 +1227,4 @@ } }, "NestedTypes": {} -} \ No newline at end of file +} diff --git a/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.net8.json b/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.net8.json index cec2e37188..f263ee3d1a 100644 --- a/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.net8.json +++ b/Microsoft.Azure.Cosmos.Encryption.Custom/tests/Microsoft.Azure.Cosmos.Encryption.Custom.Tests/Contracts/DotNetSDKEncryptionCustomAPI.net8.json @@ -76,6 +76,11 @@ "Type": "Constructor", "Attributes": [], "MethodInfo": "[Void .ctor(Microsoft.Data.Encryption.Cryptography.EncryptionKeyStoreProvider, System.Nullable`1[System.TimeSpan]), Void .ctor(Microsoft.Data.Encryption.Cryptography.EncryptionKeyStoreProvider, System.Nullable`1[System.TimeSpan])]" + }, + "Void Initialize(Microsoft.Azure.Cosmos.Container)": { + "Type": "Method", + "Attributes": [], + "MethodInfo": "Void Initialize(Microsoft.Azure.Cosmos.Container);IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" } }, "NestedTypes": {} @@ -457,6 +462,11 @@ "Type": "Constructor", "Attributes": [], "MethodInfo": "[Void .ctor(Microsoft.Data.Encryption.Cryptography.EncryptionKeyStoreProvider, System.Nullable`1[System.TimeSpan]), Void .ctor(Microsoft.Data.Encryption.Cryptography.EncryptionKeyStoreProvider, System.Nullable`1[System.TimeSpan])]" + }, + "Void Initialize(Microsoft.Azure.Cosmos.Container)": { + "Type": "Method", + "Attributes": [], + "MethodInfo": "Void Initialize(Microsoft.Azure.Cosmos.Container);IsAbstract:False;IsStatic:False;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" } }, "NestedTypes": {} From 78a94707548d5b0861764002e0ad1108dd93e56f Mon Sep 17 00:00:00 2001 From: MartinSarkany Date: Thu, 13 Nov 2025 15:48:21 +0100 Subject: [PATCH 4/4] Prevent race condition, extend documentation --- .../src/CosmosDataEncryptionKeyProvider.cs | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/Microsoft.Azure.Cosmos.Encryption.Custom/src/CosmosDataEncryptionKeyProvider.cs b/Microsoft.Azure.Cosmos.Encryption.Custom/src/CosmosDataEncryptionKeyProvider.cs index 6700e6aed1..01c90c5c9a 100644 --- a/Microsoft.Azure.Cosmos.Encryption.Custom/src/CosmosDataEncryptionKeyProvider.cs +++ b/Microsoft.Azure.Cosmos.Encryption.Custom/src/CosmosDataEncryptionKeyProvider.cs @@ -10,7 +10,14 @@ namespace Microsoft.Azure.Cosmos.Encryption.Custom using Microsoft.Data.Encryption.Cryptography; /// - /// Default implementation for a provider to get a data encryption key - wrapped keys are stored in a Cosmos DB container. + /// Default implementation for a provider to get a data encryption key (DEK). Wrapped DEKs are stored as items in a Cosmos DB container. + /// Container requirements: + /// - Partition key path must be /id (DEK id used as partition key). + /// - Use a dedicated container to isolate access control and throughput. + /// - Disable TTL to avoid accidental key deletion. + /// Usage pattern: construct , call or once at startup, then use for DEK operations. + /// Concurrency: initialization is single-assignment (uses ) so concurrent calls after success throw . Fetch/create operations are safe concurrently. + /// Resilience: if the container is deleted or unavailable after initialization, operations surface the underlying exception (for example NotFound). Automatic re-creation is not attempted. /// See https://aka.ms/CosmosClientEncryption for more information on client-side encryption support in Azure Cosmos DB. /// public sealed class CosmosDataEncryptionKeyProvider : DataEncryptionKeyProvider @@ -132,18 +139,22 @@ public CosmosDataEncryptionKeyProvider( } /// - /// Initialize Cosmos DB container for CosmosDataEncryptionKeyProvider to store wrapped DEKs + /// Ensures the Cosmos DB container for storing wrapped DEKs exists (creating it if needed) and initializes this provider with that container. + /// This must be invoked exactly once before any fetch/create operations. /// - /// Database - /// Container id - /// Cancellation token - /// A task to await on. + /// The Cosmos DB in which the DEK container should exist. + /// The identifier of the DEK container. If the container does not exist it will be created with partition key path /id. + /// An optional cancellation token. + /// A task representing the asynchronous initialization. public async Task InitializeAsync( Database database, string containerId, CancellationToken cancellationToken = default) { - this.ThrowIfAlreadyInitialized(); + if (Volatile.Read(ref this.container) != null) + { + throw new InvalidOperationException($"{nameof(CosmosDataEncryptionKeyProvider)} has already been initialized."); + } ArgumentValidation.ThrowIfNull(database); @@ -160,23 +171,32 @@ public async Task InitializeAsync( nameof(containerId)); } - this.container = containerResponse.Container; + this.SetContainer(containerResponse.Container); } /// - /// Initialize using an existing Cosmos DB container for storing wrapped DEKs. + /// Initializes the provider with an already created Cosmos DB container that meets the required partition key definition (/id). /// - /// Existing Cosmos DB container. + /// Existing Cosmos DB container containing wrapped DEKs or ready to store them. public void Initialize(Container container) { - this.ThrowIfAlreadyInitialized(); + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } - this.container = container ?? throw new ArgumentNullException(nameof(container)); + this.SetContainer(container); } - private void ThrowIfAlreadyInitialized() + /// + /// Sets the backing Cosmos exactly once. + /// Throws if already initialized to prevent accidental reassignment. + /// + /// The container to associate with this provider. + private void SetContainer(Container container) { - if (this.container != null) + Container previous = Interlocked.CompareExchange(ref this.container, container, null); + if (previous != null) { throw new InvalidOperationException($"{nameof(CosmosDataEncryptionKeyProvider)} has already been initialized."); }