Skip to content

Commit 302520d

Browse files
authored
Enhanced claims processing for MS SQL Set session context (#2003)
# Improved Claims Handling for DB Policies and MS SQL Session Context ## Why make this change? Initially reported in #1957, DAB does not gracefully handle instances where the provided JWT token has claims with value type JSON Array. Two negative impacts: 1. Developers who don't override the default MS SQL `set-session-context` value of `true` to `false` will observe that requests fail for tokens that fit the above criteria. 2. Developers who write database policies with `@claims.claimType` references will see requests fail when the claimType referenced fits the above criteria. **Note:** The issue raised in #1957 is unique because the user may be using a historical version of Duende's IdentityServer which emits token scopes in a `scope` claim here the value is a JSON array. That format differs from Entra ID which emits tokens scopes in the 'scp' claim whose value is a string of values delimited by spaces. Reference: [Entra ID Access Token Claims Reference](https://learn.microsoft.com/entra/identity-platform/access-token-claims-reference#payload-claims). ## What changes are introduced in this PR? ### 1. Prevents `@claims.claimType` references in a DB policy from failing a request when the claimType has a value type of JSON array. **Example:** Given the database policy: `@claims.groups eq @item.groupid`, DAB would previously (before this PR's changes) fail a request if the provided access token had >1 group because the `groups` claim is a JSON string array in the JWT token which dotnet resolves into multiple `Claim` objects. (one claim per group where claimType is `groups` and value is `groupGUID`. The request failure no longer occurs. **How is this implemented?** DAB now recognizes when multiple `Claim` objects exist for a single claim type. Given the policy `@claims.groups eq @item.groupid` DAB will replace `@claims.groups` with the first instance of the claimType `groups` that DAB finds. If DAB has resolved two `groups` claim objects such as `["groupGUID1", "groupGUID2"]`, DAB would only resolve the first it finds: `groupGUID1`. When/if implemented, issue #2004 addresses this behavior by adding the OData operator `in` so that the query predicate is generated to be `([dbo].[groupid] in ('tokenGroupGUID1', 'tokenGroupGUID2'))`. ### 2. Prevents multiple instances of a claimType from failing a request utilizing MSSQL's `set-session-context` feature **How?** Aggregates multiple `Claim` objects of the same `claimType` (claim name) into a JSON array serialized into a string. The original value type in the JWT JSON array (bool, int, string) is preserved. DAB uses the serialized JSON as the value for a session context variable whose key is `claimType`. When dotnet processes a JWT token, claims whose values are JSON arrays will be split into distinct `Claim` objects where `claimType` is the claim name and `value` is one of the values in the array. `Claim` objects are created for each object in the array. DAB uses the session context feature to pass token claims and claim values to the database as session context variables. #### This is not a breaking change. This is not a breaking change because access tokens that had claims which didn't result in DAB failing the request will still have the claim values passed as is -> scalar values. This is because usable tokens didn't contain claims with JSON arrays as values. A breaking change would be modifying this behavior to pass the scalar by its original type as present in the JWT token by setting `DbConnectionParam.DbType` explicitly when creating `DbConnectionParam`. #### How can developers write security policies in SQL? When the value of a session context variable is a serialized JSON string containing JSON array, the value can be processed using the following tsql functionality: - `JSON_QUERY`(SQL Server 2016(13.X) and later, SQL MI, Azure SQL Database, Azure Synapse Analytics) - `JSON_VALUE` (SQL Server 2016(13.X) and later, SQL MI, Azure SQL Database, Azure Synapse Analytics) - `JSON_ARRAY` (SQL Server 2022 (16.X), Azure SQL Database) #### Example JWT token that is now handled without error ```json { "aud": "00000003-0000-0000-c000-000000000000", "iss": "https://sts.windows.net/25fd4421-0fff-4ed6-ad2f-c2ca00ed7207/", "iat": 1706642510, "acr": "1", "int_array": [1,2,3], "bool_array": [true, false, true], "groups":"src1", "_claim_sources":{ "src1" : { "endpoint" : "https://graph.microsoft.com/v1.0/users/{userID}/getMemberObjects" }}, "wids": ["d74b8d81-39eb-4201-bd9f-9f1c4011e3c9","18d14519-c4da-4ad4-936d-9a2de69d33cf","9e513fc0-e8af-43b1-a6c7-949edb1967a3"], "roles": ["anonymous", "authenticated", "myCustomRole"], "scp": "email openid profile User.Read", "scope": ["idServerScope1", "idServerScope2"] } ``` #### Example tsql created by dab for session context The following SQL shows how an Entra ID `scp` claim is still passed as a space delimited string and how a `roles` claim, a JSON array in the JWT token, is properly serialized into a JSON array string and passed to MS SQL session context without the request failing: Assumption: `x-ms-api-role` header value is `authenticated` ```sql EXEC sp_set_session_context 'roles', @session_param0, @read_only = 1; EXEC sp_set_session_context 'scp', @session_param1, @read_only = 1; EXEC sp_set_session_context 'scopes', @session_param2, @read_only = 1; SELECT TOP 1 [dbo_books].[id] AS [id], [dbo_books].[title] AS [title], [dbo_books].[publisher_id] AS [publisher_id] FROM [dbo].[books] AS [dbo_books] WHERE [dbo_books].[id] = @param0 ORDER BY [dbo_books].[id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES,WITHOUT_ARRAY_WRAPPER @param0 int, @param1 nvarchar(2), @Param2 nvarchar(5), @param3 nvarchar(12), @session_param0 nvarchar(13), @session_param1 nvarchar(37) @session_param2 nvarchar(44) @param0=1, @param1=N'id', @Param2=N'title', @param3=N'publisher_id', @session_param0=N'["Authenticated"]', @session_param1=N'GraphQL.ReadWrite REST.EndpointAccess' @session_param2=N'["GraphQL.ReadWrite", "REST.EndpointAccess"]' ``` #### Matrix of how jwt claims are process by DAB and dotnet for session context `Dictionary<string, string> GetProcessedUserClaims(HttpContext? context)` is populated based on the following rules: | Jwt ClaimName | JwtClaimValue | |--------------------|-------------------------------------------------------------------------------------------| | scp | openid profile customScope | | scopes (idserver4) | ["openid", "profile","customScope"] | | intArrayClaim | [1,2,3] | | boolArrayClaim | [true, false, true] | | nullValueClaim | null | | _claim_sources | {"src1": {"endpoint":"https://graph.microsoft.com/v1.0/users/{userID}/getMemberObjects"}} | | DotNetProcessed Name | DotNet Processed Value | |----------------------------------------------|-------------------------------------------------------------------------------------------| | scp | openid profile customScope | | scopes </br> scopes </br> scopes | openid </br> profile </br> customScope | | intArrayClaim </br> intArrayClaim </br> intArrayClaim | 1 </br> 2 </br> 3 | | boolArrayClaim </br> boolArrayClaim </br> boolArrayClaim | true </br> false </br> true | | nullValueClaim | //empty string | | _claim_sources | {"src1": {"endpoint":"https://graph.microsoft.com/v1.0/users/{userID}/getMemberObjects"}} | | ProcessedValueForSessionCtx | Remarks | |-------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | openid profile customScope | scp claim from Entra ID access token | | ["openid", "profile", "customScope" | Non-Entra ID identity providers may emit a "scopes" claim as a JSON string array. Dotnet processes the array into individual scope claims and doesn't retain the space delimited string. | | [1,2,3] | | | [true, false, true] | | | | | | {"src1": {"endpoint":"https://graph.microsoft.com/v1.0/users/{userID}/getMemberObjects"}} | | ## Tests -[x] Unit tests -[x] Integration tests
1 parent a64c277 commit 302520d

File tree

4 files changed

+521
-90
lines changed

4 files changed

+521
-90
lines changed

src/Core/Authorization/AuthorizationResolver.cs

Lines changed: 142 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.IdentityModel.Tokens.Jwt;
55
using System.Net;
66
using System.Security.Claims;
7+
using System.Text.Json;
78
using System.Text.RegularExpressions;
89
using Azure.DataApiBuilder.Auth;
910
using Azure.DataApiBuilder.Config.DatabasePrimitives;
@@ -190,7 +191,7 @@ public string ProcessDBPolicy(string entityName, string roleName, EntityActionOp
190191
return string.Empty;
191192
}
192193

193-
return GetPolicyWithClaimValues(dBpolicyWithClaimTypes, GetAllUserClaims(httpContext));
194+
return GetPolicyWithClaimValues(dBpolicyWithClaimTypes, GetAllAuthenticatedUserClaims(httpContext));
194195
}
195196

