Skip to content

Commit d783120

Browse files
authored
Bind database policy claims as typed OData constants (#3756)
## Why make this change? Closes #3755 ## What is this change? - Replaces claim references in database policies with inert OData parameter aliases. - Keeps claim values separate from policy text in a `ResolvedDatabasePolicy`. - Injects claim values into the parsed OData AST as typed `ConstantNode` values before operand type promotion. - Converts supported primitive claim types to CLR values using invariant parsing and fails closed when a claim does not match its declared type. - Continues using database parameters for SQL predicates and now parameterizes Cosmos DB policy constants instead of inlining them. - Preserves legitimate claim values containing apostrophes, percent characters, or encoded text without decoding or rewriting them. Database policy resolution now returns a `ResolvedDatabasePolicy` rather than a string. This keeps trusted OData policy syntax separate from untrusted claim values: the policy contains inert aliases, while typed values are carried independently and bound during OData AST processing. This prevents URI decoding or escaping behavior from turning claim data into policy syntax. Relevant specification: - [OData 4.01 URL Conventions: Parameter Aliases](https://docs.oasis-open.org/odata/odata/v4.01/cs02/part2-url-conventions/odata-v4.01-cs02-part2-url-conventions.html#sec_ParameterAliases) ```mermaid flowchart TD A["Trusted configured policy<br/>@item.ownerId eq @claims.userId"] B["Untrusted authenticated claim<br/>alice%27 or 1 eq 1 or %27"] A --> C["AuthorizationResolver"] B --> C C --> D["ResolvedDatabasePolicy"] D --> E["Policy:<br/>ownerId eq @dabClaim0"] D --> F["ClaimValues:<br/>@dabClaim0 maps to raw CLR string"] E --> G["ODataParser"] F --> H["ConstantNode map"] H --> G G --> I["ClaimsTypeDataUriResolver<br/>Resolves aliases before type promotion"] I --> J["ParameterAliasRewriter<br/>Resolves remaining aliases and Boolean contexts"] J --> K["Typed FilterClause AST"] K --> L["ODataASTVisitor"] K --> M["ODataASTCosmosVisitor"] L --> N["SQL predicate and provider parameters"] M --> O["Cosmos SQL predicate and provider parameters"] ``` ## How was this tested? - [x] Integration Tests - [x] Unit Tests Focused unit-test coverage includes: - Literal apostrophes in string claims. - Percent-encoded text. - Double-encoded and mixed-encoded text. - Legitimate percent characters. - Typed boolean, integer, floating-point, and null claims. - Malformed primitive claims failing closed. - Cosmos DB policy constants being emitted as query parameters. Integration tests use an authenticated REST test in FindApiTestBase, so it will run against MSSQL, PGSQL, MySQL, and DWSQL, along with a cosmos specific authenticated GQL integration test. ## Sample Request(s) No client-facing request contract changes are introduced. Example database policy: @item.ownerId eq @claims.userId Example request: GET /api/Note Authorization: Bearer <token-with-userId-claim> X-MS-API-ROLE: authenticated A legitimate claim such as `O'Brien` or `50% complete` is preserved exactly and bound as a database parameter. It is never inserted into or reinterpreted as OData policy syntax.
1 parent a876cb6 commit d783120

32 files changed

Lines changed: 1285 additions & 132 deletions

config-generators/cosmosdb_nosql-commands.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "anon
44
update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete"
55
update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "limited-read-role:read"
66
update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "item-level-permission-role:read"
7+
update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "claim_policy_tester:read" --policy-database "@item.name eq @claims.userId"
78
add Character --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Character:Characters"
89
update Character --config "dab-config.CosmosDb_NoSql.json" --permissions "item-level-permission-role:read"
910
add Star --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Star:Stars"

config-generators/dwsql-commands.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ update Book --config "dab-config.DwSql.json" --permissions "policy_tester_08:cre
101101
update Book --config "dab-config.DwSql.json" --permissions "policy_tester_08:update" --fields.include "*" --policy-database "@item.id eq 9"
102102
update Book --config "dab-config.DwSql.json" --permissions "policy_tester_08:delete" --fields.include "*" --policy-database "@item.id eq 9"
103103
update Book --config "dab-config.DwSql.json" --permissions "policy_tester_08:read" --fields.include "*"
104+
update Book --config "dab-config.DwSql.json" --permissions "claim_policy_tester:read" --fields.include "*" --policy-database "@item.title eq @claims.userId"
104105
update Book --config "dab-config.DwSql.json" --permissions "test_role_with_noread:create,update,delete"
105106
update Book --config "dab-config.DwSql.json" --permissions "test_role_with_excluded_fields:create,update,delete"
106107
update Book --config "dab-config.DwSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "publisher_id"

config-generators/mssql-commands.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ update Book --config "dab-config.MsSql.json" --permissions "policy_tester_08:cre
124124
update Book --config "dab-config.MsSql.json" --permissions "policy_tester_08:update" --fields.include "*" --policy-database "@item.id eq 9"
125125
update Book --config "dab-config.MsSql.json" --permissions "policy_tester_08:delete" --fields.include "*" --policy-database "@item.id eq 9"
126126
update Book --config "dab-config.MsSql.json" --permissions "policy_tester_08:read" --fields.include "*"
127+
update Book --config "dab-config.MsSql.json" --permissions "claim_policy_tester:read" --fields.include "*" --policy-database "@item.title eq @claims.userId"
127128
update Book --config "dab-config.MsSql.json" --permissions "test_role_with_noread:create,update,delete"
128129
update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields:create,update,delete"
129130
update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "publisher_id"

config-generators/mysql-commands.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ update Book --config "dab-config.MySql.json" --permissions "policy_tester_08:cre
9696
update Book --config "dab-config.MySql.json" --permissions "policy_tester_08:update" --fields.include "*" --policy-database "@item.id eq 9"
9797
update Book --config "dab-config.MySql.json" --permissions "policy_tester_08:delete" --fields.include "*" --policy-database "@item.id eq 9"
9898
update Book --config "dab-config.MySql.json" --permissions "policy_tester_08:read" --fields.include "*"
99+
update Book --config "dab-config.MySql.json" --permissions "claim_policy_tester:read" --fields.include "*" --policy-database "@item.title eq @claims.userId"
99100
update Book --config "dab-config.MySql.json" --permissions "test_role_with_noread:create,update,delete"
100101
update Book --config "dab-config.MySql.json" --permissions "test_role_with_excluded_fields:create,update,delete"
101102
update Book --config "dab-config.MySql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "publisher_id"

config-generators/postgresql-commands.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ update Book --config "dab-config.PostgreSql.json" --permissions "policy_tester_0
9898
update Book --config "dab-config.PostgreSql.json" --permissions "policy_tester_08:update" --fields.include "*" --policy-database "@item.id eq 9"
9999
update Book --config "dab-config.PostgreSql.json" --permissions "policy_tester_08:delete" --fields.include "*" --policy-database "@item.id eq 9"
100100
update Book --config "dab-config.PostgreSql.json" --permissions "policy_tester_08:read" --fields.include "*"
101+
update Book --config "dab-config.PostgreSql.json" --permissions "claim_policy_tester:read" --fields.include "*" --policy-database "@item.title eq @claims.userId"
101102
update Book --config "dab-config.PostgreSql.json" --permissions "test_role_with_noread:create,update,delete"
102103
update Book --config "dab-config.PostgreSql.json" --permissions "test_role_with_excluded_fields:create,update,delete"
103104
update Book --config "dab-config.PostgreSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "publisher_id"

src/Auth/IAuthorizationResolver.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,15 @@ public interface IAuthorizationResolver
7272
public string GetDBPolicyForRequest(string entityName, string roleName, EntityActionOperation operation);
7373

7474
/// <summary>
75-
/// Retrieves the policy of an operation within an entity's role entry
76-
/// within the permissions section of the runtime config, and tries to process
77-
/// the policy.
75+
/// Resolves claim references in a database policy to parameter aliases and
76+
/// returns their typed values separately from the policy text.
7877
/// </summary>
7978
/// <param name="entityName">Entity from request.</param>
8079
/// <param name="roleName">Role defined in client role header.</param>
8180
/// <param name="operation">Operation type: Create, Read, Update, Delete.</param>
8281
/// <param name="httpContext">Contains token claims of the authenticated user used in policy evaluation.</param>
83-
/// <returns>Returns the parsed policy, if successfully processed, or an exception otherwise.</returns>
84-
public string ProcessDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext);
82+
/// <returns>The policy text and typed claim values to bind to it.</returns>
83+
public ResolvedDatabasePolicy ResolveDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext);
8584

