Skip to content

Commit c69318b

Browse files
authored
Merge branch 'main' into copilot/add-role-inheritance-permissions
2 parents dab4c6f + a4fe457 commit c69318b

32 files changed

Lines changed: 2114 additions & 56 deletions

schemas/dab.draft.schema.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,28 @@
6161
"maximum": 2147483647
6262
}
6363
}
64+
},
65+
"user-delegated-auth": {
66+
"description": "User-delegated authentication configuration for On-Behalf-Of (OBO) flow. Enables DAB to connect to the database using the calling user's identity.",
67+
"type": ["object", "null"],
68+
"additionalProperties": false,
69+
"properties": {
70+
"enabled": {
71+
"$ref": "#/$defs/boolean-or-string",
72+
"description": "Enable user-delegated authentication (OBO flow).",
73+
"default": false
74+
},
75+
"provider": {
76+
"type": "string",
77+
"description": "Identity provider for user-delegated authentication.",
78+
"enum": ["EntraId"],
79+
"default": "EntraId"
80+
},
81+
"database-audience": {
82+
"type": "string",
83+
"description": "The audience URI for the target database (e.g., https://database.windows.net for Azure SQL)."
84+
}
85+
}
6486
}
6587
},
6688
"allOf": [

src/Cli.Tests/ConfigGeneratorTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ public void TestSpecialCharactersInConnectionString()
178178
""mode"": ""production""
179179
}
180180
},
181+
""autoentities"": {},
181182
""entities"": {}
182183
}");
183184

src/Cli.Tests/ModuleInitializer.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public static void Init()
2323
VerifierSettings.IgnoreMember<DataSource>(dataSource => dataSource.IsDatasourceHealthEnabled);
2424
// Ignore the DatasourceThresholdMs from the output to avoid committing it.
2525
VerifierSettings.IgnoreMember<DataSource>(dataSource => dataSource.DatasourceThresholdMs);
26+
// Ignore the IsUserDelegatedAuthEnabled from the output as it's a computed property.
27+
VerifierSettings.IgnoreMember<DataSource>(dataSource => dataSource.IsUserDelegatedAuthEnabled);
2628
// Ignore the datasource files as that's unimportant from a test standpoint.
2729
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.DataSourceFiles);
2830
// Ignore the CosmosDataSourceUsed as that's unimportant from a test standpoint.
@@ -105,6 +107,8 @@ public static void Init()
105107
VerifierSettings.IgnoreMember<GraphQLRuntimeOptions>(options => options.FeatureFlags);
106108
// Ignore the JSON schema path as that's unimportant from a test standpoint.
107109
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.Schema);
110+
// Ignore the JSON schema path as that's unimportant from a test standpoint.
111+
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.Autoentities);
108112
// Ignore the message as that's not serialized in our config file anyway.
109113
VerifierSettings.IgnoreMember<DataSource>(dataSource => dataSource.DatabaseTypeNotSupportedMessage);
110114
// Ignore DefaultDataSourceName as that's not serialized in our config file.

src/Cli.Tests/TestHelper.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,7 @@ public static string GenerateConfigWithGivenDepthLimit(string? depthLimitJson =
13021302
}}
13031303
}}
13041304
}},
1305+
""autoentities"": {{}},
13051306
""entities"": {{}}";
13061307

13071308
return $"{{{SAMPLE_SCHEMA_DATA_SOURCE},{runtimeSection}}}";