196197
/// <inheritdoc />
@@ -438,61 +439,159 @@ public IEnumerable<string> GetAllowedExposedColumns(string entityName, string ro
438439
}
439440

440441
/// <summary>
441-
/// Helper method to extract all claims available in the HttpContext's user object and add the claims
442-
/// to the claimsInRequestContext dictionary to be used for claimType -> claim lookups.
442+
/// Returns a dictionary (string, string) where a key is a claim's name and a value is a claim's value.
443+
/// Resolves multiple claim objects of the same claim type into a JSON array mirroring the format
444+
/// of the claim in the original JWT token. The JSON array's type depends on the value type
445+
/// present in the original JWT token.
443446
/// </summary>
447+
/// <remarks>
448+
/// DotNet will resolve a claim with value type JSON array to multiple Claim objects
449+
/// with the same type and different values.
450+
/// e.g. roles and groups claim, which are arrays of strings and are flattened to:
451+
/// roles: role1, roles: role2, groups: group1, groups: group2
452+
/// "Claims are name valued pairs and nothing more."
453+
/// Ref: https://github.com/dotnet/aspnetcore/issues/13647#issuecomment-527523224
454+
/// The library that parses the JWT token into claims is the one that decides
455+
/// *how* to resolve the claims from the token's JSON payload.
456+
/// dotnet flattens the claims into a list:
457+
/// https://github.com/dotnet/aspnetcore/blob/282bfc1b486ae235a3395150a8d53073a57b7f43/src/Security/Authentication/OAuth/src/JsonKeyClaimAction.cs#L39-L53
458+
/// </remarks>
459+
/// <param name="context">HttpContext which contains a ClaimsPrincipal</param>
460+
/// <returns>Processed claims and claim values.</returns>
461+
public static Dictionary<string, string> GetProcessedUserClaims(HttpContext? context)
462+
{
463+
Dictionary<string, string> processedClaims = new();
464+
465+
if (context is null)
466+
{
467+
return processedClaims;
468+
}
469+
470+
Dictionary<string, List<Claim>> userClaims = GetAllAuthenticatedUserClaims(context);
471+
472+
foreach ((string claimName, List<Claim> claimValues) in userClaims)
473+
{
474+
// Some identity providers (other than Entra ID) may emit a 'scope' claim as a JSON string array. dotnet will
475+
// create claim objects for each value in the array. DAB will honor that format
476+
// and processes the 'scope' claim objects as a JSON array serialized to a string.
477+
if (claimValues.Count > 1)
478+
{
479+
switch (claimValues.First().ValueType)
480+
{
481+
case ClaimValueTypes.Boolean:
482+
processedClaims.Add(claimName, value: JsonSerializer.Serialize(claimValues.Select(claim => bool.Parse(claim.Value))));
483+
break;
484+
case ClaimValueTypes.Integer:
485+
case ClaimValueTypes.Integer32:
486+
processedClaims.Add(claimName, value: JsonSerializer.Serialize(claimValues.Select(claim => int.Parse(claim.Value))));
487+
break;
488+
// Per Microsoft Docs: UInt32's CLS compliant alternative is Integer64
489+
// https://learn.microsoft.com/dotnet/api/system.uint32?view=net-6.0#remarks
490+
case ClaimValueTypes.UInteger32:
491+
case ClaimValueTypes.Integer64:
492+
processedClaims.Add(claimName, value: JsonSerializer.Serialize(claimValues.Select(claim => long.Parse(claim.Value))));
493+
break;
494+
// Per Microsoft Docs: UInt64's CLS compliant alternative is decimal
495+
// https://learn.microsoft.com/dotnet/api/system.uint64?view=net-6.0#remarks
496+
case ClaimValueTypes.UInteger64:
497+
processedClaims.Add(claimName, value: JsonSerializer.Serialize(claimValues.Select(claim => decimal.Parse(claim.Value))));
498+
break;
499+
case ClaimValueTypes.Double:
500+
processedClaims.Add(claimName, value: JsonSerializer.Serialize(claimValues.Select(claim => double.Parse(claim.Value))));
501+
break;
502+
case ClaimValueTypes.String:
503+
case JsonClaimValueTypes.JsonNull:
504+
case JsonClaimValueTypes.Json:
505+
default:
506+
string json = JsonSerializer.Serialize(claimValues.Select(claim => claim.Value));
507+
processedClaims.Add(claimName, value: json);
508+
break;
509+
}
510+
}
511+
else
512+
{
513+
// Remaining claims will be collected as string scalar values.
514+
// While Claim.ValueType may indicate the token value was not a string (int, bool),
515+
// resolving the actual type here would be a breaking change because
516+
// DAB has historically sent single instance claims as value type string.
517+
// This block also accommodates Entra ID access tokens to avoid a breaking change because
518+
// the 'scp' claim is a space delimited string which is not broken up into separate claim objects
519+
// by dotnet. The Entra ID 'scp' claim should be passed to MSSQL's session context as-is.
520+
// https://learn.microsoft.com/entra/identity-platform/access-token-claims-reference#payload-claims
521+
processedClaims.Add(claimName, value: claimValues[0].Value);
522+
}
523+
}
524+
525+
return processedClaims;
526+
}
527+
528+
/// <summary>
529+
/// Helper method to extract all claims available in the HttpContext's user object (from authenticated ClaimsIdentity objects)
530+
/// and add the claims to the claimsInRequestContext dictionary to be used for claimType -> claim lookups.
531+
/// This method only resolves one `roles` claim from the authenticated user's claims: the `roles` claim whose
532+
/// value matches the x-ms-api-role` header value.
533+
/// </summary>
534+
/// <remarks>
535+
/// DotNet will resolve a claim with value type JSON array to multiple Claim objects with the same type and different values.
536+
/// e.g. roles and groups claim, which are arrays of strings and are flattened to: roles: role1, roles: role2, groups: group1, groups: group2
537+
/// Claims are name valued pairs and nothing more. https://github.com/dotnet/aspnetcore/issues/13647#issuecomment-527523224
538+
/// The library that parses the jwt token into claims is the one that decides *how* to resolve the claims from the token's JSON payload.
539+
/// DotNet flattens the claims into a list: https://github.com/dotnet/aspnetcore/blob/282bfc1b486ae235a3395150a8d53073a57b7f43/src/Security/Authentication/OAuth/src/JsonKeyClaimAction.cs#L39-L53
540+
/// </remarks>
444541
/// <param name="context">HttpContext object used to extract the authenticated user's claims.</param>
445-
/// <returns>Dictionary with claimType -> claim mappings.</returns>
446-
public static Dictionary<string, Claim> GetAllUserClaims(HttpContext? context)
542+
/// <returns>Dictionary with claimType -> list of claim mappings.</returns>
543+
public static Dictionary<string, List<Claim>> GetAllAuthenticatedUserClaims(HttpContext? context)
447544
{
448-
Dictionary<string, Claim> claimsInRequestContext = new();
545+
Dictionary<string, List<Claim>> resolvedClaims = new();
449546
if (context is null)
450547
{
451-
return claimsInRequestContext;
548+
return resolvedClaims;
452549
}
453550

454551
string clientRoleHeader = context.Request.Headers[CLIENT_ROLE_HEADER].ToString();
455552

456553
// Iterate through all the identities to populate claims in request context.
457554
foreach (ClaimsIdentity identity in context.User.Identities)
458555
{
459-
460-
// Only add a role claim which represents the role context evaluated for the request,
461-
// as this can be via the virtue of an identity added by DAB.
462-
if (!claimsInRequestContext.ContainsKey(AuthenticationOptions.ROLE_CLAIM_TYPE) &&
463-
identity.HasClaim(type: AuthenticationOptions.ROLE_CLAIM_TYPE, value: clientRoleHeader))
464-
{
465-
claimsInRequestContext.Add(AuthenticationOptions.ROLE_CLAIM_TYPE, new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, clientRoleHeader, ClaimValueTypes.String));
466-
}
467-
468556
// If identity is not authenticated, we don't honor any other claims present in this identity.
469557
if (!identity.IsAuthenticated)
470558
{
471559
continue;
472560
}
473561

562+
// DAB will only resolve one 'roles' claim whose value matches the x-ms-api-role header value
563+
// because DAB executes requests in the context of a single role. The `roles` claim
564+
// resolved here can be forwarded to MSSQL's set-session-context. Modifying this behavior
565+
// is a breaking change.
566+
if (!resolvedClaims.ContainsKey(AuthenticationOptions.ROLE_CLAIM_TYPE) &&
567+
identity.HasClaim(type: AuthenticationOptions.ROLE_CLAIM_TYPE, value: clientRoleHeader))
568+
{
569+
List<Claim> roleClaim = new()
570+
{
571+
new Claim(type: AuthenticationOptions.ROLE_CLAIM_TYPE, value: clientRoleHeader, valueType: ClaimValueTypes.String)
572+
};
573+
574+
resolvedClaims.Add(AuthenticationOptions.ROLE_CLAIM_TYPE, roleClaim);
575+
}
576+
577+
// Process all remaining claims adding all `Claim` objects with the same claimType (claim name)
578+
// into a list and storing that in resolvedClaims using the claimType as the key.
474579
foreach (Claim claim in identity.Claims)
475580
{
476-
/*
477-
* An example claim would be of format:
478-
* claim.Type: "user_email"
479-
* claim.Value: "[email protected]"
480-
* claim.ValueType: "string"
481-
*/
482-
// At this point, only add non-role claims to the collection and only throw an exception for duplicate non-role claims.
483-
if (!claim.Type.Equals(AuthenticationOptions.ROLE_CLAIM_TYPE) && !claimsInRequestContext.TryAdd(claim.Type, claim))
581+
// 'roles' claim has already been processed.
582+
if (claim.Type.Equals(AuthenticationOptions.ROLE_CLAIM_TYPE))
484583
{
485-
// If there are duplicate claims present in the request, return an exception.
486-
throw new DataApiBuilderException(
487-
message: "Duplicate claims are not allowed within a request.",
488-
statusCode: HttpStatusCode.Forbidden,
489-
subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed
490-
);
584+
continue;
585+
}
586+
587+
if (!resolvedClaims.TryAdd(key: claim.Type, value: new List<Claim>() { claim }))
588+
{
589+
resolvedClaims[claim.Type].Add(claim);
491590
}
492591
}
493592
}
494593

