From 57c8fb1f804cbea400ae66671bb285bd4a8a3750 Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Fri, 15 May 2026 15:53:10 +1000 Subject: [PATCH 01/18] Add ClientSecretCredential support --- .../CosmosSinkSettingsTests.cs | 21 ++++++++++++- .../CosmosExtensionServices.cs | 10 +++++++ .../CosmosSettingsBase.cs | 30 +++++++++++++++---- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs index f724911..6950797 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs @@ -40,7 +40,26 @@ public void GetValidationErrors_WithNoRbacConnection_ReturnsError() Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.AccountEndpoint)))); } - + + [TestMethod] + public void GetValidationErrors_WithServicePrincipalAuthAndNoConnectionInfo_ReturnsError() + { + var settings = new CosmosSinkSettings + { + UseServicePrincipalAuth = true, + Database = "db", + Container = "container", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.AccountEndpoint)))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientId)))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)))); + } + [TestMethod] public void Validate_WithConnectionString_Succeeds() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index c158a08..211e6cb 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -99,6 +99,16 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions); } } + else if (settings.UseServicePrincipalAuth) + { + TokenCredential tokenCredential = new ClientSecretCredential( + settings.TenantId, + settings.ClientId, + settings.ClientSecret, + options: new TokenCredentialOptions() + ); + cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions); + } else { cosmosClient = new CosmosClient(settings.ConnectionString, clientOptions); diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs index d147ec0..e50af46 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs @@ -19,7 +19,11 @@ public abstract class CosmosSettingsBase : IValidatableObject public string? AccountEndpoint { get; set; } public bool EnableInteractiveCredentials { get; set; } public bool InitClientEncryption { get; set; } = false; - + public bool UseServicePrincipalAuth { get; set; } = false; + public string? TenantId { get; set; } + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + /// /// /// When running the Azure Cosmos DB emulator in a Linux Container on Windows @@ -49,17 +53,33 @@ public abstract class CosmosSettingsBase : IValidatableObject public virtual IEnumerable Validate(ValidationContext validationContext) { - if (!UseRbacAuth && string.IsNullOrEmpty(ConnectionString)) + if (!UseRbacAuth && !UseServicePrincipalAuth && string.IsNullOrEmpty(ConnectionString)) { - yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth is true", new[] { nameof(ConnectionString) }); + yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth or UseServicePrincipalAuth is true", new[] { nameof(ConnectionString) }); } if (UseRbacAuth && string.IsNullOrEmpty(AccountEndpoint)) { yield return new ValidationResult("AccountEndpoint must be specified when UseRbacAuth is true", new[] { nameof(AccountEndpoint) }); } - if (!UseRbacAuth && InitClientEncryption) + if (!UseRbacAuth && !UseServicePrincipalAuth && InitClientEncryption) + { + yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth or UseServicePrincipalAuth is true", new[] { nameof(InitClientEncryption) }); + } + if (UseServicePrincipalAuth && string.IsNullOrEmpty(AccountEndpoint)) + { + yield return new ValidationResult("AccountEndpoint must be specified when UseServicePrincipalAuth is true", new[] { nameof(AccountEndpoint) }); + } + if (UseServicePrincipalAuth && string.IsNullOrEmpty(TenantId)) + { + yield return new ValidationResult("TenantId must be specified when UseServicePrincipalAuth is true", new[] { nameof(TenantId) }); + } + if (UseServicePrincipalAuth && string.IsNullOrEmpty(ClientId)) + { + yield return new ValidationResult("ClientId must be specified when UseServicePrincipalAuth is true", new[] { nameof(ClientId) }); + } + if (UseServicePrincipalAuth && string.IsNullOrEmpty(ClientSecret)) { - yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth is true", new[] { nameof(InitClientEncryption) }); + yield return new ValidationResult("ClientSecret must be specified when UseServicePrincipalAuth is true", new[] { nameof(ClientSecret) }); } } } From bd0916b0c36cf0fd4db9bdd78fe10dd3e2983177 Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Fri, 15 May 2026 16:21:06 +1000 Subject: [PATCH 02/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../CosmosExtensionServices.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index 211e6cb..d99689b 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -107,7 +107,17 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp settings.ClientSecret, options: new TokenCredentialOptions() ); - cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions); + + if (settings.InitClientEncryption) + { + var keyResolver = new KeyResolver(tokenCredential); + cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions) + .WithEncryption(keyResolver, KeyEncryptionKeyResolverName.AzureKeyVault); + } + else + { + cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions); + } } else { From b4c80e778044f6a8f623f0bdfe69bbd42447953a Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Fri, 15 May 2026 16:25:55 +1000 Subject: [PATCH 03/18] Remove TokenCredentialOptions parameter --- .../CosmosExtensionServices.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index d99689b..8a6f49c 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -104,8 +104,7 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp TokenCredential tokenCredential = new ClientSecretCredential( settings.TenantId, settings.ClientId, - settings.ClientSecret, - options: new TokenCredentialOptions() + settings.ClientSecret ); if (settings.InitClientEncryption) From 6e69a5852202a310fdf8c1fada9029c215d3528e Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Fri, 15 May 2026 16:35:03 +1000 Subject: [PATCH 04/18] UseServicePrincipalAuth and UseRbacAuth are mutual exclusive --- .../CosmosSettingsBase.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs index e50af46..9025979 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs @@ -65,19 +65,23 @@ public virtual IEnumerable Validate(ValidationContext validati { yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth or UseServicePrincipalAuth is true", new[] { nameof(InitClientEncryption) }); } - if (UseServicePrincipalAuth && string.IsNullOrEmpty(AccountEndpoint)) + if (UseServicePrincipalAuth && UseRbacAuth) + { + yield return new ValidationResult("UseRbacAuth and UseServicePrincipalAuth cannot be true at the same time", new[] { nameof(UseRbacAuth), nameof(UseServicePrincipalAuth) }); + } + if (UseServicePrincipalAuth && !UseRbacAuth && string.IsNullOrEmpty(AccountEndpoint)) { yield return new ValidationResult("AccountEndpoint must be specified when UseServicePrincipalAuth is true", new[] { nameof(AccountEndpoint) }); } - if (UseServicePrincipalAuth && string.IsNullOrEmpty(TenantId)) + if (UseServicePrincipalAuth && !UseRbacAuth && string.IsNullOrEmpty(TenantId)) { yield return new ValidationResult("TenantId must be specified when UseServicePrincipalAuth is true", new[] { nameof(TenantId) }); } - if (UseServicePrincipalAuth && string.IsNullOrEmpty(ClientId)) + if (UseServicePrincipalAuth && !UseRbacAuth && string.IsNullOrEmpty(ClientId)) { yield return new ValidationResult("ClientId must be specified when UseServicePrincipalAuth is true", new[] { nameof(ClientId) }); } - if (UseServicePrincipalAuth && string.IsNullOrEmpty(ClientSecret)) + if (UseServicePrincipalAuth && !UseRbacAuth && string.IsNullOrEmpty(ClientSecret)) { yield return new ValidationResult("ClientSecret must be specified when UseServicePrincipalAuth is true", new[] { nameof(ClientSecret) }); } From e9225835bbb5da1057a92398e66aae04cde94427 Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Fri, 15 May 2026 16:35:25 +1000 Subject: [PATCH 05/18] Add positive test --- .../CosmosSinkSettingsTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs index 6950797..a543efc 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs @@ -60,6 +60,24 @@ public void GetValidationErrors_WithServicePrincipalAuthAndNoConnectionInfo_Retu Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)))); } + [TestMethod] + public void GetValidationErrors_WithServicePrincipalAuthAndConnectionInfo_ReturnsNoErrors() + { + var settings = new CosmosSinkSettings + { + UseServicePrincipalAuth = true, + AccountEndpoint = "https://localhost:8081/", + TenantId = "tenant-id", + ClientId = "client-id", + ClientSecret = "client-secret", + Database = "db", + Container = "container", + }; + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + Assert.IsFalse(validationErrors.Any()); + } + [TestMethod] public void Validate_WithConnectionString_Succeeds() { From 6b6812637d6a67bc99b1282cc451839ae75db730 Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Fri, 15 May 2026 16:43:07 +1000 Subject: [PATCH 06/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../CosmosExtensionServices.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index 8a6f49c..a8fae99 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -102,9 +102,9 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp else if (settings.UseServicePrincipalAuth) { TokenCredential tokenCredential = new ClientSecretCredential( - settings.TenantId, - settings.ClientId, - settings.ClientSecret + settings.TenantId!, + settings.ClientId!, + settings.ClientSecret! ); if (settings.InitClientEncryption) From 208c6a750e011eb772899fdea3a0b1bdd6a33825 Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Wed, 20 May 2026 12:41:57 +1000 Subject: [PATCH 07/18] Fold the Service Principal path into UseRbacAuth mode --- .../CosmosSinkSettingsTests.cs | 50 +++++++++++-- .../CosmosSourceSettingsTests.cs | 73 ++++++++++++++++++- .../CosmosExtensionServices.cs | 60 +++++++-------- .../CosmosSettingsBase.cs | 36 ++++----- 4 files changed, 159 insertions(+), 60 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs index a543efc..7a54169 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs @@ -42,36 +42,70 @@ public void GetValidationErrors_WithNoRbacConnection_ReturnsError() } [TestMethod] - public void GetValidationErrors_WithServicePrincipalAuthAndNoConnectionInfo_ReturnsError() + public void GetValidationErrors_WithRbacAuthAndIncompleteServicePrincipalInfo_ReturnsErrors() { var settings = new CosmosSinkSettings { - UseServicePrincipalAuth = true, + UseRbacAuth = true, Database = "db", Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id" }; var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)) && v.Contains(nameof(CosmosSinkSettings.ClientId)))); + } - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.AccountEndpoint)))); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)))); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientId)))); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)))); + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCertificateInfo_ReturnsErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id", + ClientId = "client-id", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); } [TestMethod] - public void GetValidationErrors_WithServicePrincipalAuthAndConnectionInfo_ReturnsNoErrors() + public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ReturnsNoErrors() { var settings = new CosmosSinkSettings { - UseServicePrincipalAuth = true, + UseRbacAuth = true, + Database = "db", + Container = "container", AccountEndpoint = "https://localhost:8081/", TenantId = "tenant-id", ClientId = "client-id", ClientSecret = "client-secret", + }; + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + Assert.IsFalse(validationErrors.Any()); + } + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientCertificateInfo_ReturnsNoErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, Database = "db", Container = "container", + AccountEndpoint = "https://localhost:8081/", + TenantId = "tenant-id", + ClientId = "client-id", + ClientCertificatePath = "./certs/cert.pfx", }; var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs index 1cb18f4..810430d 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs @@ -33,7 +33,78 @@ public void GetValidationErrors_WithNoRbacConnection_ReturnsError() Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.AccountEndpoint)))); } - + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndIncompleteServicePrincipalInfo_ReturnsErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id" + }; + + var validationErrors = settings.GetValidationErrors(); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)) && v.Contains(nameof(CosmosSinkSettings.ClientId)))); + } + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCertificateInfo_ReturnsErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id", + ClientId = "client-id", + }; + + var validationErrors = settings.GetValidationErrors(); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); + } + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ReturnsNoErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://localhost:8081/", + TenantId = "tenant-id", + ClientId = "client-id", + ClientSecret = "client-secret", + }; + var validationErrors = settings.GetValidationErrors(); + + Assert.IsFalse(validationErrors.Any()); + } + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientCertificateInfo_ReturnsNoErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://localhost:8081/", + TenantId = "tenant-id", + ClientId = "client-id", + ClientCertificatePath = "./certs/cert.pfx", + }; + var validationErrors = settings.GetValidationErrors(); + + Assert.IsFalse(validationErrors.Any()); + } + [TestMethod] public void Validate_WithConnectionString_Succeeds() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index a8fae99..40bc0a4 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -8,6 +8,7 @@ using System.Net; using System.Net.Http; using System.Reflection; +using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; namespace Cosmos.DataTransfer.CosmosExtension @@ -58,7 +59,8 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp LimitToEndpoint = settings.LimitToEndpoint, }; - if (!string.IsNullOrEmpty(settings.WebProxy)){ + if (!string.IsNullOrEmpty(settings.WebProxy)) + { var webProxy = new WebProxy(settings.WebProxy); if (settings.UseDefaultProxyCredentials) { @@ -82,41 +84,39 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp logger.LogWarning("SSL certificate validation is DISABLED. This should ONLY be used for development scenarios. Never use in production."); clientOptions.ServerCertificateCustomValidationCallback = (cert, chain, errors) => true; } - + CosmosClient? cosmosClient; if (settings.UseRbacAuth) { - TokenCredential tokenCredential = new DefaultAzureCredential(includeInteractiveCredentials: settings.EnableInteractiveCredentials); - - if(settings.InitClientEncryption) + TokenCredential? tokenCredential = null; + var servicePrincipalFound = !string.IsNullOrEmpty(settings.TenantId) && !string.IsNullOrEmpty(settings.ClientId); + if (servicePrincipalFound) { - var keyResolver = new KeyResolver(tokenCredential); - cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions) - .WithEncryption(keyResolver, KeyEncryptionKeyResolverName.AzureKeyVault); - } - else - { - cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions); - } - } - else if (settings.UseServicePrincipalAuth) - { - TokenCredential tokenCredential = new ClientSecretCredential( - settings.TenantId!, - settings.ClientId!, - settings.ClientSecret! - ); + if (!string.IsNullOrEmpty(settings.ClientSecret)) + { + tokenCredential = new ClientSecretCredential(settings.TenantId, settings.ClientId, settings.ClientSecret); + } + else if (!string.IsNullOrEmpty(settings.ClientCertificatePath)) + { + if (!string.IsNullOrEmpty(settings.ClientCertificatePassword)) + { + var certificate = new X509Certificate2(settings.ClientCertificatePath, settings.ClientCertificatePassword); - if (settings.InitClientEncryption) - { - var keyResolver = new KeyResolver(tokenCredential); - cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions) - .WithEncryption(keyResolver, KeyEncryptionKeyResolverName.AzureKeyVault); - } - else - { - cosmosClient = new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions); + tokenCredential = new ClientCertificateCredential(settings.TenantId, settings.ClientId, certificate); + } + else + { + tokenCredential = new ClientCertificateCredential(settings.TenantId, settings.ClientId, settings.ClientCertificatePath); + } + } } + + tokenCredential ??= new DefaultAzureCredential(includeInteractiveCredentials: settings.EnableInteractiveCredentials); + + cosmosClient = settings.InitClientEncryption + ? new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions) + .WithEncryption(new KeyResolver(tokenCredential), KeyEncryptionKeyResolverName.AzureKeyVault) + : new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions); } else { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs index 9025979..72185fb 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs @@ -19,11 +19,15 @@ public abstract class CosmosSettingsBase : IValidatableObject public string? AccountEndpoint { get; set; } public bool EnableInteractiveCredentials { get; set; } public bool InitClientEncryption { get; set; } = false; - public bool UseServicePrincipalAuth { get; set; } = false; + public string? TenantId { get; set; } public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? ClientCertificatePath { get; set; } + public string? ClientCertificatePassword { get; set; } + /// /// /// When running the Azure Cosmos DB emulator in a Linux Container on Windows @@ -53,37 +57,27 @@ public abstract class CosmosSettingsBase : IValidatableObject public virtual IEnumerable Validate(ValidationContext validationContext) { - if (!UseRbacAuth && !UseServicePrincipalAuth && string.IsNullOrEmpty(ConnectionString)) + if (!UseRbacAuth && string.IsNullOrEmpty(ConnectionString)) { - yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth or UseServicePrincipalAuth is true", new[] { nameof(ConnectionString) }); + yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth is true", new[] { nameof(ConnectionString) }); } if (UseRbacAuth && string.IsNullOrEmpty(AccountEndpoint)) { yield return new ValidationResult("AccountEndpoint must be specified when UseRbacAuth is true", new[] { nameof(AccountEndpoint) }); } - if (!UseRbacAuth && !UseServicePrincipalAuth && InitClientEncryption) - { - yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth or UseServicePrincipalAuth is true", new[] { nameof(InitClientEncryption) }); - } - if (UseServicePrincipalAuth && UseRbacAuth) - { - yield return new ValidationResult("UseRbacAuth and UseServicePrincipalAuth cannot be true at the same time", new[] { nameof(UseRbacAuth), nameof(UseServicePrincipalAuth) }); - } - if (UseServicePrincipalAuth && !UseRbacAuth && string.IsNullOrEmpty(AccountEndpoint)) - { - yield return new ValidationResult("AccountEndpoint must be specified when UseServicePrincipalAuth is true", new[] { nameof(AccountEndpoint) }); - } - if (UseServicePrincipalAuth && !UseRbacAuth && string.IsNullOrEmpty(TenantId)) + if (!UseRbacAuth && InitClientEncryption) { - yield return new ValidationResult("TenantId must be specified when UseServicePrincipalAuth is true", new[] { nameof(TenantId) }); + yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth is true", new[] { nameof(InitClientEncryption) }); } - if (UseServicePrincipalAuth && !UseRbacAuth && string.IsNullOrEmpty(ClientId)) + var servicePrincipalAttributes = new[] { TenantId, ClientId }; + var notAllAttributesFound = servicePrincipalAttributes.Any(attr => string.IsNullOrEmpty(attr)) && !servicePrincipalAttributes.All(attr => string.IsNullOrEmpty(attr)); + if (UseRbacAuth && notAllAttributesFound) { - yield return new ValidationResult("ClientId must be specified when UseServicePrincipalAuth is true", new[] { nameof(ClientId) }); + yield return new ValidationResult("Both TenantId and ClientId must be specified when UseRbacAuth is used with service principal", [nameof(TenantId), nameof(ClientId)]); } - if (UseServicePrincipalAuth && !UseRbacAuth && string.IsNullOrEmpty(ClientSecret)) + if (UseRbacAuth && !servicePrincipalAttributes.Any(attr => string.IsNullOrEmpty(attr)) && new[] { ClientSecret, ClientCertificatePath }.All(s => string.IsNullOrEmpty(s))) { - yield return new ValidationResult("ClientSecret must be specified when UseServicePrincipalAuth is true", new[] { nameof(ClientSecret) }); + yield return new ValidationResult("Either ClientSecret or ClientCertificatePath must be specified when UseRbacAuth is used with service principal", [nameof(ClientSecret), nameof(ClientCertificatePath)]); } } } From 43a81af1f18767c056bdd7b1cf7bc7a88554206c Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Thu, 21 May 2026 14:47:08 +1000 Subject: [PATCH 08/18] Fix copy-paste bug --- .../CosmosSourceSettingsTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs index 810430d..5f41971 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs @@ -16,7 +16,7 @@ public void GetValidationErrors_WithNoConnection_ReturnsError() var validationErrors = settings.GetValidationErrors(); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ConnectionString)))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ConnectionString)))); } [TestMethod] @@ -31,13 +31,13 @@ public void GetValidationErrors_WithNoRbacConnection_ReturnsError() var validationErrors = settings.GetValidationErrors(); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.AccountEndpoint)))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.AccountEndpoint)))); } [TestMethod] public void GetValidationErrors_WithRbacAuthAndIncompleteServicePrincipalInfo_ReturnsErrors() { - var settings = new CosmosSinkSettings + var settings = new CosmosSourceSettings { UseRbacAuth = true, Database = "db", @@ -48,13 +48,13 @@ public void GetValidationErrors_WithRbacAuthAndIncompleteServicePrincipalInfo_Re var validationErrors = settings.GetValidationErrors(); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)) && v.Contains(nameof(CosmosSinkSettings.ClientId)))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.TenantId)) && v.Contains(nameof(CosmosSourceSettings.ClientId)))); } [TestMethod] public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCertificateInfo_ReturnsErrors() { - var settings = new CosmosSinkSettings + var settings = new CosmosSourceSettings { UseRbacAuth = true, Database = "db", @@ -66,13 +66,13 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCert var validationErrors = settings.GetValidationErrors(); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ClientSecret)) && v.Contains(nameof(CosmosSourceSettings.ClientCertificatePath)))); } [TestMethod] public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ReturnsNoErrors() { - var settings = new CosmosSinkSettings + var settings = new CosmosSourceSettings { UseRbacAuth = true, Database = "db", @@ -90,7 +90,7 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ [TestMethod] public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientCertificateInfo_ReturnsNoErrors() { - var settings = new CosmosSinkSettings + var settings = new CosmosSourceSettings { UseRbacAuth = true, Database = "db", From 228fa7d9884bed61d7cb07158dab9066a1f54d8b Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Thu, 21 May 2026 15:41:04 +1000 Subject: [PATCH 09/18] Fix validation logic As per feedback: https://github.com/AzureCosmosDB/data-migration-desktop-tool/pull/242#discussion_r3278095700 and https://github.com/AzureCosmosDB/data-migration-desktop-tool/pull/242#discussion_r3278095791 --- .../CosmosSinkSettingsTests.cs | 38 +++++++++++++++++++ .../CosmosSourceSettingsTests.cs | 37 ++++++++++++++++++ .../CosmosSettingsBase.cs | 32 +++++++++++----- 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs index 7a54169..152fd98 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs @@ -76,6 +76,44 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCert Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); } + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndSecretAndCertificateInfo_ReturnsErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id", + ClientId = "client-id", + ClientSecret = "client-secret", + ClientCertificatePath = "./certs/cert.pfx", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); + } + + [TestMethod] + public void GetValidationErrors_WithNoRbacAuthButHasServicePrincipal_ReturnsError() + { + var settings = new CosmosSinkSettings + { + ConnectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=", + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id" + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.UseRbacAuth)))); + } + [TestMethod] public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ReturnsNoErrors() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs index 5f41971..80c2bc9 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs @@ -69,6 +69,43 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCert Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ClientSecret)) && v.Contains(nameof(CosmosSourceSettings.ClientCertificatePath)))); } + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndSecretAndCertificateInfo_ReturnsErrors() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id", + ClientId = "client-id", + ClientSecret = "client-secret", + ClientCertificatePath = "./certs/cert.pfx", + }; + + var validationErrors = settings.GetValidationErrors(); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ClientSecret)) && v.Contains(nameof(CosmosSourceSettings.ClientCertificatePath)))); + } + + [TestMethod] + public void GetValidationErrors_WithNoRbacAuthButHasServicePrincipal_ReturnsError() + { + var settings = new CosmosSourceSettings + { + ConnectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=", + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id" + }; + + var validationErrors = settings.GetValidationErrors(); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.UseRbacAuth)))); + } + [TestMethod] public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ReturnsNoErrors() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs index 72185fb..1a72c0b 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs @@ -59,25 +59,39 @@ public virtual IEnumerable Validate(ValidationContext validati { if (!UseRbacAuth && string.IsNullOrEmpty(ConnectionString)) { - yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth is true", new[] { nameof(ConnectionString) }); + yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth is true", [nameof(ConnectionString)]); } if (UseRbacAuth && string.IsNullOrEmpty(AccountEndpoint)) { - yield return new ValidationResult("AccountEndpoint must be specified when UseRbacAuth is true", new[] { nameof(AccountEndpoint) }); + yield return new ValidationResult("AccountEndpoint must be specified when UseRbacAuth is true", [nameof(AccountEndpoint)]); } if (!UseRbacAuth && InitClientEncryption) { - yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth is true", new[] { nameof(InitClientEncryption) }); + yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth is true", [nameof(InitClientEncryption)]); } - var servicePrincipalAttributes = new[] { TenantId, ClientId }; - var notAllAttributesFound = servicePrincipalAttributes.Any(attr => string.IsNullOrEmpty(attr)) && !servicePrincipalAttributes.All(attr => string.IsNullOrEmpty(attr)); - if (UseRbacAuth && notAllAttributesFound) + var tenantIdSet = !string.IsNullOrEmpty(TenantId); + var clientIdSet = !string.IsNullOrEmpty(ClientId); + var servicePrincipalSet = tenantIdSet && clientIdSet; + if (UseRbacAuth && tenantIdSet != clientIdSet) { - yield return new ValidationResult("Both TenantId and ClientId must be specified when UseRbacAuth is used with service principal", [nameof(TenantId), nameof(ClientId)]); + yield return new ValidationResult("Both TenantId and ClientId must be specified when UseRbacAuth is used with service principal", + [nameof(TenantId), nameof(ClientId)]); } - if (UseRbacAuth && !servicePrincipalAttributes.Any(attr => string.IsNullOrEmpty(attr)) && new[] { ClientSecret, ClientCertificatePath }.All(s => string.IsNullOrEmpty(s))) + var clientSecretSet = !string.IsNullOrEmpty(ClientSecret); + var clientCertificateSet = !string.IsNullOrEmpty(ClientCertificatePath); + if (UseRbacAuth && servicePrincipalSet && !clientSecretSet && !clientCertificateSet) { - yield return new ValidationResult("Either ClientSecret or ClientCertificatePath must be specified when UseRbacAuth is used with service principal", [nameof(ClientSecret), nameof(ClientCertificatePath)]); + yield return new ValidationResult("Either ClientSecret or ClientCertificatePath must be specified when UseRbacAuth is used with service principal", + [nameof(ClientSecret), nameof(ClientCertificatePath)]); + } + if (UseRbacAuth && servicePrincipalSet && clientSecretSet && clientCertificateSet) + { + yield return new ValidationResult("Specify either ClientSecret or ClientCertificatePath, not both.", + [nameof(ClientSecret), nameof(ClientCertificatePath)]); + } + if (!UseRbacAuth && (tenantIdSet || clientIdSet || clientSecretSet || clientCertificateSet)) + { + yield return new ValidationResult("Service principal settings require UseRbacAuth to be set to true.", [nameof(UseRbacAuth)]); } } } From 26a5b02920087f51409a171005de940e81f4d476 Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Thu, 21 May 2026 15:57:32 +1000 Subject: [PATCH 10/18] Address various code review feedback for PR #242 --- .../CosmosExtensionServices.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index 40bc0a4..b1940d0 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -89,17 +89,21 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp if (settings.UseRbacAuth) { TokenCredential? tokenCredential = null; - var servicePrincipalFound = !string.IsNullOrEmpty(settings.TenantId) && !string.IsNullOrEmpty(settings.ClientId); - if (servicePrincipalFound) + + if (!string.IsNullOrEmpty(settings.TenantId) && !string.IsNullOrEmpty(settings.ClientId)) { if (!string.IsNullOrEmpty(settings.ClientSecret)) { + logger.LogWarning("ClientSecret is configured in settings. Ensure this configuration file is not committed to source control. " + + "Consider injecting via environment variables, command-line args (--{Section}:ClientSecret=...), or User Secrets instead.", + settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"); tokenCredential = new ClientSecretCredential(settings.TenantId, settings.ClientId, settings.ClientSecret); } else if (!string.IsNullOrEmpty(settings.ClientCertificatePath)) { if (!string.IsNullOrEmpty(settings.ClientCertificatePassword)) { + // TODO: switch to X509CertificateLoader when targeting .NET 9+ var certificate = new X509Certificate2(settings.ClientCertificatePath, settings.ClientCertificatePassword); tokenCredential = new ClientCertificateCredential(settings.TenantId, settings.ClientId, certificate); From 1d4eb01cbd465caac6f8629182937994bc203e25 Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Thu, 21 May 2026 16:34:28 +1000 Subject: [PATCH 11/18] Apply suggestions from Copilot code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../CosmosExtensionServices.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index b1940d0..adc8642 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -94,8 +94,7 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp { if (!string.IsNullOrEmpty(settings.ClientSecret)) { - logger.LogWarning("ClientSecret is configured in settings. Ensure this configuration file is not committed to source control. " + - "Consider injecting via environment variables, command-line args (--{Section}:ClientSecret=...), or User Secrets instead.", + logger.LogWarning("ClientSecret is configured in settings. Ensure this configuration file is not committed to source control. Consider injecting via environment variables, command-line args (--{Section}:ClientSecret=...), or User Secrets instead.", settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"); tokenCredential = new ClientSecretCredential(settings.TenantId, settings.ClientId, settings.ClientSecret); } @@ -104,7 +103,10 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp if (!string.IsNullOrEmpty(settings.ClientCertificatePassword)) { // TODO: switch to X509CertificateLoader when targeting .NET 9+ - var certificate = new X509Certificate2(settings.ClientCertificatePath, settings.ClientCertificatePassword); + var certificate = new X509Certificate2( + settings.ClientCertificatePath, + settings.ClientCertificatePassword, + X509KeyStorageFlags.EphemeralKeySet); tokenCredential = new ClientCertificateCredential(settings.TenantId, settings.ClientId, certificate); } From 191f978f121aed98eecae0106a3526591a627226 Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Thu, 21 May 2026 20:25:50 +1000 Subject: [PATCH 12/18] Fix message-template placeholder --- .../CosmosExtensionServices.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index adc8642..f97c453 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -94,8 +94,8 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp { if (!string.IsNullOrEmpty(settings.ClientSecret)) { - logger.LogWarning("ClientSecret is configured in settings. Ensure this configuration file is not committed to source control. Consider injecting via environment variables, command-line args (--{Section}:ClientSecret=...), or User Secrets instead.", - settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"); + var section = settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"; + logger.LogWarning("ClientSecret is configured in settings. Ensure this configuration file is not committed to source control. Consider injecting via environment variables, command-line args (--{section}:ClientSecret=...), or User Secrets instead.", section); tokenCredential = new ClientSecretCredential(settings.TenantId, settings.ClientId, settings.ClientSecret); } else if (!string.IsNullOrEmpty(settings.ClientCertificatePath)) From 1bba6384c59b6dd57f1ad6366ac96fbaeb87b1bb Mon Sep 17 00:00:00 2001 From: Thuc Nguyen Date: Thu, 21 May 2026 20:43:47 +1000 Subject: [PATCH 13/18] Add more validations as per Copilot review comments --- .../CosmosSinkSettingsTests.cs | 37 +++++++++++++++++++ .../CosmosSourceSettingsTests.cs | 37 +++++++++++++++++++ .../CosmosSettingsBase.cs | 10 +++++ 3 files changed, 84 insertions(+) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs index 152fd98..d128a36 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs @@ -96,6 +96,43 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndSecretAndCerti Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); } + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndPasswordButNoCertificate_ReturnsErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id", + ClientId = "client-id", + ClientCertificatePassword = "client-secret-password", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientCertificatePassword)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); + } + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndSecretOrCertificateButNoServicePrincipal_ReturnsErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + ClientSecret = "client-secret", + ClientCertificatePath = "./certs/cert.pfx", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)) && v.Contains(nameof(CosmosSinkSettings.ClientId)))); + } + [TestMethod] public void GetValidationErrors_WithNoRbacAuthButHasServicePrincipal_ReturnsError() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs index 80c2bc9..bcc4ce0 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs @@ -89,6 +89,43 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndSecretAndCerti Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ClientSecret)) && v.Contains(nameof(CosmosSourceSettings.ClientCertificatePath)))); } + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndPasswordButNoCertificate_ReturnsErrors() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = "tenant-id", + ClientId = "client-id", + ClientCertificatePassword = "client-secret-password", + }; + + var validationErrors = settings.GetValidationErrors(); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ClientCertificatePassword)) && v.Contains(nameof(CosmosSourceSettings.ClientCertificatePath)))); + } + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndSecretOrCertificateButNoServicePrincipal_ReturnsErrors() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + ClientSecret = "client-secret", + ClientCertificatePath = "./certs/cert.pfx", + }; + + var validationErrors = settings.GetValidationErrors(); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.TenantId)) && v.Contains(nameof(CosmosSourceSettings.ClientId)))); + } + [TestMethod] public void GetValidationErrors_WithNoRbacAuthButHasServicePrincipal_ReturnsError() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs index 1a72c0b..58cb35a 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs @@ -89,6 +89,16 @@ public virtual IEnumerable Validate(ValidationContext validati yield return new ValidationResult("Specify either ClientSecret or ClientCertificatePath, not both.", [nameof(ClientSecret), nameof(ClientCertificatePath)]); } + if (UseRbacAuth && servicePrincipalSet && !clientCertificateSet && !string.IsNullOrEmpty(ClientCertificatePassword)) + { + yield return new ValidationResult("ClientCertificatePassword can only be set when ClientCertificatePath is set.", + [nameof(ClientCertificatePassword), nameof(ClientCertificatePath)]); + } + if (UseRbacAuth && !servicePrincipalSet && (clientSecretSet || clientCertificateSet)) + { + yield return new ValidationResult("ClientSecret or ClientCertificatePath cannot be set without TenantId/ClientId.", + [nameof(TenantId), nameof(ClientId)]); + } if (!UseRbacAuth && (tenantIdSet || clientIdSet || clientSecretSet || clientCertificateSet)) { yield return new ValidationResult("Service principal settings require UseRbacAuth to be set to true.", [nameof(UseRbacAuth)]); From 7ab7d0a339e7177400612676f7e8bfc9962e94f9 Mon Sep 17 00:00:00 2001 From: Phil Nachreiner Date: Tue, 26 May 2026 12:59:20 -0700 Subject: [PATCH 14/18] Cosmos RBAC SP auth hardening and test coverage --- .../CosmosExtensionServicesCredentialTests.cs | 102 ++++++++++++++++ .../CosmosSinkSettingsTests.cs | 11 +- .../CosmosSourceSettingsTests.cs | 35 ++++-- .../CosmosExtensionServices.cs | 110 ++++++++++++------ .../CosmosSettingsBase.cs | 73 ++++++++---- .../Properties/AssemblyInfo.cs | 3 + Extensions/Cosmos/README.md | 89 +++++++++++--- 7 files changed, 340 insertions(+), 83 deletions(-) create mode 100644 Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs create mode 100644 Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/Properties/AssemblyInfo.cs diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs new file mode 100644 index 0000000..97ff770 --- /dev/null +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs @@ -0,0 +1,102 @@ +using Azure.Identity; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Cosmos.DataTransfer.CosmosExtension.UnitTests; + +[TestClass] +public class CosmosExtensionServicesCredentialTests +{ + [TestMethod] + public void GetTokenCredentialSelection_WithNoServicePrincipalInfo_ReturnsDefaultCredential() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + AccountEndpoint = "https://localhost:8081/", + Database = "db", + Container = "container", + }; + + var selection = CosmosExtensionServices.GetTokenCredentialSelection(settings); + + Assert.AreEqual(CosmosExtensionServices.TokenCredentialSelection.DefaultAzureCredential, selection); + } + + [TestMethod] + public void GetTokenCredentialSelection_WithTenantClientAndSecret_ReturnsClientSecretCredential() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + AccountEndpoint = "https://localhost:8081/", + Database = "db", + Container = "container", + TenantId = "tenant-id", + ClientId = "client-id", + ClientSecret = "client-secret", + }; + + var selection = CosmosExtensionServices.GetTokenCredentialSelection(settings); + + Assert.AreEqual(CosmosExtensionServices.TokenCredentialSelection.ClientSecretCredential, selection); + } + + [TestMethod] + public void GetTokenCredentialSelection_WithTenantClientAndCertificatePath_ReturnsClientCertificateCredential() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + AccountEndpoint = "https://localhost:8081/", + Database = "db", + Container = "container", + TenantId = "tenant-id", + ClientId = "client-id", + ClientCertificatePath = "./certs/cert.pfx", + }; + + var selection = CosmosExtensionServices.GetTokenCredentialSelection(settings); + + Assert.AreEqual(CosmosExtensionServices.TokenCredentialSelection.ClientCertificateCredential, selection); + } + + [TestMethod] + public void CreateRbacTokenCredential_WithNoServicePrincipalInfo_ReturnsDefaultAzureCredential() + { + var loggerMock = new Mock(); + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + AccountEndpoint = "https://localhost:8081/", + Database = "db", + Container = "container", + }; + + var credential = CosmosExtensionServices.CreateRbacTokenCredential(settings, loggerMock.Object); + + Assert.IsInstanceOfType(credential); + } + + [TestMethod] + public void CreateRbacTokenCredential_WithInvalidCertificatePath_ThrowsFriendlyConfigurationError() + { + var loggerMock = new Mock(); + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + AccountEndpoint = "https://localhost:8081/", + Database = "db", + Container = "container", + TenantId = "tenant-id", + ClientId = "client-id", + ClientCertificatePath = "./certs/does-not-exist.pfx", + }; + + var ex = Assert.ThrowsException(() => + CosmosExtensionServices.CreateRbacTokenCredential(settings, loggerMock.Object)); + + StringAssert.Contains(ex.Message, "Failed to configure RBAC credentials"); + Assert.IsNotNull(ex.InnerException); + } +} diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs index d128a36..ab93d66 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs @@ -55,6 +55,7 @@ public void GetValidationErrors_WithRbacAuthAndIncompleteServicePrincipalInfo_Re var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)) && v.Contains(nameof(CosmosSinkSettings.ClientId)))); } @@ -73,6 +74,7 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCert var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); } @@ -93,6 +95,7 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndSecretAndCerti var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientSecret)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); } @@ -112,6 +115,7 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndPasswordButNoC var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ClientCertificatePassword)) && v.Contains(nameof(CosmosSinkSettings.ClientCertificatePath)))); } @@ -130,6 +134,7 @@ public void GetValidationErrors_WithRbacAuthAndSecretOrCertificateButNoServicePr var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)) && v.Contains(nameof(CosmosSinkSettings.ClientId)))); } @@ -164,8 +169,10 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ ClientId = "client-id", ClientSecret = "client-secret", }; + var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); + Assert.IsFalse(validationErrors.Any()); } @@ -182,11 +189,13 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientCertificate ClientId = "client-id", ClientCertificatePath = "./certs/cert.pfx", }; + var validationErrors = settings.GetValidationErrors(); LogErrors(validationErrors); + Assert.IsFalse(validationErrors.Any()); } - + [TestMethod] public void Validate_WithConnectionString_Succeeds() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs index bcc4ce0..cd75a4a 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs @@ -5,6 +5,11 @@ namespace Cosmos.DataTransfer.CosmosExtension.UnitTests; [TestClass] public class CosmosSourceSettingsTests { + private static void LogErrors(IEnumerable errors) + { + foreach (var error in errors) Console.WriteLine($"Validation Error: {error}"); + } + [TestMethod] public void GetValidationErrors_WithNoConnection_ReturnsError() { @@ -15,6 +20,7 @@ public void GetValidationErrors_WithNoConnection_ReturnsError() }; var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ConnectionString)))); } @@ -30,6 +36,7 @@ public void GetValidationErrors_WithNoRbacConnection_ReturnsError() }; var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.AccountEndpoint)))); } @@ -47,7 +54,8 @@ public void GetValidationErrors_WithRbacAuthAndIncompleteServicePrincipalInfo_Re }; var validationErrors = settings.GetValidationErrors(); - + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.TenantId)) && v.Contains(nameof(CosmosSourceSettings.ClientId)))); } @@ -65,7 +73,8 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCert }; var validationErrors = settings.GetValidationErrors(); - + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ClientSecret)) && v.Contains(nameof(CosmosSourceSettings.ClientCertificatePath)))); } @@ -85,7 +94,8 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndSecretAndCerti }; var validationErrors = settings.GetValidationErrors(); - + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ClientSecret)) && v.Contains(nameof(CosmosSourceSettings.ClientCertificatePath)))); } @@ -104,7 +114,8 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalAndPasswordButNoC }; var validationErrors = settings.GetValidationErrors(); - + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ClientCertificatePassword)) && v.Contains(nameof(CosmosSourceSettings.ClientCertificatePath)))); } @@ -122,7 +133,8 @@ public void GetValidationErrors_WithRbacAuthAndSecretOrCertificateButNoServicePr }; var validationErrors = settings.GetValidationErrors(); - + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.TenantId)) && v.Contains(nameof(CosmosSourceSettings.ClientId)))); } @@ -139,7 +151,8 @@ public void GetValidationErrors_WithNoRbacAuthButHasServicePrincipal_ReturnsErro }; var validationErrors = settings.GetValidationErrors(); - + LogErrors(validationErrors); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.UseRbacAuth)))); } @@ -156,8 +169,10 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ ClientId = "client-id", ClientSecret = "client-secret", }; + var validationErrors = settings.GetValidationErrors(); - + LogErrors(validationErrors); + Assert.IsFalse(validationErrors.Any()); } @@ -174,11 +189,13 @@ public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientCertificate ClientId = "client-id", ClientCertificatePath = "./certs/cert.pfx", }; + var validationErrors = settings.GetValidationErrors(); - + LogErrors(validationErrors); + Assert.IsFalse(validationErrors.Any()); } - + [TestMethod] public void Validate_WithConnectionString_Succeeds() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index f97c453..f166e4b 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -5,16 +5,24 @@ using Microsoft.Azure.Cosmos.Encryption; using Microsoft.Extensions.Logging; using System.Globalization; +using System.IO; using System.Net; using System.Net.Http; using System.Reflection; -using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography; using System.Text.RegularExpressions; namespace Cosmos.DataTransfer.CosmosExtension { public static class CosmosExtensionServices { + internal enum TokenCredentialSelection + { + DefaultAzureCredential, + ClientSecretCredential, + ClientCertificateCredential, + } + // Static HttpClient instances with different configurations for reuse across connections // This avoids connection exhaustion and properly handles credentials private static readonly Lazy _httpClientWithDefaultCredentials = new Lazy(() => @@ -37,6 +45,71 @@ public static class CosmosExtensionServices return new HttpClient(handler); }); + // NOTE: Kept as a separate helper so auth-path behavior can be tested directly. + internal static TokenCredentialSelection GetTokenCredentialSelection(CosmosSettingsBase settings) + { + if (!string.IsNullOrEmpty(settings.TenantId) && !string.IsNullOrEmpty(settings.ClientId)) + { + if (!string.IsNullOrEmpty(settings.ClientSecret)) + { + return TokenCredentialSelection.ClientSecretCredential; + } + + if (!string.IsNullOrEmpty(settings.ClientCertificatePath)) + { + return TokenCredentialSelection.ClientCertificateCredential; + } + } + + return TokenCredentialSelection.DefaultAzureCredential; + } + + // NOTE: Added explicit exception wrapping to surface actionable auth configuration failures. + internal static TokenCredential CreateRbacTokenCredential(CosmosSettingsBase settings, ILogger logger) + { + try + { + var selection = GetTokenCredentialSelection(settings); + switch (selection) + { + case TokenCredentialSelection.ClientSecretCredential: + { + var section = settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"; + logger.LogWarning( + "ClientSecret is configured in settings. Ensure this configuration file is not committed to source control. Consider injecting via environment variables, command-line args (--{Section}:ClientSecret=...), or User Secrets instead.", + section); + return new ClientSecretCredential(settings.TenantId!, settings.ClientId!, settings.ClientSecret!); + } + + case TokenCredentialSelection.ClientCertificateCredential: + // NOTE: We intentionally use Azure.Identity's certificate-path overload so + // credential/material lifetime is owned by the SDK and not manually tracked in this process. + if (!File.Exists(settings.ClientCertificatePath)) + { + throw new FileNotFoundException( + "Client certificate file was not found.", + settings.ClientCertificatePath); + } + + return new ClientCertificateCredential(settings.TenantId!, settings.ClientId!, settings.ClientCertificatePath!); + + default: + return new DefaultAzureCredential(includeInteractiveCredentials: settings.EnableInteractiveCredentials); + } + } + catch (Exception ex) when ( + ex is CryptographicException || + ex is IOException || + ex is UnauthorizedAccessException || + ex is ArgumentException) + { + var section = settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"; + throw new InvalidOperationException( + $"Failed to configure RBAC credentials from {section}. Validate TenantId/ClientId and service principal secret/certificate settings.", + ex); + } + } + public static CosmosClient CreateClient(CosmosSettingsBase settings, string displayName, ILogger logger, string? sourceDisplayName = null) { string userAgentString = CreateUserAgentString(displayName, sourceDisplayName); @@ -84,44 +157,15 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp logger.LogWarning("SSL certificate validation is DISABLED. This should ONLY be used for development scenarios. Never use in production."); clientOptions.ServerCertificateCustomValidationCallback = (cert, chain, errors) => true; } - + CosmosClient? cosmosClient; if (settings.UseRbacAuth) { - TokenCredential? tokenCredential = null; - - if (!string.IsNullOrEmpty(settings.TenantId) && !string.IsNullOrEmpty(settings.ClientId)) - { - if (!string.IsNullOrEmpty(settings.ClientSecret)) - { - var section = settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"; - logger.LogWarning("ClientSecret is configured in settings. Ensure this configuration file is not committed to source control. Consider injecting via environment variables, command-line args (--{section}:ClientSecret=...), or User Secrets instead.", section); - tokenCredential = new ClientSecretCredential(settings.TenantId, settings.ClientId, settings.ClientSecret); - } - else if (!string.IsNullOrEmpty(settings.ClientCertificatePath)) - { - if (!string.IsNullOrEmpty(settings.ClientCertificatePassword)) - { - // TODO: switch to X509CertificateLoader when targeting .NET 9+ - var certificate = new X509Certificate2( - settings.ClientCertificatePath, - settings.ClientCertificatePassword, - X509KeyStorageFlags.EphemeralKeySet); - - tokenCredential = new ClientCertificateCredential(settings.TenantId, settings.ClientId, certificate); - } - else - { - tokenCredential = new ClientCertificateCredential(settings.TenantId, settings.ClientId, settings.ClientCertificatePath); - } - } - } - - tokenCredential ??= new DefaultAzureCredential(includeInteractiveCredentials: settings.EnableInteractiveCredentials); + var tokenCredential = CreateRbacTokenCredential(settings, logger); cosmosClient = settings.InitClientEncryption ? new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions) - .WithEncryption(new KeyResolver(tokenCredential), KeyEncryptionKeyResolverName.AzureKeyVault) + .WithEncryption(new KeyResolver(tokenCredential), KeyEncryptionKeyResolverName.AzureKeyVault) : new CosmosClient(settings.AccountEndpoint, tokenCredential, clientOptions); } else diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs index 58cb35a..14b1d03 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs @@ -19,15 +19,12 @@ public abstract class CosmosSettingsBase : IValidatableObject public string? AccountEndpoint { get; set; } public bool EnableInteractiveCredentials { get; set; } public bool InitClientEncryption { get; set; } = false; - public string? TenantId { get; set; } public string? ClientId { get; set; } - public string? ClientSecret { get; set; } - public string? ClientCertificatePath { get; set; } public string? ClientCertificatePassword { get; set; } - + /// /// /// When running the Azure Cosmos DB emulator in a Linux Container on Windows @@ -59,49 +56,79 @@ public virtual IEnumerable Validate(ValidationContext validati { if (!UseRbacAuth && string.IsNullOrEmpty(ConnectionString)) { - yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth is true", [nameof(ConnectionString)]); + yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth is true", new[] { nameof(ConnectionString) }); } if (UseRbacAuth && string.IsNullOrEmpty(AccountEndpoint)) { - yield return new ValidationResult("AccountEndpoint must be specified when UseRbacAuth is true", [nameof(AccountEndpoint)]); + yield return new ValidationResult("AccountEndpoint must be specified when UseRbacAuth is true", new[] { nameof(AccountEndpoint) }); } if (!UseRbacAuth && InitClientEncryption) { - yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth is true", [nameof(InitClientEncryption)]); + yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth is true", new[] { nameof(InitClientEncryption) }); } + var tenantIdSet = !string.IsNullOrEmpty(TenantId); var clientIdSet = !string.IsNullOrEmpty(ClientId); var servicePrincipalSet = tenantIdSet && clientIdSet; + var clientSecretSet = !string.IsNullOrEmpty(ClientSecret); + var clientCertificateSet = !string.IsNullOrEmpty(ClientCertificatePath); + var clientCertificatePasswordSet = !string.IsNullOrEmpty(ClientCertificatePassword); + if (UseRbacAuth && tenantIdSet != clientIdSet) { - yield return new ValidationResult("Both TenantId and ClientId must be specified when UseRbacAuth is used with service principal", - [nameof(TenantId), nameof(ClientId)]); + yield return new ValidationResult( + "Both TenantId and ClientId must be specified when UseRbacAuth is used with service principal", + new[] { nameof(TenantId), nameof(ClientId) }); } - var clientSecretSet = !string.IsNullOrEmpty(ClientSecret); - var clientCertificateSet = !string.IsNullOrEmpty(ClientCertificatePath); + if (UseRbacAuth && servicePrincipalSet && !clientSecretSet && !clientCertificateSet) { - yield return new ValidationResult("Either ClientSecret or ClientCertificatePath must be specified when UseRbacAuth is used with service principal", - [nameof(ClientSecret), nameof(ClientCertificatePath)]); + yield return new ValidationResult( + "Either ClientSecret or ClientCertificatePath must be specified when UseRbacAuth is used with service principal", + new[] { nameof(ClientSecret), nameof(ClientCertificatePath) }); } + if (UseRbacAuth && servicePrincipalSet && clientSecretSet && clientCertificateSet) { - yield return new ValidationResult("Specify either ClientSecret or ClientCertificatePath, not both.", - [nameof(ClientSecret), nameof(ClientCertificatePath)]); + yield return new ValidationResult( + "Specify either ClientSecret or ClientCertificatePath, not both.", + new[] { nameof(ClientSecret), nameof(ClientCertificatePath) }); } - if (UseRbacAuth && servicePrincipalSet && !clientCertificateSet && !string.IsNullOrEmpty(ClientCertificatePassword)) + + if (UseRbacAuth && servicePrincipalSet && !clientCertificateSet && clientCertificatePasswordSet) { - yield return new ValidationResult("ClientCertificatePassword can only be set when ClientCertificatePath is set.", - [nameof(ClientCertificatePassword), nameof(ClientCertificatePath)]); + yield return new ValidationResult( + "ClientCertificatePassword can only be set when ClientCertificatePath is set.", + new[] { nameof(ClientCertificatePassword), nameof(ClientCertificatePath) }); } - if (UseRbacAuth && !servicePrincipalSet && (clientSecretSet || clientCertificateSet)) + + if (UseRbacAuth && !servicePrincipalSet && (clientSecretSet || clientCertificateSet || clientCertificatePasswordSet)) { - yield return new ValidationResult("ClientSecret or ClientCertificatePath cannot be set without TenantId/ClientId.", - [nameof(TenantId), nameof(ClientId)]); + yield return new ValidationResult( + "ClientSecret, ClientCertificatePath, or ClientCertificatePassword cannot be set without TenantId/ClientId.", + new[] + { + nameof(TenantId), + nameof(ClientId), + nameof(ClientSecret), + nameof(ClientCertificatePath), + nameof(ClientCertificatePassword) + }); } - if (!UseRbacAuth && (tenantIdSet || clientIdSet || clientSecretSet || clientCertificateSet)) + + if (!UseRbacAuth && (tenantIdSet || clientIdSet || clientSecretSet || clientCertificateSet || clientCertificatePasswordSet)) { - yield return new ValidationResult("Service principal settings require UseRbacAuth to be set to true.", [nameof(UseRbacAuth)]); + yield return new ValidationResult( + "Service principal settings require UseRbacAuth to be set to true.", + new[] + { + nameof(UseRbacAuth), + nameof(TenantId), + nameof(ClientId), + nameof(ClientSecret), + nameof(ClientCertificatePath), + nameof(ClientCertificatePassword) + }); } } } diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/Properties/AssemblyInfo.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..2224b52 --- /dev/null +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Cosmos.DataTransfer.CosmosExtension.UnitTests")] diff --git a/Extensions/Cosmos/README.md b/Extensions/Cosmos/README.md index 58b68bf..31c293b 100644 --- a/Extensions/Cosmos/README.md +++ b/Extensions/Cosmos/README.md @@ -28,28 +28,33 @@ These properties will be preserved exactly as they appear in the source when mig ## Settings - ### Main Settings -| Setting | Description | Default | -|------------------------|---------------------------------------------------------------------------------------------------|-----------| -| ConnectionString | Cosmos DB connection string (AccountEndpoint + AccountKey) | | -| UseRbacAuth | Use Role Based Access Control for authentication | false | -| AccountEndpoint | Cosmos DB account endpoint (required for RBAC) | | -| EnableInteractiveCredentials | Prompt for Azure login if default credentials are unavailable | false | -| Database | Cosmos DB database name | | -| Container | Cosmos DB container name | | -| WebProxy | Proxy server URL for Cosmos DB connections | | -| InitClientEncryption | Enable Always Encrypted feature | false | -| LimitToEndpoint | Restrict client to endpoint (see CosmosClientOptions.LimitToEndpoint) | false | -| DisableSslValidation | Disable SSL certificate validation (for local dev only; not for production) | false | -| AllowBulkExecution | Enable bulk execution for optimized performance.
**Warning:** May affect consistency and error handling. | false | - -Source and sink require settings used to locate and access the Cosmos DB account. This can be done in one of two ways: +- `ConnectionString`: Cosmos DB connection string (AccountEndpoint + AccountKey). +- `UseRbacAuth` (default: `false`): Use role-based access control for authentication. +- `AccountEndpoint`: Cosmos DB account endpoint (required for RBAC). +- `EnableInteractiveCredentials` (default: `false`): Prompt for Azure login if default credentials are unavailable. +- `TenantId`: Microsoft Entra tenant ID for explicit service principal auth (RBAC mode). +- `ClientId`: Service principal app/client ID for explicit service principal auth (RBAC mode). +- `ClientSecret`: Service principal client secret for explicit service principal auth (RBAC mode). +- `ClientCertificatePath`: Path to a service principal certificate file for explicit service principal auth (RBAC mode). +- `ClientCertificatePassword`: Optional password for the service principal certificate. +- `Database`: Cosmos DB database name. +- `Container`: Cosmos DB container name. +- `WebProxy`: Proxy server URL for Cosmos DB connections. +- `InitClientEncryption` (default: `false`): Enable Always Encrypted feature. +- `LimitToEndpoint` (default: `false`): Restrict client to endpoint (see CosmosClientOptions.LimitToEndpoint). +- `DisableSslValidation` (default: `false`): Disable SSL certificate validation (for local dev only; not for production). +- `AllowBulkExecution` (default: `false`): Enable bulk execution for optimized performance. Warning: may affect consistency and error handling. + +Source and sink require settings used to locate and access the Cosmos DB account. This can be done in one of three ways: - Using a `ConnectionString` that includes an AccountEndpoint and AccountKey - Using RBAC (Role Based Access Control) by setting `UseRbacAuth` to true and specifying `AccountEndpoint` and optionally `EnableInteractiveCredentials` to prompt the user to log in to Azure if default credentials are not available. See ([migrate-passwordless](https://learn.microsoft.com/azure/cosmos-db/nosql/migrate-passwordless?tabs=sign-in-azure-cli%2Cdotnet%2Cazure-portal-create%2Cazure-portal-associate%2Capp-service-identity) for how to configure Cosmos DB for passwordless access. +- Using RBAC with explicit service principal credentials by setting `UseRbacAuth` to true and specifying `AccountEndpoint`, `TenantId`, `ClientId`, and either `ClientSecret` or `ClientCertificatePath`. +> **Security warning**: `ClientSecret` is plaintext in settings files. Do not commit secrets to source control. Prefer runtime injection through .NET configuration providers such as environment variables, command-line args (for example `--SourceSettings:ClientSecret=...` / `--SinkSettings:ClientSecret=...`), or User Secrets. +> **Implementation note**: The RBAC service principal path now uses explicit credential-selection logic with additional validation and error wrapping. This was added to make auth-path behavior deterministic and testable, avoid unmanaged certificate object lifetime handling in the tool process, and surface actionable messages when certificate/credential configuration is invalid. ### Bulk Execution @@ -72,6 +77,7 @@ Source and sink settings also both require parameters to specify the data locati - `Container` Source supports the following optional parameters: + - `IncludeMetadataFields` (`false` by default) - Enables inclusion of built-in Cosmos fields prefixed with `"_"`, for example `"_etag"` and `"_ts"`. - `PartitionKeyValue` - Allows for filtering to a single partition. - `Query` - Allows further filtering using a Cosmos SQL statement. @@ -84,7 +90,7 @@ Source supports the following optional parameters: Source and Sink support Always Encrypted as an optional parameter. When `InitClientEncryption` is set to `true`, the extension will initialize the Cosmos client with the Always Encrypted feature enabled. This allows for the use of encrypted fields in the Cosmos DB container. The extension will automatically decrypt the fields when reading from the source and encrypt the fields when writing to the sink.
-The extension will also automatically handle the encryption keys and encryption policy for the client, but it requires `UseRbacAuth` to be set to `true` and the user to have the necessary permissions to access the key vault. +The extension will also automatically handle the encryption keys and encryption policy for the client, but it requires `UseRbacAuth` to be set to `true` and the user/service principal to have the necessary permissions to access the key vault.
> **Note**: To use Always Encrypted, Cosmos DB container must be pre-configured with the necessary encryption policy and the user must have the necessary permissions to access the key vault. @@ -125,6 +131,24 @@ Or with RBAC: } ``` +Or with RBAC and explicit service principal credentials: + +```json +{ + "UseRbacAuth": true, + "AccountEndpoint": "https://source.documents.azure.com:443/", + "TenantId": "", + "ClientId": "", + "ClientSecret": "", + "Database":"myDb", + "Container":"myContainer", + "IncludeMetadataFields": false, + "PartitionKeyValue":"123", + "Query":"SELECT * FROM c WHERE c.category='event'", + "InitClientEncryption": false +} +``` + #### Disable SSL Validation Configuration Example For development purposes with SSL validation disabled: @@ -223,3 +247,34 @@ For development purposes with SSL validation disabled: "LimitToEndpoint": false } ``` + +Or with RBAC and explicit service principal credentials: + +```json +{ + "UseRbacAuth": true, + "AccountEndpoint": "https://target.documents.azure.com:443/", + "TenantId": "", + "ClientId": "", + "ClientCertificatePath": "./certs/sp-auth.pfx", + "ClientCertificatePassword": "", + "Database":"myDb", + "Container":"myContainer", + "PartitionKeyPath":"/id", + "RecreateContainer": false, + "BatchSize": 100, + "ConnectionMode": "Gateway", + "MaxRetryCount": 5, + "InitialRetryDurationMs": 200, + "CreatedContainerMaxThroughput": 1000, + "UseAutoscaleForDatabase": false, + "UseAutoscaleForCreatedContainer": true, + "WriteMode": "InsertStream", + "PreserveMixedCaseIds": false, + "IgnoreNullValues": false, + "IsServerlessAccount": false, + "UseSharedThroughput": false, + "InitClientEncryption": false, + "LimitToEndpoint": false +} +``` From 188abb7afd5dfc7f887f9ee8ca131d37ba46c04f Mon Sep 17 00:00:00 2001 From: Phil Nachreiner Date: Tue, 26 May 2026 14:35:39 -0700 Subject: [PATCH 15/18] Address latest Copilot feedback on PR 242 --- .../CosmosExtensionServicesCredentialTests.cs | 47 +++++++++++++++++++ .../CosmosSinkSettingsTests.cs | 39 +++++++++++++++ .../CosmosSourceSettingsTests.cs | 39 +++++++++++++++ .../CosmosExtensionServices.cs | 14 ++++-- .../CosmosSettingsBase.cs | 20 +++++--- Extensions/Cosmos/README.md | 2 +- 6 files changed, 149 insertions(+), 12 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs index 97ff770..be82f03 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs @@ -1,6 +1,8 @@ using Azure.Identity; using Microsoft.Extensions.Logging; using Moq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; namespace Cosmos.DataTransfer.CosmosExtension.UnitTests; @@ -99,4 +101,49 @@ public void CreateRbacTokenCredential_WithInvalidCertificatePath_ThrowsFriendlyC StringAssert.Contains(ex.Message, "Failed to configure RBAC credentials"); Assert.IsNotNull(ex.InnerException); } + + [TestMethod] + public void CreateRbacTokenCredential_WithPasswordProtectedCertificate_ReturnsClientCertificateCredential() + { + var loggerMock = new Mock(); + const string certPassword = "test-password"; + var certPath = CreatePasswordProtectedPfx(certPassword); + + try + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + AccountEndpoint = "https://localhost:8081/", + Database = "db", + Container = "container", + TenantId = "tenant-id", + ClientId = "client-id", + ClientCertificatePath = certPath, + ClientCertificatePassword = certPassword, + }; + + var credential = CosmosExtensionServices.CreateRbacTokenCredential(settings, loggerMock.Object); + + Assert.IsInstanceOfType(credential); + } + finally + { + if (File.Exists(certPath)) + { + File.Delete(certPath); + } + } + } + + private static string CreatePasswordProtectedPfx(string password) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=unit-test-cert", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var pfxBytes = certificate.Export(X509ContentType.Pfx, password); + var certPath = Path.Combine(Path.GetTempPath(), $"dmt-test-{Guid.NewGuid():N}.pfx"); + File.WriteAllBytes(certPath, pfxBytes); + return certPath; + } } diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs index ab93d66..2956909 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs @@ -41,6 +41,24 @@ public void GetValidationErrors_WithNoRbacConnection_ReturnsError() Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.AccountEndpoint)))); } + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndConnectionString_ReturnsError() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + ConnectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=key", + AccountEndpoint = "https://example.documents.azure.com:443/", + Database = "db", + Container = "container", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.UseRbacAuth)) && v.Contains(nameof(CosmosSinkSettings.ConnectionString)))); + } + [TestMethod] public void GetValidationErrors_WithRbacAuthAndIncompleteServicePrincipalInfo_ReturnsErrors() { @@ -138,6 +156,27 @@ public void GetValidationErrors_WithRbacAuthAndSecretOrCertificateButNoServicePr Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.TenantId)) && v.Contains(nameof(CosmosSinkSettings.ClientId)))); } + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndWhitespaceTenantOrClient_ReturnsErrors() + { + var settings = new CosmosSinkSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = " ", + ClientId = "client-id", + ClientSecret = "client-secret", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains("Both TenantId and ClientId must be specified when UseRbacAuth is used with service principal"))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains("ClientSecret, ClientCertificatePath, or ClientCertificatePassword cannot be set without TenantId/ClientId."))); + } + [TestMethod] public void GetValidationErrors_WithNoRbacAuthButHasServicePrincipal_ReturnsError() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs index cd75a4a..65f5473 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSourceSettingsTests.cs @@ -41,6 +41,24 @@ public void GetValidationErrors_WithNoRbacConnection_ReturnsError() Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.AccountEndpoint)))); } + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndConnectionString_ReturnsError() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + ConnectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=key", + AccountEndpoint = "https://example.documents.azure.com:443/", + Database = "db", + Container = "container", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.UseRbacAuth)) && v.Contains(nameof(CosmosSourceSettings.ConnectionString)))); + } + [TestMethod] public void GetValidationErrors_WithRbacAuthAndIncompleteServicePrincipalInfo_ReturnsErrors() { @@ -138,6 +156,27 @@ public void GetValidationErrors_WithRbacAuthAndSecretOrCertificateButNoServicePr Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.TenantId)) && v.Contains(nameof(CosmosSourceSettings.ClientId)))); } + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndWhitespaceTenantOrClient_ReturnsErrors() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + Database = "db", + Container = "container", + AccountEndpoint = "https://example.documents.azure.com:443/", + TenantId = " ", + ClientId = "client-id", + ClientSecret = "client-secret", + }; + + var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains("Both TenantId and ClientId must be specified when UseRbacAuth is used with service principal"))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains("ClientSecret, ClientCertificatePath, or ClientCertificatePassword cannot be set without TenantId/ClientId."))); + } + [TestMethod] public void GetValidationErrors_WithNoRbacAuthButHasServicePrincipal_ReturnsError() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index f166e4b..5fd7a13 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -10,6 +10,7 @@ using System.Net.Http; using System.Reflection; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; namespace Cosmos.DataTransfer.CosmosExtension @@ -82,16 +83,21 @@ internal static TokenCredential CreateRbacTokenCredential(CosmosSettingsBase set } case TokenCredentialSelection.ClientCertificateCredential: - // NOTE: We intentionally use Azure.Identity's certificate-path overload so - // credential/material lifetime is owned by the SDK and not manually tracked in this process. if (!File.Exists(settings.ClientCertificatePath)) { throw new FileNotFoundException( "Client certificate file was not found.", settings.ClientCertificatePath); } - - return new ClientCertificateCredential(settings.TenantId!, settings.ClientId!, settings.ClientCertificatePath!); + var certificatePassword = string.IsNullOrWhiteSpace(settings.ClientCertificatePassword) + ? null + : settings.ClientCertificatePassword; + var certificate = new X509Certificate2( + settings.ClientCertificatePath!, + certificatePassword, + X509KeyStorageFlags.EphemeralKeySet); + + return new ClientCertificateCredential(settings.TenantId!, settings.ClientId!, certificate); default: return new DefaultAzureCredential(includeInteractiveCredentials: settings.EnableInteractiveCredentials); diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs index 14b1d03..951f12a 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs @@ -54,25 +54,31 @@ public abstract class CosmosSettingsBase : IValidatableObject public virtual IEnumerable Validate(ValidationContext validationContext) { - if (!UseRbacAuth && string.IsNullOrEmpty(ConnectionString)) + if (!UseRbacAuth && string.IsNullOrWhiteSpace(ConnectionString)) { yield return new ValidationResult("ConnectionString must be specified unless UseRbacAuth is true", new[] { nameof(ConnectionString) }); } - if (UseRbacAuth && string.IsNullOrEmpty(AccountEndpoint)) + if (UseRbacAuth && string.IsNullOrWhiteSpace(AccountEndpoint)) { yield return new ValidationResult("AccountEndpoint must be specified when UseRbacAuth is true", new[] { nameof(AccountEndpoint) }); } + if (UseRbacAuth && !string.IsNullOrWhiteSpace(ConnectionString)) + { + yield return new ValidationResult( + "ConnectionString must not be set when UseRbacAuth is true.", + new[] { nameof(UseRbacAuth), nameof(ConnectionString) }); + } if (!UseRbacAuth && InitClientEncryption) { yield return new ValidationResult("InitClientEncryption can only be used when UseRbacAuth is true", new[] { nameof(InitClientEncryption) }); } - var tenantIdSet = !string.IsNullOrEmpty(TenantId); - var clientIdSet = !string.IsNullOrEmpty(ClientId); + var tenantIdSet = !string.IsNullOrWhiteSpace(TenantId); + var clientIdSet = !string.IsNullOrWhiteSpace(ClientId); var servicePrincipalSet = tenantIdSet && clientIdSet; - var clientSecretSet = !string.IsNullOrEmpty(ClientSecret); - var clientCertificateSet = !string.IsNullOrEmpty(ClientCertificatePath); - var clientCertificatePasswordSet = !string.IsNullOrEmpty(ClientCertificatePassword); + var clientSecretSet = !string.IsNullOrWhiteSpace(ClientSecret); + var clientCertificateSet = !string.IsNullOrWhiteSpace(ClientCertificatePath); + var clientCertificatePasswordSet = !string.IsNullOrWhiteSpace(ClientCertificatePassword); if (UseRbacAuth && tenantIdSet != clientIdSet) { diff --git a/Extensions/Cosmos/README.md b/Extensions/Cosmos/README.md index 31c293b..7e65fdf 100644 --- a/Extensions/Cosmos/README.md +++ b/Extensions/Cosmos/README.md @@ -50,7 +50,7 @@ These properties will be preserved exactly as they appear in the source when mig Source and sink require settings used to locate and access the Cosmos DB account. This can be done in one of three ways: - Using a `ConnectionString` that includes an AccountEndpoint and AccountKey -- Using RBAC (Role Based Access Control) by setting `UseRbacAuth` to true and specifying `AccountEndpoint` and optionally `EnableInteractiveCredentials` to prompt the user to log in to Azure if default credentials are not available. See ([migrate-passwordless](https://learn.microsoft.com/azure/cosmos-db/nosql/migrate-passwordless?tabs=sign-in-azure-cli%2Cdotnet%2Cazure-portal-create%2Cazure-portal-associate%2Capp-service-identity) for how to configure Cosmos DB for passwordless access. +- Using RBAC (Role Based Access Control) by setting `UseRbacAuth` to true and specifying `AccountEndpoint` and optionally `EnableInteractiveCredentials` to prompt the user to log in to Azure if default credentials are not available. See [migrate-passwordless](https://learn.microsoft.com/azure/cosmos-db/nosql/migrate-passwordless?tabs=sign-in-azure-cli%2Cdotnet%2Cazure-portal-create%2Cazure-portal-associate%2Capp-service-identity) for how to configure Cosmos DB for passwordless access. - Using RBAC with explicit service principal credentials by setting `UseRbacAuth` to true and specifying `AccountEndpoint`, `TenantId`, `ClientId`, and either `ClientSecret` or `ClientCertificatePath`. > **Security warning**: `ClientSecret` is plaintext in settings files. Do not commit secrets to source control. Prefer runtime injection through .NET configuration providers such as environment variables, command-line args (for example `--SourceSettings:ClientSecret=...` / `--SinkSettings:ClientSecret=...`), or User Secrets. From 5487dd3a04e403cc18f52052ec033bc765fe3f38 Mon Sep 17 00:00:00 2001 From: Phil Nachreiner Date: Fri, 29 May 2026 12:42:21 -0700 Subject: [PATCH 16/18] Address remaining PR 242 Copilot feedback --- ...aTransfer.CosmosExtension.UnitTests.csproj | 1 + .../CosmosExtensionServicesCredentialTests.cs | 19 +++++++++++++++++++ .../CosmosExtensionServices.cs | 8 ++++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/Cosmos.DataTransfer.CosmosExtension.UnitTests.csproj b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/Cosmos.DataTransfer.CosmosExtension.UnitTests.csproj index 6718de6..69b1486 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/Cosmos.DataTransfer.CosmosExtension.UnitTests.csproj +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/Cosmos.DataTransfer.CosmosExtension.UnitTests.csproj @@ -14,6 +14,7 @@ + diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs index be82f03..0c7184a 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs @@ -44,6 +44,25 @@ public void GetTokenCredentialSelection_WithTenantClientAndSecret_ReturnsClientS Assert.AreEqual(CosmosExtensionServices.TokenCredentialSelection.ClientSecretCredential, selection); } + [TestMethod] + public void GetTokenCredentialSelection_WithWhitespaceServicePrincipalInfo_ReturnsDefaultCredential() + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + AccountEndpoint = "https://localhost:8081/", + Database = "db", + Container = "container", + TenantId = " ", + ClientId = " ", + ClientSecret = "client-secret", + }; + + var selection = CosmosExtensionServices.GetTokenCredentialSelection(settings); + + Assert.AreEqual(CosmosExtensionServices.TokenCredentialSelection.DefaultAzureCredential, selection); + } + [TestMethod] public void GetTokenCredentialSelection_WithTenantClientAndCertificatePath_ReturnsClientCertificateCredential() { diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index 5fd7a13..98a2609 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -49,14 +49,14 @@ internal enum TokenCredentialSelection // NOTE: Kept as a separate helper so auth-path behavior can be tested directly. internal static TokenCredentialSelection GetTokenCredentialSelection(CosmosSettingsBase settings) { - if (!string.IsNullOrEmpty(settings.TenantId) && !string.IsNullOrEmpty(settings.ClientId)) + if (!string.IsNullOrWhiteSpace(settings.TenantId) && !string.IsNullOrWhiteSpace(settings.ClientId)) { - if (!string.IsNullOrEmpty(settings.ClientSecret)) + if (!string.IsNullOrWhiteSpace(settings.ClientSecret)) { return TokenCredentialSelection.ClientSecretCredential; } - if (!string.IsNullOrEmpty(settings.ClientCertificatePath)) + if (!string.IsNullOrWhiteSpace(settings.ClientCertificatePath)) { return TokenCredentialSelection.ClientCertificateCredential; } @@ -132,7 +132,7 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp { ConnectionMode = settings.ConnectionMode, ApplicationName = userAgentString, - AllowBulkExecution = true, + AllowBulkExecution = settings.AllowBulkExecution, EnableContentResponseOnWrite = false, Serializer = cosmosSerializer, LimitToEndpoint = settings.LimitToEndpoint, From 7707dc2091add1c0a012874ab3ac0d2e7ec3f593 Mon Sep 17 00:00:00 2001 From: Phil Nachreiner Date: Fri, 29 May 2026 12:56:11 -0700 Subject: [PATCH 17/18] Harden Cosmos certificate auth polish --- .../CosmosExtensionServicesCredentialTests.cs | 76 +++++++++++++++++++ .../CosmosExtensionServices.cs | 29 +++++-- Extensions/Cosmos/README.md | 4 +- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs index 0c7184a..95f1e80 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs @@ -145,6 +145,14 @@ public void CreateRbacTokenCredential_WithPasswordProtectedCertificate_ReturnsCl var credential = CosmosExtensionServices.CreateRbacTokenCredential(settings, loggerMock.Object); Assert.IsInstanceOfType(credential); + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(nameof(CosmosSourceSettings.ClientCertificatePassword))), + It.IsAny(), + It.IsAny>()), + Times.Once); } finally { @@ -155,6 +163,63 @@ public void CreateRbacTokenCredential_WithPasswordProtectedCertificate_ReturnsCl } } + [TestMethod] + public void CreateRbacTokenCredential_WithCertificateWithoutPrivateKey_ThrowsFriendlyConfigurationError() + { + var loggerMock = new Mock(); + var certPath = CreatePublicCertificate(); + + try + { + var settings = new CosmosSourceSettings + { + UseRbacAuth = true, + AccountEndpoint = "https://localhost:8081/", + Database = "db", + Container = "container", + TenantId = "tenant-id", + ClientId = "client-id", + ClientCertificatePath = certPath, + }; + + var ex = Assert.ThrowsException(() => + CosmosExtensionServices.CreateRbacTokenCredential(settings, loggerMock.Object)); + + StringAssert.Contains(ex.Message, "Failed to configure RBAC credentials"); + Assert.IsInstanceOfType(ex.InnerException); + StringAssert.Contains(ex.InnerException!.Message, "private key"); + } + finally + { + if (File.Exists(certPath)) + { + File.Delete(certPath); + } + } + } + + [TestMethod] + public void CreateClientOptions_UsesAllowBulkExecutionSetting() + { + var loggerMock = new Mock(); + var settings = new CosmosSourceSettings + { + ConnectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=key", + Database = "db", + Container = "container", + AllowBulkExecution = true, + }; + + var clientOptions = CosmosExtensionServices.CreateClientOptions(settings, "test-agent", loggerMock.Object); + + Assert.IsTrue(clientOptions.AllowBulkExecution); + + settings.AllowBulkExecution = false; + clientOptions = CosmosExtensionServices.CreateClientOptions(settings, "test-agent", loggerMock.Object); + + Assert.IsFalse(clientOptions.AllowBulkExecution); + } + private static string CreatePasswordProtectedPfx(string password) { using var rsa = RSA.Create(2048); @@ -165,4 +230,15 @@ private static string CreatePasswordProtectedPfx(string password) File.WriteAllBytes(certPath, pfxBytes); return certPath; } + + private static string CreatePublicCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=unit-test-cert", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var certBytes = certificate.Export(X509ContentType.Cert); + var certPath = Path.Combine(Path.GetTempPath(), $"dmt-test-{Guid.NewGuid():N}.cer"); + File.WriteAllBytes(certPath, certBytes); + return certPath; + } } diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index 98a2609..e8f0fb1 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -68,6 +68,8 @@ internal static TokenCredentialSelection GetTokenCredentialSelection(CosmosSetti // NOTE: Added explicit exception wrapping to surface actionable auth configuration failures. internal static TokenCredential CreateRbacTokenCredential(CosmosSettingsBase settings, ILogger logger) { + var section = settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"; + try { var selection = GetTokenCredentialSelection(settings); @@ -75,7 +77,6 @@ internal static TokenCredential CreateRbacTokenCredential(CosmosSettingsBase set { case TokenCredentialSelection.ClientSecretCredential: { - var section = settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"; logger.LogWarning( "ClientSecret is configured in settings. Ensure this configuration file is not committed to source control. Consider injecting via environment variables, command-line args (--{Section}:ClientSecret=...), or User Secrets instead.", section); @@ -92,11 +93,24 @@ internal static TokenCredential CreateRbacTokenCredential(CosmosSettingsBase set var certificatePassword = string.IsNullOrWhiteSpace(settings.ClientCertificatePassword) ? null : settings.ClientCertificatePassword; + + if (certificatePassword is not null) + { + logger.LogWarning( + "ClientCertificatePassword is configured in settings. Ensure this configuration file is not committed to source control. Consider injecting via environment variables, command-line args (--{Section}:ClientCertificatePassword=...), or User Secrets instead.", + section); + } + var certificate = new X509Certificate2( settings.ClientCertificatePath!, certificatePassword, X509KeyStorageFlags.EphemeralKeySet); + if (!certificate.HasPrivateKey) + { + throw new CryptographicException("Client certificate must contain a private key."); + } + return new ClientCertificateCredential(settings.TenantId!, settings.ClientId!, certificate); default: @@ -109,17 +123,14 @@ ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException) { - var section = settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"; throw new InvalidOperationException( $"Failed to configure RBAC credentials from {section}. Validate TenantId/ClientId and service principal secret/certificate settings.", ex); } } - public static CosmosClient CreateClient(CosmosSettingsBase settings, string displayName, ILogger logger, string? sourceDisplayName = null) + internal static CosmosClientOptions CreateClientOptions(CosmosSettingsBase settings, string userAgentString, ILogger logger) { - string userAgentString = CreateUserAgentString(displayName, sourceDisplayName); - var cosmosSerializer = new RawJsonCosmosSerializer(); if (settings is CosmosSinkSettings sinkSettings) { @@ -163,6 +174,14 @@ public static CosmosClient CreateClient(CosmosSettingsBase settings, string disp logger.LogWarning("SSL certificate validation is DISABLED. This should ONLY be used for development scenarios. Never use in production."); clientOptions.ServerCertificateCustomValidationCallback = (cert, chain, errors) => true; } + + return clientOptions; + } + + public static CosmosClient CreateClient(CosmosSettingsBase settings, string displayName, ILogger logger, string? sourceDisplayName = null) + { + string userAgentString = CreateUserAgentString(displayName, sourceDisplayName); + var clientOptions = CreateClientOptions(settings, userAgentString, logger); CosmosClient? cosmosClient; if (settings.UseRbacAuth) diff --git a/Extensions/Cosmos/README.md b/Extensions/Cosmos/README.md index 7e65fdf..5733b96 100644 --- a/Extensions/Cosmos/README.md +++ b/Extensions/Cosmos/README.md @@ -37,7 +37,7 @@ These properties will be preserved exactly as they appear in the source when mig - `TenantId`: Microsoft Entra tenant ID for explicit service principal auth (RBAC mode). - `ClientId`: Service principal app/client ID for explicit service principal auth (RBAC mode). - `ClientSecret`: Service principal client secret for explicit service principal auth (RBAC mode). -- `ClientCertificatePath`: Path to a service principal certificate file for explicit service principal auth (RBAC mode). +- `ClientCertificatePath`: Path to a PFX/PKCS#12 service principal certificate file that contains a private key for explicit service principal auth (RBAC mode). - `ClientCertificatePassword`: Optional password for the service principal certificate. - `Database`: Cosmos DB database name. - `Container`: Cosmos DB container name. @@ -53,7 +53,7 @@ Source and sink require settings used to locate and access the Cosmos DB account - Using RBAC (Role Based Access Control) by setting `UseRbacAuth` to true and specifying `AccountEndpoint` and optionally `EnableInteractiveCredentials` to prompt the user to log in to Azure if default credentials are not available. See [migrate-passwordless](https://learn.microsoft.com/azure/cosmos-db/nosql/migrate-passwordless?tabs=sign-in-azure-cli%2Cdotnet%2Cazure-portal-create%2Cazure-portal-associate%2Capp-service-identity) for how to configure Cosmos DB for passwordless access. - Using RBAC with explicit service principal credentials by setting `UseRbacAuth` to true and specifying `AccountEndpoint`, `TenantId`, `ClientId`, and either `ClientSecret` or `ClientCertificatePath`. -> **Security warning**: `ClientSecret` is plaintext in settings files. Do not commit secrets to source control. Prefer runtime injection through .NET configuration providers such as environment variables, command-line args (for example `--SourceSettings:ClientSecret=...` / `--SinkSettings:ClientSecret=...`), or User Secrets. +> **Security warning**: `ClientSecret` and `ClientCertificatePassword` are plaintext in settings files. Do not commit secrets to source control. Prefer runtime injection through .NET configuration providers such as environment variables, command-line args (for example `--SourceSettings:ClientSecret=...`, `--SinkSettings:ClientSecret=...`, `--SourceSettings:ClientCertificatePassword=...`, or `--SinkSettings:ClientCertificatePassword=...`), or User Secrets. > **Implementation note**: The RBAC service principal path now uses explicit credential-selection logic with additional validation and error wrapping. This was added to make auth-path behavior deterministic and testable, avoid unmanaged certificate object lifetime handling in the tool process, and surface actionable messages when certificate/credential configuration is invalid. ### Bulk Execution From 6d2af708fef981f3d644cda2dc4fac9e8fbab8ab Mon Sep 17 00:00:00 2001 From: Phil Nachreiner Date: Fri, 29 May 2026 15:14:26 -0700 Subject: [PATCH 18/18] Clarify certificate password RBAC setting --- Extensions/Cosmos/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extensions/Cosmos/README.md b/Extensions/Cosmos/README.md index 5733b96..ea3b7a6 100644 --- a/Extensions/Cosmos/README.md +++ b/Extensions/Cosmos/README.md @@ -38,7 +38,7 @@ These properties will be preserved exactly as they appear in the source when mig - `ClientId`: Service principal app/client ID for explicit service principal auth (RBAC mode). - `ClientSecret`: Service principal client secret for explicit service principal auth (RBAC mode). - `ClientCertificatePath`: Path to a PFX/PKCS#12 service principal certificate file that contains a private key for explicit service principal auth (RBAC mode). -- `ClientCertificatePassword`: Optional password for the service principal certificate. +- `ClientCertificatePassword`: Optional password for the service principal certificate (RBAC mode). - `Database`: Cosmos DB database name. - `Container`: Cosmos DB container name. - `WebProxy`: Proxy server URL for Cosmos DB connections.