diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index b368227a75..a1355679fb 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1094,5 +1094,130 @@ private void SetupFileSystemWithInitialConfig(string jsonConfig) Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? config)); Assert.IsNotNull(config.Runtime); } + + /// + /// Tests adding user-delegated-auth configuration options individually or together. + /// Verifies that enabled and database-audience properties can be set independently or combined. + /// Also verifies default values for properties not explicitly set. + /// Commands: + /// - dab configure --data-source.user-delegated-auth.enabled true + /// - dab configure --data-source.user-delegated-auth.database-audience "https://database.windows.net" + /// - dab configure --data-source.user-delegated-auth.enabled true --data-source.user-delegated-auth.database-audience "https://database.windows.net" + /// + [DataTestMethod] + [DataRow(true, null, DisplayName = "Set enabled=true only")] + [DataRow(null, "https://database.windows.net", DisplayName = "Set database-audience only")] + [DataRow(true, "https://database.windows.net", DisplayName = "Set both enabled and database-audience")] + public void TestAddUserDelegatedAuthConfiguration(bool? enabledValue, string? audienceValue) + { + // Arrange + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + dataSourceUserDelegatedAuthEnabled: enabledValue, + dataSourceUserDelegatedAuthDatabaseAudience: audienceValue, + config: TEST_RUNTIME_CONFIG_FILE + ); + + // Act + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.DataSource); + Assert.IsNotNull(config.DataSource.UserDelegatedAuth); + + // Verify enabled value (if set, use provided value; otherwise defaults to false) + if (enabledValue.HasValue) + { + Assert.AreEqual(enabledValue.Value, config.DataSource.UserDelegatedAuth.Enabled); + } + else + { + Assert.IsFalse(config.DataSource.UserDelegatedAuth.Enabled); + } + + // Verify database-audience value + if (audienceValue is not null) + { + Assert.AreEqual(audienceValue, config.DataSource.UserDelegatedAuth.DatabaseAudience); + } + else + { + Assert.IsNull(config.DataSource.UserDelegatedAuth.DatabaseAudience); + } + + // Verify provider is set to default + Assert.AreEqual("EntraId", config.DataSource.UserDelegatedAuth.Provider); + } + + /// + /// Tests that enabling user-delegated-auth on a non-MSSQL database fails. + /// This method verifies that user-delegated-auth is only allowed for MSSQL database type. + /// Command: dab configure --data-source.database-type postgresql --data-source.user-delegated-auth.enabled true + /// + [DataTestMethod] + [DataRow("postgresql", DisplayName = "Fail when enabling user-delegated-auth on PostgreSQL")] + [DataRow("mysql", DisplayName = "Fail when enabling user-delegated-auth on MySQL")] + [DataRow("cosmosdb_nosql", DisplayName = "Fail when enabling user-delegated-auth on CosmosDB")] + public void TestFailureWhenEnablingUserDelegatedAuthOnNonMSSQLDatabase(string dbType) + { + // Arrange + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + dataSourceDatabaseType: dbType, + dataSourceUserDelegatedAuthEnabled: true, + config: TEST_RUNTIME_CONFIG_FILE + ); + + // Act + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsFalse(isSuccess); + } + + /// + /// Tests updating existing user-delegated-auth configuration by changing the database-audience. + /// Verifies that the database-audience can be updated while preserving the enabled setting. + /// Also validates JSON structure: verifies user-delegated-auth is correctly nested under data-source + /// with proper JSON property names (enabled, provider, database-audience). + /// + [TestMethod] + public void TestUpdateUserDelegatedAuthDatabaseAudience() + { + // Arrange - Config with existing user-delegated-auth section + SetupFileSystemWithInitialConfig(TestHelper.CONFIG_WITH_USER_DELEGATED_AUTH); + + string newAudience = "https://database.usgovcloudapi.net"; + ConfigureOptions options = new( + dataSourceUserDelegatedAuthDatabaseAudience: newAudience, + config: TEST_RUNTIME_CONFIG_FILE + ); + + // Act + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config)); + Assert.IsNotNull(config.DataSource); + Assert.IsNotNull(config.DataSource.UserDelegatedAuth); + Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled); + Assert.AreEqual(newAudience, config.DataSource.UserDelegatedAuth.DatabaseAudience); + Assert.AreEqual("EntraId", config.DataSource.UserDelegatedAuth.Provider); + + // Verify JSON structure using JObject to ensure correct nesting + JObject configJson = JObject.Parse(updatedConfig); + JToken? userDelegatedAuthSection = configJson["data-source"]?["user-delegated-auth"]; + Assert.IsNotNull(userDelegatedAuthSection); + Assert.AreEqual(newAudience, (string?)userDelegatedAuthSection["database-audience"]); + Assert.AreEqual(true, (bool?)userDelegatedAuthSection["enabled"]); + Assert.AreEqual("EntraId", (string?)userDelegatedAuthSection["provider"]); + } } } diff --git a/src/Cli.Tests/TestHelper.cs b/src/Cli.Tests/TestHelper.cs index 4c461fc5ba..a75e359ee4 100644 --- a/src/Cli.Tests/TestHelper.cs +++ b/src/Cli.Tests/TestHelper.cs @@ -279,6 +279,46 @@ public static Process ExecuteDabCommand(string command, string flags) public const string CONFIG_WITH_DISABLED_GLOBAL_REST_GRAPHQL = $"{{{SAMPLE_SCHEMA_DATA_SOURCE},{RUNTIME_SECTION_WITH_DISABLED_REST_GRAPHQL}}}"; + /// + /// A config json with user-delegated-auth enabled. This is used in tests to verify updating existing + /// user-delegated-auth configuration. + /// + public const string CONFIG_WITH_USER_DELEGATED_AUTH = @" + { + ""$schema"": """ + DAB_DRAFT_SCHEMA_TEST_PATH + @""", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": """ + SAMPLE_TEST_CONN_STRING + @""", + ""user-delegated-auth"": { + ""enabled"": true, + ""provider"": ""EntraId"", + ""database-audience"": ""https://database.windows.net"" + } + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""mode"": ""development"", + ""cors"": { + ""origins"": [], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""StaticWebApps"" + } + } + }, + ""entities"": {} + }"; + public const string SINGLE_ENTITY = @" { ""entities"": { diff --git a/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs b/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs new file mode 100644 index 0000000000..29110a5a7c --- /dev/null +++ b/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Cli.Tests +{ + [TestClass] + public class UserDelegatedAuthRuntimeParsingTests + { + [TestMethod] + public void TestRuntimeCanParseUserDelegatedAuthConfig() + { + // Arrange + string configJson = @"{ + ""$schema"": ""test"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""testconnectionstring"", + ""user-delegated-auth"": { + ""enabled"": true, + ""database-audience"": ""https://database.windows.net"" + } + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""mode"": ""development"", + ""cors"": { + ""origins"": [], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""StaticWebApps"" + } + } + }, + ""entities"": {} + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config); + + // Assert + Assert.IsTrue(success); + Assert.IsNotNull(config); + Assert.IsNotNull(config.DataSource.UserDelegatedAuth); + Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled); + Assert.AreEqual("https://database.windows.net", config.DataSource.UserDelegatedAuth.DatabaseAudience); + } + + [TestMethod] + public void TestRuntimeCanParseConfigWithoutUserDelegatedAuth() + { + // Arrange + string configJson = @"{ + ""$schema"": ""test"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""testconnectionstring"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""mode"": ""development"", + ""cors"": { + ""origins"": [], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""StaticWebApps"" + } + } + }, + ""entities"": {} + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config); + + // Assert + Assert.IsTrue(success); + Assert.IsNotNull(config); + Assert.IsNull(config.DataSource.UserDelegatedAuth); + } + } +} diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 14234d24d7..262cbc9145 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -29,6 +29,8 @@ public ConfigureOptions( string? dataSourceOptionsSchema = null, bool? dataSourceOptionsSetSessionContext = null, string? dataSourceHealthName = null, + bool? dataSourceUserDelegatedAuthEnabled = null, + string? dataSourceUserDelegatedAuthDatabaseAudience = null, int? depthLimit = null, bool? runtimeGraphQLEnabled = null, string? runtimeGraphQLPath = null, @@ -84,6 +86,8 @@ public ConfigureOptions( DataSourceOptionsSchema = dataSourceOptionsSchema; DataSourceOptionsSetSessionContext = dataSourceOptionsSetSessionContext; DataSourceHealthName = dataSourceHealthName; + DataSourceUserDelegatedAuthEnabled = dataSourceUserDelegatedAuthEnabled; + DataSourceUserDelegatedAuthDatabaseAudience = dataSourceUserDelegatedAuthDatabaseAudience; // GraphQL DepthLimit = depthLimit; RuntimeGraphQLEnabled = runtimeGraphQLEnabled; @@ -160,6 +164,12 @@ public ConfigureOptions( [Option("data-source.health.name", Required = false, HelpText = "Identifier for data source in health check report.")] public string? DataSourceHealthName { get; } + [Option("data-source.user-delegated-auth.enabled", Required = false, HelpText = "Enable user-delegated authentication (OBO) for Azure SQL and SQL Server. Default: false (boolean).")] + public bool? DataSourceUserDelegatedAuthEnabled { get; } + + [Option("data-source.user-delegated-auth.database-audience", Required = false, HelpText = "Database resource identifier for token acquisition (e.g., https://database.windows.net for Azure SQL).")] + public string? DataSourceUserDelegatedAuthDatabaseAudience { get; } + [Option("runtime.graphql.depth-limit", Required = false, HelpText = "Max allowed depth of the nested query. Allowed values: (0,2147483647] inclusive. Default is infinity. Use -1 to remove limit.")] public int? DepthLimit { get; } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 9a3401a55a..6c51f002b7 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -643,6 +643,7 @@ private static bool TryUpdateConfiguredDataSourceOptions( DatabaseType dbType = runtimeConfig.DataSource.DatabaseType; string dataSourceConnectionString = runtimeConfig.DataSource.ConnectionString; DatasourceHealthCheckConfig? datasourceHealthCheckConfig = runtimeConfig.DataSource.Health; + UserDelegatedAuthOptions? userDelegatedAuthConfig = runtimeConfig.DataSource.UserDelegatedAuth; if (options.DataSourceDatabaseType is not null) { @@ -714,8 +715,41 @@ private static bool TryUpdateConfiguredDataSourceOptions( } } + // Handle user-delegated-auth options + if (options.DataSourceUserDelegatedAuthEnabled is not null + || options.DataSourceUserDelegatedAuthDatabaseAudience is not null) + { + // Determine the enabled state: use new value if provided, otherwise preserve existing + bool enabled = options.DataSourceUserDelegatedAuthEnabled + ?? userDelegatedAuthConfig?.Enabled + ?? false; + + // Validate that user-delegated-auth is only used with MSSQL when enabled=true + if (enabled && !DatabaseType.MSSQL.Equals(dbType)) + { + _logger.LogError("user-delegated-auth is only supported for database-type 'mssql'."); + return false; + } + + // Get database-audience: use new value if provided, otherwise preserve existing + string? databaseAudience = options.DataSourceUserDelegatedAuthDatabaseAudience + ?? userDelegatedAuthConfig?.DatabaseAudience; + + // Get provider: preserve existing or use default "EntraId" + string? provider = userDelegatedAuthConfig?.Provider ?? "EntraId"; + + // Create or update user-delegated-auth config + userDelegatedAuthConfig = new UserDelegatedAuthOptions( + Enabled: enabled, + Provider: provider, + DatabaseAudience: databaseAudience); + } + dbOptions = EnumerableUtilities.IsNullOrEmpty(dbOptions) ? null : dbOptions; - DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig); + DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig) + { + UserDelegatedAuth = userDelegatedAuthConfig + }; runtimeConfig = runtimeConfig with { DataSource = dataSource }; return runtimeConfig != null;