495-
return claimsInRequestContext;
594+
return resolvedClaims;
496595
}
497596

498597
/// <summary>
@@ -503,7 +602,7 @@ public static Dictionary<string, Claim> GetAllUserClaims(HttpContext? context)
503602
/// <param name="claimsInRequestContext">Dictionary holding all the claims available in the request.</param>
504603
/// <returns>Processed policy with claim values substituted for claim types.</returns>
505604
/// <exception cref="DataApiBuilderException"></exception>
506-
private static string GetPolicyWithClaimValues(string policy, Dictionary<string, Claim> claimsInRequestContext)
605+
private static string GetPolicyWithClaimValues(string policy, Dictionary<string, List<Claim>> claimsInRequestContext)
507606
{
508607
// Regex used to extract all claimTypes in policy. It finds all the substrings which are
509608
// of the form @claims.*** where *** contains characters from a-zA-Z0-9._ .
@@ -523,15 +622,22 @@ private static string GetPolicyWithClaimValues(string policy, Dictionary<string,
523622
/// </summary>
524623
/// <param name="claimTypeMatch">The claimType present in policy with a prefix of @claims..</param>
525624
/// <param name="claimsInRequestContext">Dictionary populated with all the user claims.</param>
526-
/// <returns>The claim value for the given claimTypeMatch.</returns>
625+
/// <returns>The claim value of the first claim whose claimType matches 'claimTypeMatch'.</returns>
527626
/// <exception cref="DataApiBuilderException"> Throws exception when the user does not possess the given claim.</exception>
528-
private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<string, Claim> claimsInRequestContext)
627+
private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<string, List<Claim>> claimsInRequestContext)
529628
{
530629
// Gets <claimType> from @claims.<claimType>
531630
string claimType = claimTypeMatch.Value.ToString().Substring(CLAIM_PREFIX.Length);
532-
if (claimsInRequestContext.TryGetValue(claimType, out Claim? claim))
631+
if (claimsInRequestContext.TryGetValue(claimType, out List<Claim>? claims)
632+
&& claims is not null && claims.Count > 0)
533633
{
534-
return GetClaimValue(claim);
634+
// Database policies do not support operators like "in" or "contains".
635+
// Return the first value in the list of claims.
636+
// This is not a breaking change since historically,
637+
// if the user had >1 role AND wrote a db policy to include the token '@claims.role',
638+
// DAB would fail the request. Now, the request won't fail, but the value
639+
// resolved is the first claim encountered (when there are multiple claim instances).
640+
return GetClaimValue(claims.First());
535641
}
536642
else
537643
{

src/Core/Resolvers/MsSqlQueryExecutor.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System.Data;
55
using System.Data.Common;
66
using System.Net;
7-
using System.Security.Claims;
87
using System.Text;
98
using Azure.Core;
109
using Azure.DataApiBuilder.Config.ObjectModel;
@@ -208,17 +207,17 @@ public override string GetSessionParamsQuery(HttpContext? httpContext, IDictiona
208207
}
209208

210209
// Dictionary containing all the claims belonging to the user, to be used as session parameters.
211-
Dictionary<string, Claim> sessionParams = AuthorizationResolver.GetAllUserClaims(httpContext);
210+
Dictionary<string, string> sessionParams = AuthorizationResolver.GetProcessedUserClaims(httpContext);
212211

213212
// Counter to generate different param name for each of the sessionParam.
214213
IncrementingInteger counter = new();
215214
const string SESSION_PARAM_NAME = $"{BaseQueryStructure.PARAM_NAME_PREFIX}session_param";
216215
StringBuilder sessionMapQuery = new();
217216

218-
foreach ((string claimType, Claim claim) in sessionParams)
217+
foreach ((string claimType, string claimValue) in sessionParams)
219218
{
220219
string paramName = $"{SESSION_PARAM_NAME}{counter.Next()}";
221-
parameters.Add(paramName, new(claim.Value));
220+
parameters.Add(paramName, new(claimValue));
222221
// Append statement to set read only param value - can be set only once for a connection.
223222
string statementToSetReadOnlyParam = "EXEC sp_set_session_context " + $"'{claimType}', " + paramName + ", @read_only = 1;";
224223
sessionMapQuery = sessionMapQuery.Append(statementToSetReadOnlyParam);

0 commit comments

Comments
 (0)