Skip to content

Commit 99e30ba

Browse files
CopilotanushakolanAniruddh25
authored
[OBO] Add CLI support for user-delegated authentication configuration (#3128)
## Why make this change? Implements CLI configuration for OBO (On-Behalf-Of) delegated identity as specified in #2898. The OBO core implementation was merged into main via PR #3151. This PR adds CLI commands to enable operators to configure per-user Entra ID authentication to Azure SQL and SQL Server via CLI instead of manual config file editing. ## What is this change? **CLI Commands Added** - `dab configure --data-source.user-delegated-auth.enabled true` - Enable/disable OBO authentication for Azure SQL and SQL Server - `dab configure --data-source.user-delegated-auth.database-audience "https://database.windows.net"` - Configure database resource identifier for token acquisition **Implementation Details** - Updated `ConfigureOptions.cs` with two new CLI option parameters (`dataSourceUserDelegatedAuthEnabled`, `dataSourceUserDelegatedAuthDatabaseAudience`) - Updated `ConfigGenerator.TryUpdateConfiguredDataSourceOptions()` to create/update `UserDelegatedAuthOptions` configuration - Added validation to ensure user-delegated-auth is only used with MSSQL database type - Provider field automatically defaults to "EntraId" when user-delegated-auth is configured - Preserves existing user-delegated-auth configuration when updating individual fields - Help text clarifies support for both Azure SQL and on-premises SQL Server **Configuration Output** The CLI generates configuration that integrates with the `UserDelegatedAuthOptions` from the merged OBO implementation: ```json { "data-source": { "database-type": "mssql", "connection-string": "...", "user-delegated-auth": { "enabled": true, "provider": "EntraId", "database-audience": "https://database.windows.net" } } } ``` **Files Changed (5 CLI-specific files)** - `src/Cli/Commands/ConfigureOptions.cs` - CLI option definitions with SQL Server on-premises support - `src/Cli/ConfigGenerator.cs` - Configuration update logic - `src/Cli.Tests/ConfigureOptionsTests.cs` - Consolidated CLI configuration tests - `src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs` - 2 runtime parsing tests - `src/Cli.Tests/TestHelper.cs` - Added CONFIG_WITH_USER_DELEGATED_AUTH test constant ## How was this tested? - [x] Unit Tests - 9 tests total: - 3 parameterized CLI configuration tests (enabled only, audience only, both together) - 1 update test with JSON structure validation (verifies proper nesting under data-source with correct property names) - 3 validation error tests (PostgreSQL, MySQL, CosmosDB rejection) - 2 runtime parsing tests (verify CLI-generated config loads correctly) - Tests verify default values for properties not explicitly set (Enabled defaults to false, DatabaseAudience defaults to null) - [x] Integration Tests - Verified MSSQL-only validation with PostgreSQL/MySQL/CosmosDB rejection scenarios ## Sample Request(s) **Initialize and configure OBO in one workflow:** ```bash # Initialize MSSQL config dab init --database-type mssql --connection-string "Server=myserver.database.windows.net;Database=mydb" # Enable user-delegated auth with both options dab configure --data-source.user-delegated-auth.enabled true \ --data-source.user-delegated-auth.database-audience "https://database.windows.net" ``` **Update audience for existing configuration:** ```bash dab configure --data-source.user-delegated-auth.database-audience "https://database.usgovcloudapi.net" ``` **Update only the enabled flag:** ```bash dab configure --data-source.user-delegated-auth.enabled false ``` **Validation - fails for non-MSSQL:** ```bash dab init --database-type postgresql --connection-string "..." dab configure --data-source.user-delegated-auth.enabled true # Error: user-delegated-auth is only supported for database-type 'mssql'. ``` <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>[OBO] CLI Support for Enabling OBO Delegated Identity</issue_title> > <issue_description>Implement CLI support for enabling OBO exactly as defined in the design. > > - These CLI Commands should be supported > `dab configure --data-source.user-delegated-auth.enabled true` > `dab configure --data-source.user-delegated-auth.database-audience "<audience>"` > > - Ensure CLI writes correct config structure > - Ensure runtime consumes CLI‑produced config > > Testing: > 1. CLI sets user-delegated-auth.enabled = true > 2. CLI sets correct database-audience > 3. runtime reads CLI output correctly > 4. invalid CLI input fails deterministically</issue_description> > > <agent_instructions>https://microsoft-my.sharepoint.com/:w:/p/anushakolan/IQDsb4patJ52Q65Cp0QHQ-b_Aazplc1go_6k2HccCOYLQNU?e=Bpuv1Z&wdOrigin=TEAMS-MAGLEV.null_ns.rwc&wdExp=TEAMS-TREATMENT&wdhostclicktime=1770765689642&web=1 > > Follow this design document for more context and questions, understand the requirement of the task, this is the main task, #2898, create the PR accordingly.</agent_instructions> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #3127 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: anushakolan <[email protected]> Co-authored-by: Anusha Kolan <[email protected]> Co-authored-by: Aniruddh Munde <[email protected]>
1 parent a4fe457 commit 99e30ba

5 files changed

Lines changed: 311 additions & 1 deletion

File tree

src/Cli.Tests/ConfigureOptionsTests.cs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,5 +1094,130 @@ private void SetupFileSystemWithInitialConfig(string jsonConfig)
10941094
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? config));
10951095
Assert.IsNotNull(config.Runtime);
10961096
}
1097+
1098+
/// <summary>
1099+
/// Tests adding user-delegated-auth configuration options individually or together.
1100+
/// Verifies that enabled and database-audience properties can be set independently or combined.
1101+
/// Also verifies default values for properties not explicitly set.
1102+
/// Commands:
1103+
/// - dab configure --data-source.user-delegated-auth.enabled true
1104+
/// - dab configure --data-source.user-delegated-auth.database-audience "https://database.windows.net"
1105+
/// - dab configure --data-source.user-delegated-auth.enabled true --data-source.user-delegated-auth.database-audience "https://database.windows.net"
1106+
/// </summary>
1107+
[DataTestMethod]
1108+
[DataRow(true, null, DisplayName = "Set enabled=true only")]
1109+
[DataRow(null, "https://database.windows.net", DisplayName = "Set database-audience only")]
1110+
[DataRow(true, "https://database.windows.net", DisplayName = "Set both enabled and database-audience")]
1111+
public void TestAddUserDelegatedAuthConfiguration(bool? enabledValue, string? audienceValue)
1112+
{
1113+
// Arrange
1114+
SetupFileSystemWithInitialConfig(INITIAL_CONFIG);
1115+
1116+
ConfigureOptions options = new(
1117+
dataSourceUserDelegatedAuthEnabled: enabledValue,
1118+
dataSourceUserDelegatedAuthDatabaseAudience: audienceValue,
1119+
config: TEST_RUNTIME_CONFIG_FILE
1120+
);
1121+
1122+
// Act
1123+
bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
1124+
1125+
// Assert
1126+
Assert.IsTrue(isSuccess);
1127+
string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
1128+
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
1129+
Assert.IsNotNull(config.DataSource);
1130+
Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
1131+
1132+
// Verify enabled value (if set, use provided value; otherwise defaults to false)
1133+
if (enabledValue.HasValue)
1134+
{
1135+
Assert.AreEqual(enabledValue.Value, config.DataSource.UserDelegatedAuth.Enabled);
1136+
}
1137+
else
1138+
{
1139+
Assert.IsFalse(config.DataSource.UserDelegatedAuth.Enabled);
1140+
}
1141+
1142+
// Verify database-audience value
1143+
if (audienceValue is not null)
1144+
{
1145+
Assert.AreEqual(audienceValue, config.DataSource.UserDelegatedAuth.DatabaseAudience);
1146+
}
1147+
else
1148+
{
1149+
Assert.IsNull(config.DataSource.UserDelegatedAuth.DatabaseAudience);
1150+
}
1151+
1152+
// Verify provider is set to default
1153+
Assert.AreEqual("EntraId", config.DataSource.UserDelegatedAuth.Provider);
1154+
}
1155+
1156+
/// <summary>
1157+
/// Tests that enabling user-delegated-auth on a non-MSSQL database fails.
1158+
/// This method verifies that user-delegated-auth is only allowed for MSSQL database type.
1159+
/// Command: dab configure --data-source.database-type postgresql --data-source.user-delegated-auth.enabled true
1160+
/// </summary>
1161+
[DataTestMethod]
1162+
[DataRow("postgresql", DisplayName = "Fail when enabling user-delegated-auth on PostgreSQL")]
1163+
[DataRow("mysql", DisplayName = "Fail when enabling user-delegated-auth on MySQL")]
1164+
[DataRow("cosmosdb_nosql", DisplayName = "Fail when enabling user-delegated-auth on CosmosDB")]
1165+
public void TestFailureWhenEnablingUserDelegatedAuthOnNonMSSQLDatabase(string dbType)
1166+
{
1167+
// Arrange
1168+
SetupFileSystemWithInitialConfig(INITIAL_CONFIG);
1169+
1170+
ConfigureOptions options = new(
1171+
dataSourceDatabaseType: dbType,
1172+
dataSourceUserDelegatedAuthEnabled: true,
1173+
config: TEST_RUNTIME_CONFIG_FILE
1174+
);
1175+
1176+
// Act
1177+
bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
1178+
1179+
// Assert
1180+
Assert.IsFalse(isSuccess);
1181+
}
1182+
1183+
/// <summary>
1184+
/// Tests updating existing user-delegated-auth configuration by changing the database-audience.
1185+
/// Verifies that the database-audience can be updated while preserving the enabled setting.
1186+
/// Also validates JSON structure: verifies user-delegated-auth is correctly nested under data-source
1187+
/// with proper JSON property names (enabled, provider, database-audience).
1188+
/// </summary>
1189+
[TestMethod]
1190+
public void TestUpdateUserDelegatedAuthDatabaseAudience()
1191+
{
1192+
// Arrange - Config with existing user-delegated-auth section
1193+
SetupFileSystemWithInitialConfig(TestHelper.CONFIG_WITH_USER_DELEGATED_AUTH);
1194+
1195+
string newAudience = "https://database.usgovcloudapi.net";
1196+
ConfigureOptions options = new(
1197+
dataSourceUserDelegatedAuthDatabaseAudience: newAudience,
1198+
config: TEST_RUNTIME_CONFIG_FILE
1199+
);
1200+
1201+
// Act
1202+
bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!);
1203+
1204+
// Assert
1205+
Assert.IsTrue(isSuccess);
1206+
string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
1207+
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
1208+
Assert.IsNotNull(config.DataSource);
1209+
Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
1210+
Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled);
1211+
Assert.AreEqual(newAudience, config.DataSource.UserDelegatedAuth.DatabaseAudience);
1212+
Assert.AreEqual("EntraId", config.DataSource.UserDelegatedAuth.Provider);
1213+
1214+
// Verify JSON structure using JObject to ensure correct nesting
1215+
JObject configJson = JObject.Parse(updatedConfig);
1216+
JToken? userDelegatedAuthSection = configJson["data-source"]?["user-delegated-auth"];
1217+
Assert.IsNotNull(userDelegatedAuthSection);
1218+
Assert.AreEqual(newAudience, (string?)userDelegatedAuthSection["database-audience"]);
1219+
Assert.AreEqual(true, (bool?)userDelegatedAuthSection["enabled"]);
1220+
Assert.AreEqual("EntraId", (string?)userDelegatedAuthSection["provider"]);
1221+
}
10971222
}
10981223
}