8685
/// <summary>
8786
/// Get list of roles defined for entity within runtime configuration.. This is applicable for GraphQL when creating authorization

src/Auth/ResolvedDatabasePolicy.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Collections.ObjectModel;
5+
6+
namespace Azure.DataApiBuilder.Auth;
7+
8+
/// <summary>
9+
/// A database authorization policy whose claim references have been replaced by
10+
/// OData parameter aliases. Claim values remain separate from the policy text so
11+
/// they can be injected into the parsed OData AST as typed constants.
12+
/// </summary>
13+
public sealed record ResolvedDatabasePolicy
14+
{
15+
/// <summary>
16+
/// Policy text containing OData parameter aliases.
17+
/// </summary>
18+
public string Policy { get; }
19+
20+
/// <summary>
21+
/// Immutable snapshot of typed claim values keyed by parameter alias.
22+
/// </summary>
23+
public IReadOnlyDictionary<string, object?> ClaimValues { get; }
24+
25+
/// <summary>
26+
/// Represents an operation without a database authorization policy.
27+
/// </summary>
28+
public static ResolvedDatabasePolicy Empty { get; } = new(
29+
string.Empty,
30+
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>()));
31+
32+
/// <summary>
33+
/// Initializes a resolved database policy and takes an immutable snapshot of its claim values.
34+
/// </summary>
35+
/// <param name="policy">Policy text containing OData parameter aliases.</param>
36+
/// <param name="claimValues">Typed claim values keyed by their parameter alias.</param>
37+
public ResolvedDatabasePolicy(string policy, IReadOnlyDictionary<string, object?> claimValues)
38+
{
39+
ArgumentNullException.ThrowIfNull(policy);
40+
ArgumentNullException.ThrowIfNull(claimValues);
41+
42+
Policy = policy;
43+
ClaimValues = new ReadOnlyDictionary<string, object?>(
44+
new Dictionary<string, object?>(claimValues, StringComparer.Ordinal));
45+
}
46+
}

