diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index b368227a75..8ab024ae0b 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1094,5 +1094,186 @@ private void SetupFileSystemWithInitialConfig(string jsonConfig) Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? config)); Assert.IsNotNull(config.Runtime); } + + /// + /// Tests adding user-delegated-auth.enabled to a config that doesn't have user-delegated-auth configured. + /// This method verifies that the user-delegated-auth.enabled property can be set to true for MSSQL database. + /// Command: dab configure --data-source.user-delegated-auth.enabled true + /// + [TestMethod] + public void TestAddUserDelegatedAuthEnabled() + { + // Arrange + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + dataSourceUserDelegatedAuthEnabled: true, + 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); + } + + /// + /// Tests adding user-delegated-auth.database-audience to a config that doesn't have user-delegated-auth configured. + /// This method verifies that the database-audience can be set for user-delegated authentication. + /// Command: dab configure --data-source.user-delegated-auth.database-audience "https://database.windows.net" + /// + [TestMethod] + public void TestAddUserDelegatedAuthDatabaseAudience() + { + // Arrange + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + string audienceValue = "https://database.windows.net"; + + ConfigureOptions options = new( + 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); + Assert.AreEqual(audienceValue, config.DataSource.UserDelegatedAuth.DatabaseAudience); + } + + /// + /// Tests adding both user-delegated-auth.enabled and database-audience in a single command. + /// This method verifies that both properties can be set together. + /// Command: dab configure --data-source.user-delegated-auth.enabled true --data-source.user-delegated-auth.database-audience "https://database.windows.net" + /// + [DataTestMethod] + [DataRow("https://database.windows.net", DisplayName = "Azure SQL Database (public cloud)")] + [DataRow("https://database.usgovcloudapi.net", DisplayName = "Azure Government Cloud")] + [DataRow("https://database.chinacloudapi.cn", DisplayName = "Azure China Cloud")] + [DataRow("https://myinstance.abc123.database.windows.net", DisplayName = "Azure SQL Managed Instance")] + public void TestAddUserDelegatedAuthEnabledAndDatabaseAudience(string audienceValue) + { + // Arrange + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + ConfigureOptions options = new( + dataSourceUserDelegatedAuthEnabled: true, + 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); + Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled); + Assert.AreEqual(audienceValue, config.DataSource.UserDelegatedAuth.DatabaseAudience); + } + + /// + /// 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. + /// This method verifies that the database-audience can be updated while preserving the enabled setting. + /// + [TestMethod] + public void TestUpdateUserDelegatedAuthDatabaseAudience() + { + // Arrange - Config with existing user-delegated-auth section + string configWithUserDelegatedAuth = @" + { + ""$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"": {} + }"; + SetupFileSystemWithInitialConfig(configWithUserDelegatedAuth); + + 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); + } } } diff --git a/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs b/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs new file mode 100644 index 0000000000..7a463a845c --- /dev/null +++ b/src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs @@ -0,0 +1,103 @@ +// 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); + Assert.AreEqual(50, config.DataSource.UserDelegatedAuth.EffectiveTokenCacheDurationMinutes); + Assert.IsTrue(config.DataSource.UserDelegatedAuth.EffectiveDisableConnectionPooling); + } + + [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..069a2d3b6a 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. Default: false (boolean).")] + public bool? DataSourceUserDelegatedAuthEnabled { get; } + + [Option("data-source.user-delegated-auth.database-audience", Required = false, HelpText = "Azure SQL resource identifier for token acquisition (e.g., https://database.windows.net).")] + 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..7e5b6c400e 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; + UserDelegatedAuthConfig? userDelegatedAuthConfig = runtimeConfig.DataSource.UserDelegatedAuth; if (options.DataSourceDatabaseType is not null) { @@ -714,8 +715,36 @@ 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; + + // Create or update user-delegated-auth config + userDelegatedAuthConfig = new UserDelegatedAuthConfig( + Enabled: enabled, + DatabaseAudience: databaseAudience, + DisableConnectionPooling: userDelegatedAuthConfig?.DisableConnectionPooling, + TokenCacheDurationMinutes: userDelegatedAuthConfig?.TokenCacheDurationMinutes); + } + dbOptions = EnumerableUtilities.IsNullOrEmpty(dbOptions) ? null : dbOptions; - DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig); + DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig, userDelegatedAuthConfig); runtimeConfig = runtimeConfig with { DataSource = dataSource }; return runtimeConfig != null; diff --git a/src/Config/Converters/DataSourceConverterFactory.cs b/src/Config/Converters/DataSourceConverterFactory.cs index 1788ebf2b4..3f697061b2 100644 --- a/src/Config/Converters/DataSourceConverterFactory.cs +++ b/src/Config/Converters/DataSourceConverterFactory.cs @@ -51,12 +51,13 @@ public DataSourceConverter(DeserializationVariableReplacementSettings? replaceme string connectionString = string.Empty; DatasourceHealthCheckConfig? health = null; Dictionary? datasourceOptions = null; + UserDelegatedAuthConfig? userDelegatedAuth = null; while (reader.Read()) { if (reader.TokenType is JsonTokenType.EndObject) { - return new DataSource(databaseType, connectionString, datasourceOptions, health); + return new DataSource(databaseType, connectionString, datasourceOptions, health, userDelegatedAuth); } if (reader.TokenType is JsonTokenType.PropertyName) @@ -91,6 +92,20 @@ public DataSourceConverter(DeserializationVariableReplacementSettings? replaceme } } + break; + case "user-delegated-auth": + if (reader.TokenType is not JsonTokenType.Null) + { + try + { + userDelegatedAuth = JsonSerializer.Deserialize(ref reader, options); + } + catch (Exception e) + { + throw new JsonException($"Error while deserializing DataSource user-delegated-auth: {e.Message}"); + } + } + break; case "options": if (reader.TokenType is not JsonTokenType.Null) diff --git a/src/Config/ObjectModel/DataSource.cs b/src/Config/ObjectModel/DataSource.cs index d1a2456ef9..769e109a43 100644 --- a/src/Config/ObjectModel/DataSource.cs +++ b/src/Config/ObjectModel/DataSource.cs @@ -14,11 +14,13 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// Connection string to access the database. /// Custom options for the specific database. If there are no options, this could be null. /// Health check configuration for the datasource. +/// User-delegated authentication configuration (OBO). Optional. public record DataSource( DatabaseType DatabaseType, string ConnectionString, Dictionary? Options = null, - DatasourceHealthCheckConfig? Health = null) + DatasourceHealthCheckConfig? Health = null, + UserDelegatedAuthConfig? UserDelegatedAuth = null) { [JsonIgnore] public bool IsDatasourceHealthEnabled => diff --git a/src/Config/ObjectModel/UserDelegatedAuthConfig.cs b/src/Config/ObjectModel/UserDelegatedAuthConfig.cs new file mode 100644 index 0000000000..ee086a4053 --- /dev/null +++ b/src/Config/ObjectModel/UserDelegatedAuthConfig.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Configuration for user-delegated authentication (OBO - On-Behalf-Of). +/// Enables per-user Entra ID access token authentication to Azure SQL. +/// +/// Whether user-delegated authentication is enabled. +/// The Azure SQL resource identifier for token acquisition. +/// Explicitly control connection pooling behavior. Default: true (disabled) for safety. Connection pooling is disabled by default in OBO scenarios to prevent token reuse across different user contexts. +/// In-memory cache duration for OBO tokens per user. Default: 50 minutes. +public record UserDelegatedAuthConfig( + bool Enabled = false, + string? DatabaseAudience = null, + bool? DisableConnectionPooling = null, + int? TokenCacheDurationMinutes = null) +{ + /// + /// Default value for token cache duration in minutes. + /// Must be less than typical token lifetime (60 min). + /// + public const int DEFAULT_TOKEN_CACHE_DURATION_MINUTES = 50; + + /// + /// Default value for connection pooling (disabled for safety in MVP). + /// + public const bool DEFAULT_DISABLE_CONNECTION_POOLING = true; + + /// + /// Minimum allowed token cache duration in minutes. + /// + public const int MIN_TOKEN_CACHE_DURATION_MINUTES = 1; + + /// + /// Maximum allowed token cache duration in minutes. + /// Must be less than typical token lifetime (60 min). + /// + public const int MAX_TOKEN_CACHE_DURATION_MINUTES = 59; + + /// + /// Gets the effective token cache duration value. + /// Returns the configured value or the default if not specified. + /// + [JsonIgnore] + public int EffectiveTokenCacheDurationMinutes => + TokenCacheDurationMinutes ?? DEFAULT_TOKEN_CACHE_DURATION_MINUTES; + + /// + /// Gets the effective connection pooling setting. + /// Returns the configured value or the default if not specified. + /// + [JsonIgnore] + public bool EffectiveDisableConnectionPooling => + DisableConnectionPooling ?? DEFAULT_DISABLE_CONNECTION_POOLING; +}