src/Cli.Tests/TestHelper.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,46 @@ public static Process ExecuteDabCommand(string command, string flags)
279279

280280
public const string CONFIG_WITH_DISABLED_GLOBAL_REST_GRAPHQL = $"{{{SAMPLE_SCHEMA_DATA_SOURCE},{RUNTIME_SECTION_WITH_DISABLED_REST_GRAPHQL}}}";
281281

282+
/// <summary>
283+
/// A config json with user-delegated-auth enabled. This is used in tests to verify updating existing
284+
/// user-delegated-auth configuration.
285+
/// </summary>
286+
public const string CONFIG_WITH_USER_DELEGATED_AUTH = @"
287+
{
288+
""$schema"": """ + DAB_DRAFT_SCHEMA_TEST_PATH + @""",
289+
""data-source"": {
290+
""database-type"": ""mssql"",
291+
""connection-string"": """ + SAMPLE_TEST_CONN_STRING + @""",
292+
""user-delegated-auth"": {
293+
""enabled"": true,
294+
""provider"": ""EntraId"",
295+
""database-audience"": ""https://database.windows.net""
296+
}
297+
},
298+
""runtime"": {
299+
""rest"": {
300+
""enabled"": true,
301+
""path"": ""/api""
302+
},
303+
""graphql"": {
304+
""enabled"": true,
305+
""path"": ""/graphql"",
306+
""allow-introspection"": true
307+
},
308+
""host"": {
309+
""mode"": ""development"",
310+
""cors"": {
311+
""origins"": [],
312+
""allow-credentials"": false
313+
},
314+
""authentication"": {
315+
""provider"": ""StaticWebApps""
316+
}
317+
}
318+
},
319+
""entities"": {}
320+
}";
321+
282322
public const string SINGLE_ENTITY = @"
283323
{
284324
""entities"": {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Cli.Tests
5+
{
6+
[TestClass]
7+
public class UserDelegatedAuthRuntimeParsingTests
8+
{
9+
[TestMethod]
10+
public void TestRuntimeCanParseUserDelegatedAuthConfig()
11+
{
12+
// Arrange
13+
string configJson = @"{
14+
""$schema"": ""test"",
15+
""data-source"": {
16+
""database-type"": ""mssql"",
17+
""connection-string"": ""testconnectionstring"",
18+
""user-delegated-auth"": {
19+
""enabled"": true,
20+
""database-audience"": ""https://database.windows.net""
21+
}
22+
},
23+
""runtime"": {
24+
""rest"": {
25+
""enabled"": true,
26+
""path"": ""/api""
27+
},
28+
""graphql"": {
29+
""enabled"": true,
30+
""path"": ""/graphql"",
31+
""allow-introspection"": true
32+
},
33+
""host"": {
34+
""mode"": ""development"",
35+
""cors"": {
36+
""origins"": [],
37+
""allow-credentials"": false
38+
},
39+
""authentication"": {
40+
""provider"": ""StaticWebApps""
41+
}
42+
}
43+
},
44+
""entities"": {}
45+
}";
46+
47+
// Act
48+
bool success = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config);
49+
50+
// Assert
51+
Assert.IsTrue(success);
52+
Assert.IsNotNull(config);
53+
Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
54+
Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled);
55+
Assert.AreEqual("https://database.windows.net", config.DataSource.UserDelegatedAuth.DatabaseAudience);
56+
}
57+
58+
[TestMethod]
59+
public void TestRuntimeCanParseConfigWithoutUserDelegatedAuth()
60+
{
61+
// Arrange
62+
string configJson = @"{
63+
""$schema"": ""test"",
64+
""data-source"": {
65+
""database-type"": ""mssql"",
66+
""connection-string"": ""testconnectionstring""
67+
},
68+
""runtime"": {
69+
""rest"": {
70+
""enabled"": true,
71+
""path"": ""/api""
72+
},
73+
""graphql"": {
74+
""enabled"": true,
75+
""path"": ""/graphql"",
76+
""allow-introspection"": true
77+
},
78+
""host"": {
79+
""mode"": ""development"",
80+
""cors"": {
81+
""origins"": [],
82+
""allow-credentials"": false
83+
},
84+
""authentication"": {
85+
""provider"": ""StaticWebApps""
86+
}
87+
}
88+
},
89+
""entities"": {}
90+
}";
91+
92+
// Act
93+
bool success = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config);
94+
95+
// Assert
96+
Assert.IsTrue(success);
97+
Assert.IsNotNull(config);
98+
Assert.IsNull(config.DataSource.UserDelegatedAuth);
99+
}
100+
}
101+
}