src/Core/Authorization/AuthorizationResolver.cs

Lines changed: 104 additions & 43 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.Globalization;
45
using System.Net;
56
using System.Security.Claims;
67
using System.Text.Json;
@@ -205,13 +206,13 @@ public bool AreColumnsAllowedForOperation(string entityName, string roleName, En
205206
}
206207

207208
/// <inheritdoc />
208-
public string ProcessDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext)
209+
public ResolvedDatabasePolicy ResolveDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext)
209210
{
210211
string dBpolicyWithClaimTypes = GetDBPolicyForRequest(entityName, roleName, operation);
211212

212213
if (string.IsNullOrWhiteSpace(dBpolicyWithClaimTypes))
213214
{
214-
return string.Empty;
215+
return ResolvedDatabasePolicy.Empty;
215216
}
216217

217218
return GetPolicyWithClaimValues(dBpolicyWithClaimTypes, GetAllAuthenticatedUserClaims(httpContext));
@@ -759,36 +760,47 @@ public static Dictionary<string, List<Claim>> GetAllAuthenticatedUserClaims(Http
759760
}
760761

761762
/// <summary>
762-
/// Helper method to substitute all the claimTypes(denoted with @claims.claimType) in
763-
/// the policy string with their corresponding claimValues.
763+
/// Replaces all claim references (denoted with @claims.claimType) in the policy
764+
/// with OData parameter aliases and returns their typed values separately.
765+
/// Claim values must never be inserted into URI text because URI parsing can decode
766+
/// percent-encoded syntax after string escaping has already occurred.
764767
/// </summary>
765768
/// <param name="policy">The policy to be processed.</param>
766769
/// <param name="claimsInRequestContext">Dictionary holding all the claims available in the request.</param>
767-
/// <returns>Processed policy with claim values substituted for claim types.</returns>
770+
/// <returns>Policy text containing aliases and the typed values bound to those aliases.</returns>
768771
/// <exception cref="DataApiBuilderException"></exception>
769-
private static string GetPolicyWithClaimValues(string policy, Dictionary<string, List<Claim>> claimsInRequestContext)
772+
private static ResolvedDatabasePolicy GetPolicyWithClaimValues(string policy, Dictionary<string, List<Claim>> claimsInRequestContext)
770773
{
771774
// Regex used to extract all claimTypes in policy. It finds all the substrings which are
772775
// of the form @claims.*** where *** contains characters from a-zA-Z0-9._ .
773776
string claimCharsRgx = @"@claims\.[a-zA-Z0-9_\.]*";
774777

775-
// Find all the claimTypes from the policy
778+
Dictionary<string, object?> claimValues = new();
779+
int claimIndex = 0;
780+
781+
// Replace claim references with inert OData aliases. The raw values remain out of
782+
// the policy URI and are later injected directly into the parsed AST.
776783
string processedPolicy = Regex.Replace(policy, claimCharsRgx,
777-
(claimTypeMatch) => GetClaimValueFromClaim(claimTypeMatch, claimsInRequestContext));
784+
(claimTypeMatch) =>
785+
{
786+
string claimAlias = $"@dabClaim{claimIndex++}";
787+
claimValues.Add(claimAlias, GetClaimValueFromClaim(claimTypeMatch, claimsInRequestContext));
788+
return claimAlias;
789+
});
778790

779791
// Remove occurrences of @item. directives
780792
processedPolicy = processedPolicy.Replace(FIELD_PREFIX, "");
781-
return processedPolicy;
793+
return new ResolvedDatabasePolicy(processedPolicy, claimValues);
782794
}
783795

