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 6718de60..69b1486e 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 new file mode 100644 index 00000000..95f1e808 --- /dev/null +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosExtensionServicesCredentialTests.cs @@ -0,0 +1,244 @@ +using Azure.Identity; +using Microsoft.Extensions.Logging; +using Moq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +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_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() + { + 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); + } + + [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); + 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 + { + if (File.Exists(certPath)) + { + File.Delete(certPath); + } + } + } + + [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); + 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; + } + + 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.UnitTests/CosmosSinkSettingsTests.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs index f724911d..2956909c 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension.UnitTests/CosmosSinkSettingsTests.cs @@ -40,6 +40,200 @@ 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() + { + var settings = new CosmosSinkSettings + { + 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)))); + } + + [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_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_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_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() + { + 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() + { + 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(); + 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); + + 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 1cb18f4a..65f54739 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,8 +20,9 @@ public void GetValidationErrors_WithNoConnection_ReturnsError() }; var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.ConnectionString)))); + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.ConnectionString)))); } [TestMethod] @@ -30,8 +36,203 @@ public void GetValidationErrors_WithNoRbacConnection_ReturnsError() }; var validationErrors = settings.GetValidationErrors(); + LogErrors(validationErrors); + + 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() + { + var settings = new CosmosSourceSettings + { + 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(CosmosSourceSettings.TenantId)) && v.Contains(nameof(CosmosSourceSettings.ClientId)))); + } + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalButNoSecretOrCertificateInfo_ReturnsErrors() + { + var settings = new CosmosSourceSettings + { + 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(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(); + LogErrors(validationErrors); + + 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(); + LogErrors(validationErrors); + + 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(); + LogErrors(validationErrors); + + 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() + { + 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(); + LogErrors(validationErrors); + + Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSourceSettings.UseRbacAuth)))); + } + + [TestMethod] + public void GetValidationErrors_WithRbacAuthAndServicePrincipalClientSecretInfo_ReturnsNoErrors() + { + var settings = new CosmosSourceSettings + { + 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 CosmosSourceSettings + { + 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); - Assert.AreEqual(1, validationErrors.Count(v => v.Contains(nameof(CosmosSinkSettings.AccountEndpoint)))); + Assert.IsFalse(validationErrors.Any()); } [TestMethod] diff --git a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs index c158a084..e8f0fb1f 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosExtensionServices.cs @@ -5,15 +5,25 @@ 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; +using System.Security.Cryptography.X509Certificates; 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(() => @@ -36,10 +46,91 @@ public static class CosmosExtensionServices return new HttpClient(handler); }); - public static CosmosClient CreateClient(CosmosSettingsBase settings, string displayName, ILogger logger, string? sourceDisplayName = null) + // NOTE: Kept as a separate helper so auth-path behavior can be tested directly. + internal static TokenCredentialSelection GetTokenCredentialSelection(CosmosSettingsBase settings) { - string userAgentString = CreateUserAgentString(displayName, sourceDisplayName); + if (!string.IsNullOrWhiteSpace(settings.TenantId) && !string.IsNullOrWhiteSpace(settings.ClientId)) + { + if (!string.IsNullOrWhiteSpace(settings.ClientSecret)) + { + return TokenCredentialSelection.ClientSecretCredential; + } + + if (!string.IsNullOrWhiteSpace(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) + { + var section = settings is CosmosSinkSettings ? "SinkSettings" : "SourceSettings"; + + try + { + var selection = GetTokenCredentialSelection(settings); + switch (selection) + { + case TokenCredentialSelection.ClientSecretCredential: + { + 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: + if (!File.Exists(settings.ClientCertificatePath)) + { + throw new FileNotFoundException( + "Client certificate file was not found.", + settings.ClientCertificatePath); + } + 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: + return new DefaultAzureCredential(includeInteractiveCredentials: settings.EnableInteractiveCredentials); + } + } + catch (Exception ex) when ( + ex is CryptographicException || + ex is IOException || + ex is UnauthorizedAccessException || + ex is ArgumentException) + { + throw new InvalidOperationException( + $"Failed to configure RBAC credentials from {section}. Validate TenantId/ClientId and service principal secret/certificate settings.", + ex); + } + } + + internal static CosmosClientOptions CreateClientOptions(CosmosSettingsBase settings, string userAgentString, ILogger logger) + { var cosmosSerializer = new RawJsonCosmosSerializer(); if (settings is CosmosSinkSettings sinkSettings) { @@ -52,13 +143,14 @@ 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, }; - if (!string.IsNullOrEmpty(settings.WebProxy)){ + if (!string.IsNullOrEmpty(settings.WebProxy)) + { var webProxy = new WebProxy(settings.WebProxy); if (settings.UseDefaultProxyCredentials) { @@ -82,22 +174,24 @@ 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) { - TokenCredential tokenCredential = new DefaultAzureCredential(includeInteractiveCredentials: settings.EnableInteractiveCredentials); + var tokenCredential = CreateRbacTokenCredential(settings, logger); - 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); - } + 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 d147ec0d..951f12a8 100644 --- a/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs +++ b/Extensions/Cosmos/Cosmos.DataTransfer.CosmosExtension/CosmosSettingsBase.cs @@ -19,6 +19,11 @@ 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; } /// /// @@ -49,18 +54,88 @@ 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.IsNullOrWhiteSpace(TenantId); + var clientIdSet = !string.IsNullOrWhiteSpace(ClientId); + var servicePrincipalSet = tenantIdSet && clientIdSet; + var clientSecretSet = !string.IsNullOrWhiteSpace(ClientSecret); + var clientCertificateSet = !string.IsNullOrWhiteSpace(ClientCertificatePath); + var clientCertificatePasswordSet = !string.IsNullOrWhiteSpace(ClientCertificatePassword); + + if (UseRbacAuth && tenantIdSet != clientIdSet) + { + yield return new ValidationResult( + "Both TenantId and ClientId must be specified when UseRbacAuth is used with service principal", + new[] { nameof(TenantId), nameof(ClientId) }); + } + + if (UseRbacAuth && servicePrincipalSet && !clientSecretSet && !clientCertificateSet) + { + 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.", + new[] { nameof(ClientSecret), nameof(ClientCertificatePath) }); + } + + if (UseRbacAuth && servicePrincipalSet && !clientCertificateSet && clientCertificatePasswordSet) + { + yield return new ValidationResult( + "ClientCertificatePassword can only be set when ClientCertificatePath is set.", + new[] { nameof(ClientCertificatePassword), nameof(ClientCertificatePath) }); + } + + if (UseRbacAuth && !servicePrincipalSet && (clientSecretSet || clientCertificateSet || clientCertificatePasswordSet)) + { + 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 || clientCertificatePasswordSet)) + { + 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) + }); + } } } } \ No newline at end of file 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 00000000..2224b52e --- /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 58b68bfc..ea3b7a6f 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 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 (RBAC mode). +- `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 (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` 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 @@ -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 +} +```