src/Cli/Commands/ConfigureOptions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ public ConfigureOptions(
2929
string? dataSourceOptionsSchema = null,
3030
bool? dataSourceOptionsSetSessionContext = null,
3131
string? dataSourceHealthName = null,
32+
bool? dataSourceUserDelegatedAuthEnabled = null,
33+
string? dataSourceUserDelegatedAuthDatabaseAudience = null,
3234
int? depthLimit = null,
3335
bool? runtimeGraphQLEnabled = null,
3436
string? runtimeGraphQLPath = null,
@@ -84,6 +86,8 @@ public ConfigureOptions(
8486
DataSourceOptionsSchema = dataSourceOptionsSchema;
8587
DataSourceOptionsSetSessionContext = dataSourceOptionsSetSessionContext;
8688
DataSourceHealthName = dataSourceHealthName;
89+
DataSourceUserDelegatedAuthEnabled = dataSourceUserDelegatedAuthEnabled;
90+
DataSourceUserDelegatedAuthDatabaseAudience = dataSourceUserDelegatedAuthDatabaseAudience;
8791
// GraphQL
8892
DepthLimit = depthLimit;
8993
RuntimeGraphQLEnabled = runtimeGraphQLEnabled;
@@ -160,6 +164,12 @@ public ConfigureOptions(
160164
[Option("data-source.health.name", Required = false, HelpText = "Identifier for data source in health check report.")]
161165
public string? DataSourceHealthName { get; }
162166

167+
[Option("data-source.user-delegated-auth.enabled", Required = false, HelpText = "Enable user-delegated authentication (OBO) for Azure SQL and SQL Server. Default: false (boolean).")]
168+
public bool? DataSourceUserDelegatedAuthEnabled { get; }
169+
170+
[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).")]
171+
public string? DataSourceUserDelegatedAuthDatabaseAudience { get; }
172+
163173
[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.")]
164174
public int? DepthLimit { get; }
165175

src/Cli/ConfigGenerator.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,7 @@ private static bool TryUpdateConfiguredDataSourceOptions(
643643
DatabaseType dbType = runtimeConfig.DataSource.DatabaseType;
644644
string dataSourceConnectionString = runtimeConfig.DataSource.ConnectionString;
645645
DatasourceHealthCheckConfig? datasourceHealthCheckConfig = runtimeConfig.DataSource.Health;
646+
UserDelegatedAuthOptions? userDelegatedAuthConfig = runtimeConfig.DataSource.UserDelegatedAuth;
646647

647648
if (options.DataSourceDatabaseType is not null)
648649
{
@@ -714,8 +715,41 @@ private static bool TryUpdateConfiguredDataSourceOptions(
714715
}
715716
}
716717

718+
// Handle user-delegated-auth options
719+
if (options.DataSourceUserDelegatedAuthEnabled is not null
720+
|| options.DataSourceUserDelegatedAuthDatabaseAudience is not null)
721+
{
722+
// Determine the enabled state: use new value if provided, otherwise preserve existing
723+
bool enabled = options.DataSourceUserDelegatedAuthEnabled
724+
?? userDelegatedAuthConfig?.Enabled
725+
?? false;
726+
727+
// Validate that user-delegated-auth is only used with MSSQL when enabled=true
728+
if (enabled && !DatabaseType.MSSQL.Equals(dbType))
729+
{
730+
_logger.LogError("user-delegated-auth is only supported for database-type 'mssql'.");
731+
return false;
732+
}
733+
734+
// Get database-audience: use new value if provided, otherwise preserve existing
735+
string? databaseAudience = options.DataSourceUserDelegatedAuthDatabaseAudience
736+
?? userDelegatedAuthConfig?.DatabaseAudience;
737+
738+
// Get provider: preserve existing or use default "EntraId"
739+
string? provider = userDelegatedAuthConfig?.Provider ?? "EntraId";
740+
741+
// Create or update user-delegated-auth config
742+
userDelegatedAuthConfig = new UserDelegatedAuthOptions(
743+
Enabled: enabled,
744+
Provider: provider,
745+
DatabaseAudience: databaseAudience);
746+
}
747+
717748
dbOptions = EnumerableUtilities.IsNullOrEmpty(dbOptions) ? null : dbOptions;
718-
DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig);
749+
DataSource dataSource = new(dbType, dataSourceConnectionString, dbOptions, datasourceHealthCheckConfig)
750+
{
751+
UserDelegatedAuth = userDelegatedAuthConfig
752+
};
719753
runtimeConfig = runtimeConfig with { DataSource = dataSource };
720754

721755
return runtimeConfig != null;

0 commit comments

Comments
 (0)