784796
/// <summary>
785797
/// Helper function used to retrieve the claim value for the given claim type from the user's claims.
786798
/// </summary>
787799
/// <param name="claimTypeMatch">The claimType present in policy with a prefix of @claims..</param>
788800
/// <param name="claimsInRequestContext">Dictionary populated with all the user claims.</param>
789-
/// <returns>The claim value of the first claim whose claimType matches 'claimTypeMatch'.</returns>
801+
/// <returns>The typed value of the first claim whose claimType matches 'claimTypeMatch'.</returns>
790802
/// <exception cref="DataApiBuilderException"> Throws exception when the user does not possess the given claim.</exception>
791-
private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<string, List<Claim>> claimsInRequestContext)
803+
private static object? GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<string, List<Claim>> claimsInRequestContext)
792804
{
793805
// Gets <claimType> from @claims.<claimType>
794806
string claimType = claimTypeMatch.Value.ToString().Substring(CLAIM_PREFIX.Length);
@@ -815,13 +827,12 @@ private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<st
815827
}
816828

817829
/// <summary>
818-
/// Using the input parameter claim, returns the primitive literal from claim.Value:
819-
/// e.g. @claims.idp (string) resolves as 'azuread'
830+
/// Using the input parameter claim, returns the typed primitive value from claim.Value:
831+
/// e.g. @claims.idp (string) resolves as azuread
820832
/// e.g. @claims.iat (int) resolves as 1537231048
821833
/// e.g. @claims.email_verified (boolean) resolves as true
822-
/// To adhere with OData 4.01 ABNF construction rules (Section 7: Literal Data Values)
823-
/// - Primitive string literals in URLS must be enclosed within single quotes.
824-
/// - Other primitive types are represented as plain values and do not require single quotes.
834+
/// Values are returned as CLR primitives so the policy parser can bind them as typed
835+
/// OData AST constants without serializing them into URI text.
825836
/// Note: With many access token issuers, token claims are strings or string representations
826837
/// of other data types such as dates and GUIDs.
827838
/// Note: System.Security.Claim.ValueType defaults to ClaimValueTypes.String if the code calling
@@ -834,41 +845,91 @@ private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<st
834845
/// <seealso cref="https://www.iana.org/assignments/jwt/jwt.xhtml#claims"/>
835846
/// <seealso cref="https://www.rfc-editor.org/rfc/rfc7519.html#section-4"/>
836847
/// <seealso cref="https://github.com/microsoft/referencesource/blob/dae14279dd0672adead5de00ac8f117dcf74c184/mscorlib/system/security/claims/Claim.cs#L107"/>
837-
private static string GetClaimValue(Claim claim)
848+
private static object? GetClaimValue(Claim claim)
838849
{
839850
/* An example Claim object:
840851
* claim.Type: "user_email"
841852
* claim.Value: "authz@microsoft.com"
842853
* claim.ValueType: "http://www.w3.org/2001/XMLSchema#string"
843854
*/
844855

845-
switch (claim.ValueType)
846-
{
847-
case ClaimValueTypes.String:
848-
// Escape embedded single quotes per OData 4.01 ABNF (Section 7: Literal Data Values)
849-
// by doubling them. This prevents an attacker-influenced claim value from breaking
850-
// out of the string literal and injecting additional OData predicates into the
851-
// database authorization policy expression.
852-
// See: http://docs.oasis-open.org/odata/odata/v4.01/cs01/abnf/odata-abnf-construction-rules.txt
853-
return $"'{claim.Value.Replace("'", "''")}'";
854-
case ClaimValueTypes.Boolean:
855-
case ClaimValueTypes.Integer:
856-
case ClaimValueTypes.Integer32:
857-
case ClaimValueTypes.Integer64:
858-
case ClaimValueTypes.UInteger32:
859-
case ClaimValueTypes.UInteger64:
860-
case ClaimValueTypes.Double:
861-
return $"{claim.Value}";
862-
case JsonClaimValueTypes.JsonNull:
863-
return $"null";
864-
default:
865-
// One of the claims in the request had unsupported data type.
866-
throw new DataApiBuilderException(
867-
message: $"The claim value for claim: {claim.Type} belonging to the user has an unsupported data type.",
868-
statusCode: HttpStatusCode.Forbidden,
869-
subStatusCode: DataApiBuilderException.SubStatusCodes.UnsupportedClaimValueType
870-
);
856+
try
857+
{
858+
switch (claim.ValueType)
859+
{
860+
case ClaimValueTypes.String:
861+
return claim.Value;
862+
case ClaimValueTypes.Boolean:
863+
return bool.Parse(claim.Value);
864+
case ClaimValueTypes.Integer:
865+
return ParseIntegerClaimValue(claim.Value);
866+
case ClaimValueTypes.Integer32:
867+
return int.Parse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
868+
case ClaimValueTypes.Integer64:
869+
return long.Parse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
870+
case ClaimValueTypes.UInteger32:
871+
return (long)uint.Parse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
872+
case ClaimValueTypes.UInteger64:
873+
return (decimal)ulong.Parse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
874+
case ClaimValueTypes.Double:
875+
return ParseFiniteDoubleClaimValue(claim.Value);
876+
case JsonClaimValueTypes.JsonNull:
877+
return null;
878+
default:
879+
// One of the claims in the request had unsupported data type.
880+
throw CreateUnsupportedClaimValueException(claim);
881+
}
882+
}
883+
catch (Exception ex) when (ex is FormatException || ex is OverflowException)
884+
{
885+
throw CreateUnsupportedClaimValueException(claim, ex);
886+
}
887+
}
888+
889+
/// <summary>
890+
/// Parses a floating-point claim and rejects values that database providers cannot
891+
/// represent consistently, including NaN and positive or negative infinity.
892+
/// </summary>
893+
private static double ParseFiniteDoubleClaimValue(string value)
894+
{
895+
double parsedValue = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture);
896+
if (!double.IsFinite(parsedValue))
897+
{
898+
throw new FormatException("The floating-point claim value must be finite.");
899+
}
900+
901+
return parsedValue;
902+
}
903+
904+
private static DataApiBuilderException CreateUnsupportedClaimValueException(Claim claim, Exception? innerException = null)
905+
{
906+
string message = innerException is null
907+
? $"The claim value for claim: {claim.Type} belonging to the user has an unsupported data type."
908+
: $"The claim value for claim: {claim.Type} belonging to the user is invalid for its declared data type.";
909+
910+
return new DataApiBuilderException(
911+
message: message,
912+
statusCode: HttpStatusCode.Forbidden,
913+
subStatusCode: DataApiBuilderException.SubStatusCodes.UnsupportedClaimValueType,
914+
innerException: innerException);
915+
}
916+
917+
/// <summary>
918+
/// Parses an XML Schema integer claim into the narrowest OData-supported CLR integer type.
919+
/// </summary>
920+
private static object ParseIntegerClaimValue(string value)
921+
{
922+
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int intValue))
923+
{
924+
return intValue;
871925
}
926+
927+
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long longValue))
928+
{
929+
return longValue;
930+
}
931+
932+
return decimal.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
872933
}
873934

874935
/// <inheritdoc />

0 commit comments

Comments
 (0)