src/Config/Converters/DataSourceConverterFactory.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,16 @@ public DataSourceConverter(DeserializationVariableReplacementSettings? replaceme
5151
string connectionString = string.Empty;
5252
DatasourceHealthCheckConfig? health = null;
5353
Dictionary<string, object?>? datasourceOptions = null;
54+
UserDelegatedAuthOptions? userDelegatedAuth = null;
5455

5556
while (reader.Read())
5657
{
5758
if (reader.TokenType is JsonTokenType.EndObject)
5859
{
59-
return new DataSource(databaseType, connectionString, datasourceOptions, health);
60+
return new DataSource(databaseType, connectionString, datasourceOptions, health)
61+
{
62+
UserDelegatedAuth = userDelegatedAuth
63+
};
6064
}
6165

6266
if (reader.TokenType is JsonTokenType.PropertyName)
@@ -136,6 +140,20 @@ public DataSourceConverter(DeserializationVariableReplacementSettings? replaceme
136140
datasourceOptions = optionsDict;
137141
}
138142

143+
break;
144+
case "user-delegated-auth":
145+
if (reader.TokenType != JsonTokenType.Null)
146+
{
147+
try
148+
{
149+
userDelegatedAuth = JsonSerializer.Deserialize<UserDelegatedAuthOptions>(ref reader, options);
150+
}
151+
catch (Exception e)
152+
{
153+
throw new JsonException($"Error while deserializing DataSource user-delegated-auth: {e.Message}");
154+
}
155+
}
156+
139157
break;
140158
default:
141159
throw new JsonException($"Unexpected property {propertyName} while deserializing DataSource.");

src/Config/Converters/RuntimeAutoentitiesConverter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class RuntimeAutoentitiesConverter : JsonConverter<RuntimeAutoentities>
2929
public override void Write(Utf8JsonWriter writer, RuntimeAutoentities value, JsonSerializerOptions options)
3030
{
3131
writer.WriteStartObject();
32-
foreach ((string key, Autoentity autoEntity) in value.AutoEntities)
32+
foreach ((string key, Autoentity autoEntity) in value.Autoentities)
3333
{
3434
writer.WritePropertyName(key);
3535
JsonSerializer.Serialize(writer, autoEntity, options);

src/Config/DataApiBuilderException.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ public class DataApiBuilderException : Exception
2020
public const string GRAPHQL_MUTATION_FIELD_AUTHZ_FAILURE = "Unauthorized due to one or more fields in this mutation.";
2121
public const string GRAPHQL_GROUPBY_FIELD_AUTHZ_FAILURE = "Access forbidden to field '{0}' referenced in the groupBy argument.";
2222
public const string GRAPHQL_AGGREGATION_FIELD_AUTHZ_FAILURE = "Access forbidden to field '{0}' referenced in the aggregation function '{1}'.";
23+
public const string OBO_IDENTITY_CLAIMS_MISSING = "User-delegated authentication failed: Neither 'oid' nor 'sub' claim found in the access token.";
24+
public const string OBO_TENANT_CLAIM_MISSING = "User-delegated authentication failed: 'tid' (tenant id) claim not found in the access token.";
25+
public const string OBO_TOKEN_ACQUISITION_FAILED = "User-delegated authentication failed: Unable to acquire database access token on behalf of the user.";
26+
public const string OBO_MISSING_USER_CONTEXT = "User-delegated authentication failed: Missing or invalid 'Authorization: Bearer <token>' header. OBO requires a valid user token to exchange for database access.";
27+
public const string OBO_MISSING_DATABASE_AUDIENCE = "User-delegated authentication failed: 'database-audience' is not configured in the data source's user-delegated-auth settings.";
2328

2429
public enum SubStatusCodes
2530
{
@@ -127,7 +132,11 @@ public enum SubStatusCodes
127132
/// <summary>
128133
/// Error due to client input validation failure.
129134
/// </summary>
130-
DatabaseInputError
135+
DatabaseInputError,
136+
/// <summary>
137+
/// User-delegated (OBO) authentication failed due to missing identity claims.
138+
/// </summary>
139+
OboAuthenticationFailure
131140
}
132141

133142
public HttpStatusCode StatusCode { get; }

src/Config/DatabasePrimitives/DatabaseObject.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ public override bool Equals(object? other)
4343
public bool Equals(DatabaseObject? other)
4444
{
4545
return other is not null &&
46-
SchemaName.Equals(other.SchemaName) &&
47-
Name.Equals(other.Name);
46+
string.Equals(SchemaName, other.SchemaName, StringComparison.OrdinalIgnoreCase) &&
47+
string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
4848
}
4949

5050
public override int GetHashCode()
5151
{
52-
return HashCode.Combine(SchemaName, Name);
52+
return HashCode.Combine(
53+
SchemaName is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(SchemaName),
54+
Name is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(Name));
5355
}
5456

5557
/// <summary>

src/Config/ObjectModel/DataSource.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ public int DatasourceThresholdMs
4040
}
4141
}
4242

43+
/// <summary>
44+
/// Configuration for user-delegated authentication (OBO) against the
45+
/// configured database.
46+
/// </summary>
47+
[JsonPropertyName("user-delegated-auth")]
48+
public UserDelegatedAuthOptions? UserDelegatedAuth { get; init; }
49+
50+
/// <summary>
51+
/// Indicates whether user-delegated authentication is enabled for this data source.
52+
/// </summary>
53+
[JsonIgnore]
54+
public bool IsUserDelegatedAuthEnabled =>
55+
UserDelegatedAuth is not null && UserDelegatedAuth.Enabled;
56+
4357
/// <summary>
4458
/// Converts the <c>Options</c> dictionary into a typed options object.
4559
/// May return null if the dictionary is null.
@@ -111,3 +125,67 @@ public record CosmosDbNoSQLDataSourceOptions(string? Database, string? Container
111125
/// Options for MsSql database.
112126
/// </summary>
113127
public record MsSqlOptions(bool SetSessionContext = true) : IDataSourceOptions;
128+
129+
/// <summary>
130+
/// Options for user-delegated authentication (OBO) for a data source.
131+
///
132+
/// When OBO is NOT enabled (default): DAB connects to the database using a single application principal,
133+
/// either via Managed Identity or credentials supplied in the connection string. All requests execute
134+
/// under the same database identity regardless of which user made the API call.
135+
///
136+
/// When OBO IS enabled: DAB exchanges the calling user's JWT for a database access token using the
137+
/// On-Behalf-Of flow. This allows DAB to connect to the database as the actual user, enabling
138+
/// Row-Level Security (RLS) filtering based on user identity.
139+
///
140+
/// OBO requires an Azure AD App Registration (separate from the DAB service's Managed Identity).
141+
/// The operator deploying DAB must set the following environment variables for the OBO App Registration,
142+
/// which DAB reads at startup via Environment.GetEnvironmentVariable():
143+
/// - DAB_OBO_CLIENT_ID: The Application (client) ID of the OBO App Registration
144+
/// - DAB_OBO_TENANT_ID: The Directory (tenant) ID where the OBO App Registration is registered
145+
/// - DAB_OBO_CLIENT_SECRET: The client secret of the OBO App Registration (not a user secret)
146+
///
147+
/// These credentials belong to the OBO App Registration, which acts as a confidential client to exchange
148+
/// the incoming user JWT for a database access token. The user provides only their JWT; DAB uses the
149+
/// App Registration credentials to perform the OBO token exchange on their behalf.
150+
///
151+
/// These can be set in the hosting environment (e.g., Azure Container Apps secrets, Kubernetes secrets,
152+
/// Docker environment variables, or local shell environment).
153+
///
154+
/// Note: DAB-specific prefixes (DAB_OBO_*) are used instead of AZURE_* to avoid conflict with
155+
/// DefaultAzureCredential, which interprets AZURE_CLIENT_ID as a User-Assigned Managed Identity ID.
156+
/// At startup (when no user context is available), DAB falls back to Managed Identity for metadata operations.
157+
/// </summary>
158+
/// <param name="Enabled">Whether user-delegated authentication is enabled.</param>
159+
/// <param name="Provider">The authentication provider (currently only EntraId is supported).</param>
160+
/// <param name="DatabaseAudience">Audience used when acquiring database tokens on behalf of the user.</param>
161+
public record UserDelegatedAuthOptions(
162+
[property: JsonPropertyName("enabled")] bool Enabled = false,
163+
[property: JsonPropertyName("provider")] string? Provider = null,
164+
[property: JsonPropertyName("database-audience")] string? DatabaseAudience = null)
165+
{
166+
/// <summary>
167+
/// Default duration, in minutes, to cache tokens for a given delegated identity.
168+
/// With a 5-minute early refresh buffer, tokens are refreshed at the 40-minute mark.
169+
/// </summary>
170+
public const int DEFAULT_TOKEN_CACHE_DURATION_MINUTES = 45;
171+
172+
/// <summary>
173+
/// Environment variable name for OBO App Registration client ID.
174+
/// Uses DAB-specific prefix to avoid conflict with AZURE_CLIENT_ID which is
175+
/// interpreted by DefaultAzureCredential/ManagedIdentityCredential as a
176+
/// User-Assigned Managed Identity ID.
177+
/// </summary>
178+
public const string DAB_OBO_CLIENT_ID_ENV_VAR = "DAB_OBO_CLIENT_ID";
179+
180+
/// <summary>
181+
/// Environment variable name for OBO App Registration client secret.
182+
/// Used for On-Behalf-Of token exchange.
183+
/// </summary>
184+
public const string DAB_OBO_CLIENT_SECRET_ENV_VAR = "DAB_OBO_CLIENT_SECRET";
185+
186+
/// <summary>
187+
/// Environment variable name for OBO tenant ID.
188+
/// Uses DAB-specific prefix for consistency with OBO client ID.
189+
/// </summary>
190+
public const string DAB_OBO_TENANT_ID_ENV_VAR = "DAB_OBO_TENANT_ID";
191+
}
Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Collections;
45
using System.Text.Json.Serialization;
56
using Azure.DataApiBuilder.Config.Converters;
67

@@ -10,19 +11,29 @@ namespace Azure.DataApiBuilder.Config.ObjectModel;
1011
/// Represents a collection of <see cref="Autoentity"/> available from the RuntimeConfig.
1112
/// </summary>
1213
[JsonConverter(typeof(RuntimeAutoentitiesConverter))]
13-
public record RuntimeAutoentities
14+
public record RuntimeAutoentities : IEnumerable<KeyValuePair<string, Autoentity>>
1415
{
1516
/// <summary>
1617
/// The collection of <see cref="Autoentity"/> available from the RuntimeConfig.
1718
/// </summary>
18-
public IReadOnlyDictionary<string, Autoentity> AutoEntities { get; init; }
19+
public IReadOnlyDictionary<string, Autoentity> Autoentities { get; init; }
1920

2021
/// <summary>
2122
/// Creates a new instance of the <see cref="RuntimeAutoentities"/> class using a collection of entities.
2223
/// </summary>
23-
/// <param name="autoEntities">The collection of auto-entities to map to RuntimeAutoentities.</param>
24-
public RuntimeAutoentities(IReadOnlyDictionary<string, Autoentity> autoEntities)
24+
/// <param name="autoentities">The collection of auto-entities to map to RuntimeAutoentities.</param>
25+
public RuntimeAutoentities(IReadOnlyDictionary<string, Autoentity> autoentities)
2526
{
26-
AutoEntities = autoEntities;
27+
Autoentities = autoentities;
28+
}
29+
30+
public IEnumerator<KeyValuePair<string, Autoentity>> GetEnumerator()
31+
{
32+
return Autoentities.GetEnumerator();
33+
}
34+
35+
IEnumerator IEnumerable.GetEnumerator()
36+
{
37+
return GetEnumerator();
2738
}
2839
}

0 commit comments

Comments
 (0)