diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs
index 3a961ece4d..c990611f9a 100644
--- a/src/Auth/IAuthorizationResolver.cs
+++ b/src/Auth/IAuthorizationResolver.cs
@@ -72,16 +72,15 @@ public interface IAuthorizationResolver
public string GetDBPolicyForRequest(string entityName, string roleName, EntityActionOperation operation);
///
- /// Retrieves the policy of an operation within an entity's role entry
- /// within the permissions section of the runtime config, and tries to process
- /// the policy.
+ /// Resolves claim references in a database policy to parameter aliases and
+ /// returns their typed values separately from the policy text.
///
/// Entity from request.
/// Role defined in client role header.
/// Operation type: Create, Read, Update, Delete.
/// Contains token claims of the authenticated user used in policy evaluation.
- /// Returns the parsed policy, if successfully processed, or an exception otherwise.
- public string ProcessDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext);
+ /// The policy text and typed claim values to bind to it.
+ public ResolvedDatabasePolicy ResolveDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext);
///
/// Get list of roles defined for entity within runtime configuration.. This is applicable for GraphQL when creating authorization
diff --git a/src/Auth/ResolvedDatabasePolicy.cs b/src/Auth/ResolvedDatabasePolicy.cs
new file mode 100644
index 0000000000..75494b19f8
--- /dev/null
+++ b/src/Auth/ResolvedDatabasePolicy.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.ObjectModel;
+
+namespace Azure.DataApiBuilder.Auth;
+
+///
+/// A database authorization policy whose claim references have been replaced by
+/// OData parameter aliases. Claim values remain separate from the policy text so
+/// they can be injected into the parsed OData AST as typed constants.
+///
+public sealed record ResolvedDatabasePolicy
+{
+ ///
+ /// Policy text containing OData parameter aliases.
+ ///
+ public string Policy { get; }
+
+ ///
+ /// Immutable snapshot of typed claim values keyed by parameter alias.
+ ///
+ public IReadOnlyDictionary ClaimValues { get; }
+
+ ///
+ /// Initializes a resolved database policy and takes an immutable snapshot of its claim values.
+ ///
+ /// Policy text containing OData parameter aliases.
+ /// Typed claim values keyed by their parameter alias.
+ public ResolvedDatabasePolicy(string policy, IReadOnlyDictionary claimValues)
+ {
+ ArgumentNullException.ThrowIfNull(policy);
+ ArgumentNullException.ThrowIfNull(claimValues);
+
+ Policy = policy;
+ ClaimValues = new ReadOnlyDictionary(
+ new Dictionary(claimValues, StringComparer.Ordinal));
+ }
+
+ ///
+ /// Represents an operation without a database authorization policy.
+ ///
+ public static ResolvedDatabasePolicy Empty { get; } = new(
+ string.Empty,
+ new ReadOnlyDictionary(new Dictionary()));
+}
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index 205dc3d646..fd0da59393 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using System.Globalization;
using System.Net;
using System.Security.Claims;
using System.Text.Json;
@@ -205,13 +206,13 @@ public bool AreColumnsAllowedForOperation(string entityName, string roleName, En
}
///
- public string ProcessDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext)
+ public ResolvedDatabasePolicy ResolveDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext)
{
string dBpolicyWithClaimTypes = GetDBPolicyForRequest(entityName, roleName, operation);
if (string.IsNullOrWhiteSpace(dBpolicyWithClaimTypes))
{
- return string.Empty;
+ return ResolvedDatabasePolicy.Empty;
}
return GetPolicyWithClaimValues(dBpolicyWithClaimTypes, GetAllAuthenticatedUserClaims(httpContext));
@@ -759,26 +760,37 @@ public static Dictionary> GetAllAuthenticatedUserClaims(Http
}
///
- /// Helper method to substitute all the claimTypes(denoted with @claims.claimType) in
- /// the policy string with their corresponding claimValues.
+ /// Replaces all claim references (denoted with @claims.claimType) in the policy
+ /// with OData parameter aliases and returns their typed values separately.
+ /// Claim values must never be inserted into URI text because URI parsing can decode
+ /// percent-encoded syntax after string escaping has already occurred.
///
/// The policy to be processed.
/// Dictionary holding all the claims available in the request.
- /// Processed policy with claim values substituted for claim types.
+ /// Policy text containing aliases and the typed values bound to those aliases.
///
- private static string GetPolicyWithClaimValues(string policy, Dictionary> claimsInRequestContext)
+ private static ResolvedDatabasePolicy GetPolicyWithClaimValues(string policy, Dictionary> claimsInRequestContext)
{
// Regex used to extract all claimTypes in policy. It finds all the substrings which are
// of the form @claims.*** where *** contains characters from a-zA-Z0-9._ .
string claimCharsRgx = @"@claims\.[a-zA-Z0-9_\.]*";
- // Find all the claimTypes from the policy
+ Dictionary claimValues = new();
+ int claimIndex = 0;
+
+ // Replace claim references with inert OData aliases. The raw values remain out of
+ // the policy URI and are later injected directly into the parsed AST.
string processedPolicy = Regex.Replace(policy, claimCharsRgx,
- (claimTypeMatch) => GetClaimValueFromClaim(claimTypeMatch, claimsInRequestContext));
+ (claimTypeMatch) =>
+ {
+ string claimAlias = $"@dabClaim{claimIndex++}";
+ claimValues.Add(claimAlias, GetClaimValueFromClaim(claimTypeMatch, claimsInRequestContext));
+ return claimAlias;
+ });
// Remove occurrences of @item. directives
processedPolicy = processedPolicy.Replace(FIELD_PREFIX, "");
- return processedPolicy;
+ return new ResolvedDatabasePolicy(processedPolicy, claimValues);
}
///
@@ -786,9 +798,9 @@ private static string GetPolicyWithClaimValues(string policy, Dictionary
/// The claimType present in policy with a prefix of @claims..
/// Dictionary populated with all the user claims.
- /// The claim value of the first claim whose claimType matches 'claimTypeMatch'.
+ /// The typed value of the first claim whose claimType matches 'claimTypeMatch'.
/// Throws exception when the user does not possess the given claim.
- private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary> claimsInRequestContext)
+ private static object? GetClaimValueFromClaim(Match claimTypeMatch, Dictionary> claimsInRequestContext)
{
// Gets from @claims.
string claimType = claimTypeMatch.Value.ToString().Substring(CLAIM_PREFIX.Length);
@@ -815,13 +827,12 @@ private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary
- /// Using the input parameter claim, returns the primitive literal from claim.Value:
- /// e.g. @claims.idp (string) resolves as 'azuread'
+ /// Using the input parameter claim, returns the typed primitive value from claim.Value:
+ /// e.g. @claims.idp (string) resolves as azuread
/// e.g. @claims.iat (int) resolves as 1537231048
/// e.g. @claims.email_verified (boolean) resolves as true
- /// To adhere with OData 4.01 ABNF construction rules (Section 7: Literal Data Values)
- /// - Primitive string literals in URLS must be enclosed within single quotes.
- /// - Other primitive types are represented as plain values and do not require single quotes.
+ /// Values are returned as CLR primitives so the policy parser can bind them as typed
+ /// OData AST constants without serializing them into URI text.
/// Note: With many access token issuers, token claims are strings or string representations
/// of other data types such as dates and GUIDs.
/// Note: System.Security.Claim.ValueType defaults to ClaimValueTypes.String if the code calling
@@ -834,7 +845,7 @@ private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary
///
///
- private static string GetClaimValue(Claim claim)
+ private static object? GetClaimValue(Claim claim)
{
/* An example Claim object:
* claim.Type: "user_email"
@@ -842,33 +853,83 @@ private static string GetClaimValue(Claim claim)
* claim.ValueType: "http://www.w3.org/2001/XMLSchema#string"
*/
- switch (claim.ValueType)
- {
- case ClaimValueTypes.String:
- // Escape embedded single quotes per OData 4.01 ABNF (Section 7: Literal Data Values)
- // by doubling them. This prevents an attacker-influenced claim value from breaking
- // out of the string literal and injecting additional OData predicates into the
- // database authorization policy expression.
- // See: http://docs.oasis-open.org/odata/odata/v4.01/cs01/abnf/odata-abnf-construction-rules.txt
- return $"'{claim.Value.Replace("'", "''")}'";
- case ClaimValueTypes.Boolean:
- case ClaimValueTypes.Integer:
- case ClaimValueTypes.Integer32:
- case ClaimValueTypes.Integer64:
- case ClaimValueTypes.UInteger32:
- case ClaimValueTypes.UInteger64:
- case ClaimValueTypes.Double:
- return $"{claim.Value}";
- case JsonClaimValueTypes.JsonNull:
- return $"null";
- default:
- // One of the claims in the request had unsupported data type.
- throw new DataApiBuilderException(
- message: $"The claim value for claim: {claim.Type} belonging to the user has an unsupported data type.",
- statusCode: HttpStatusCode.Forbidden,
- subStatusCode: DataApiBuilderException.SubStatusCodes.UnsupportedClaimValueType
- );
+ try
+ {
+ switch (claim.ValueType)
+ {
+ case ClaimValueTypes.String:
+ return claim.Value;
+ case ClaimValueTypes.Boolean:
+ return bool.Parse(claim.Value);
+ case ClaimValueTypes.Integer:
+ return ParseIntegerClaimValue(claim.Value);
+ case ClaimValueTypes.Integer32:
+ return int.Parse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
+ case ClaimValueTypes.Integer64:
+ return long.Parse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
+ case ClaimValueTypes.UInteger32:
+ return (long)uint.Parse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
+ case ClaimValueTypes.UInteger64:
+ return (decimal)ulong.Parse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
+ case ClaimValueTypes.Double:
+ return ParseFiniteDoubleClaimValue(claim.Value);
+ case JsonClaimValueTypes.JsonNull:
+ return null;
+ default:
+ // One of the claims in the request had unsupported data type.
+ throw CreateUnsupportedClaimValueException(claim);
+ }
+ }
+ catch (Exception ex) when (ex is FormatException || ex is OverflowException)
+ {
+ throw CreateUnsupportedClaimValueException(claim, ex);
+ }
+ }
+
+ ///
+ /// Parses a floating-point claim and rejects values that database providers cannot
+ /// represent consistently, including NaN and positive or negative infinity.
+ ///
+ private static double ParseFiniteDoubleClaimValue(string value)
+ {
+ double parsedValue = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture);
+ if (!double.IsFinite(parsedValue))
+ {
+ throw new FormatException("The floating-point claim value must be finite.");
+ }
+
+ return parsedValue;
+ }
+
+ private static DataApiBuilderException CreateUnsupportedClaimValueException(Claim claim, Exception? innerException = null)
+ {
+ string message = innerException is null
+ ? $"The claim value for claim: {claim.Type} belonging to the user has an unsupported data type."
+ : $"The claim value for claim: {claim.Type} belonging to the user is invalid for its declared data type.";
+
+ return new DataApiBuilderException(
+ message: message,
+ statusCode: HttpStatusCode.Forbidden,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.UnsupportedClaimValueType,
+ innerException: innerException);
+ }
+
+ ///
+ /// Parses an XML Schema integer claim into the narrowest OData-supported CLR integer type.
+ ///
+ private static object ParseIntegerClaimValue(string value)
+ {
+ if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int intValue))
+ {
+ return intValue;
}
+
+ if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long longValue))
+ {
+ return longValue;
+ }
+
+ return decimal.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
}
///
diff --git a/src/Core/Parsers/ClaimsTypeDataUriResolver.cs b/src/Core/Parsers/ClaimsTypeDataUriResolver.cs
index c8ef174ecd..f8f213dfa2 100644
--- a/src/Core/Parsers/ClaimsTypeDataUriResolver.cs
+++ b/src/Core/Parsers/ClaimsTypeDataUriResolver.cs
@@ -15,6 +15,13 @@ namespace Azure.DataApiBuilder.Core.Parsers
///
public class ClaimsTypeDataUriResolver : ODataUriResolver
{
+ private readonly IReadOnlyDictionary _claimValueNodes;
+
+ public ClaimsTypeDataUriResolver(IReadOnlyDictionary? claimValueNodes = null)
+ {
+ _claimValueNodes = claimValueNodes ?? new Dictionary();
+ }
+
///
/// Between two nodes in the filter clause, determine the:
/// - PrimaryOperand: Node representing an OData EDM model object and has Kind == QueryNodeKind.SingleValuePropertyAccess.
@@ -27,19 +34,29 @@ public class ClaimsTypeDataUriResolver : ODataUriResolver
/// type reference for the result BinaryOperatorNode.
public override void PromoteBinaryOperandTypes(BinaryOperatorKind binaryOperatorKind, ref SingleValueNode leftNode, ref SingleValueNode rightNode, out IEdmTypeReference typeReference)
{
- if (leftNode.TypeReference.PrimitiveKind() != rightNode.TypeReference.PrimitiveKind())
+ ResolveClaimAlias(ref leftNode);
+ ResolveClaimAlias(ref rightNode);
+
+ EdmPrimitiveTypeKind? leftPrimitiveKind = leftNode.TypeReference?.PrimitiveKind();
+ EdmPrimitiveTypeKind? rightPrimitiveKind = rightNode.TypeReference?.PrimitiveKind();
+
+ if (leftPrimitiveKind != rightPrimitiveKind)
{
- if ((leftNode.Kind == QueryNodeKind.SingleValuePropertyAccess) && (rightNode is ConstantNode))
+ if (leftPrimitiveKind.HasValue &&
+ leftNode.Kind == QueryNodeKind.SingleValuePropertyAccess &&
+ rightNode is ConstantNode)
{
TryConvertNodeToTargetType(
- targetType: leftNode.TypeReference.PrimitiveKind(),
+ targetType: leftPrimitiveKind.Value,
operandToConvert: ref rightNode
);
}
- else if (rightNode.Kind == QueryNodeKind.SingleValuePropertyAccess && leftNode is ConstantNode)
+ else if (rightPrimitiveKind.HasValue &&
+ rightNode.Kind == QueryNodeKind.SingleValuePropertyAccess &&
+ leftNode is ConstantNode)
{
TryConvertNodeToTargetType(
- targetType: rightNode.TypeReference.PrimitiveKind(),
+ targetType: rightPrimitiveKind.Value,
operandToConvert: ref leftNode
);
}
@@ -48,6 +65,19 @@ public override void PromoteBinaryOperandTypes(BinaryOperatorKind binaryOperator
base.PromoteBinaryOperandTypes(binaryOperatorKind, ref leftNode, ref rightNode, out typeReference);
}
+ ///
+ /// Replaces a policy parameter alias with its typed claim constant before OData
+ /// performs type promotion. The claim value therefore never enters URI text.
+ ///
+ private void ResolveClaimAlias(ref SingleValueNode node)
+ {
+ if (node is ParameterAliasNode aliasNode &&
+ _claimValueNodes.TryGetValue(aliasNode.Alias, out SingleValueNode? claimValueNode))
+ {
+ node = claimValueNode;
+ }
+ }
+
///
/// Uses type specific parsers to attempt converting the supplied node to a new ConstantNode of type targetType
/// when the supplied node's type differs from the target's type.
diff --git a/src/Core/Parsers/FilterParser.cs b/src/Core/Parsers/FilterParser.cs
index c9cfc1eb53..e79e559ee5 100644
--- a/src/Core/Parsers/FilterParser.cs
+++ b/src/Core/Parsers/FilterParser.cs
@@ -39,8 +39,13 @@ public void BuildModel(DocumentNode graphQLSchemaRoot)
/// Represents the $filter part of the query string
/// Represents the resource path, in our case the entity name.
/// ODataUriResolver resolving different kinds of Uri parsing context.
+ /// Typed AST values for parameter aliases referenced by the filter.
/// An AST FilterClause that represents the filter portion of the WHERE clause.
- public FilterClause GetFilterClause(string filterQueryString, string resourcePath, ODataUriResolver? customResolver = null)
+ public FilterClause GetFilterClause(
+ string filterQueryString,
+ string resourcePath,
+ ODataUriResolver? customResolver = null,
+ IReadOnlyDictionary? parameterAliasNodes = null)
{
if (_model is null)
{
@@ -60,7 +65,18 @@ public FilterClause GetFilterClause(string filterQueryString, string resourcePat
parser.Resolver = customResolver;
}
- return parser.ParseFilter();
+ if (parameterAliasNodes is { Count: > 0 })
+ {
+ foreach ((string alias, SingleValueNode valueNode) in parameterAliasNodes)
+ {
+ parser.ParameterAliasNodes.Add(alias, valueNode);
+ }
+ }
+
+ FilterClause filterClause = parser.ParseFilter();
+ return parameterAliasNodes is not { Count: > 0 }
+ ? filterClause
+ : new ParameterAliasRewriter(parameterAliasNodes).Rewrite(filterClause);
}
catch (ODataException e)
{
diff --git a/src/Core/Parsers/ODataASTCosmosVisitor.cs b/src/Core/Parsers/ODataASTCosmosVisitor.cs
index 1fca34d624..1912972766 100644
--- a/src/Core/Parsers/ODataASTCosmosVisitor.cs
+++ b/src/Core/Parsers/ODataASTCosmosVisitor.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using Azure.DataApiBuilder.Core.Resolvers;
using Microsoft.OData.UriParser;
///
@@ -12,14 +13,17 @@ namespace Azure.DataApiBuilder.Core.Parsers
internal class ODataASTCosmosVisitor : QueryNodeVisitor
{
private string _prefix;
+ private readonly BaseQueryStructure _queryStructure;
///
/// Constructor for the visitor to append prefix to the column names which would be the path from container to the column
///
///
- public ODataASTCosmosVisitor(string prefix)
+ /// Stores bound Cosmos DB query parameters.
+ public ODataASTCosmosVisitor(string prefix, BaseQueryStructure queryStructure)
{
this._prefix = prefix;
+ _queryStructure = queryStructure;
}
///
@@ -123,7 +127,7 @@ public override string Visit(ConstantNode nodeIn)
return "NULL";
}
- return $"'{nodeIn.Value}'";
+ return _queryStructure.MakeDbConnectionParam(nodeIn.Value);
}
///
diff --git a/src/Core/Parsers/ParameterAliasRewriter.cs b/src/Core/Parsers/ParameterAliasRewriter.cs
new file mode 100644
index 0000000000..22595cb7bb
--- /dev/null
+++ b/src/Core/Parsers/ParameterAliasRewriter.cs
@@ -0,0 +1,104 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.OData;
+using Microsoft.OData.Edm;
+using Microsoft.OData.UriParser;
+
+namespace Azure.DataApiBuilder.Core.Parsers;
+
+///
+/// Replaces OData parameter aliases with their typed AST values after parsing.
+/// The resolver still supplies aliases during binary type promotion; this pass
+/// also covers aliases in unary and root Boolean expressions.
+///
+internal sealed class ParameterAliasRewriter
+{
+ private readonly IReadOnlyDictionary _parameterAliasNodes;
+
+ public ParameterAliasRewriter(IReadOnlyDictionary parameterAliasNodes)
+ {
+ _parameterAliasNodes = parameterAliasNodes;
+ }
+
+ ///
+ /// Rewrites all supported nodes in a filter clause and normalizes bare Boolean
+ /// values into comparisons that are valid SQL predicates across providers.
+ ///
+ public FilterClause Rewrite(FilterClause filterClause)
+ {
+ SingleValueNode expression = RewriteNode(filterClause.Expression, isPredicate: true);
+ return new FilterClause(expression, filterClause.RangeVariable);
+ }
+
+ private SingleValueNode RewriteNode(SingleValueNode node, bool isPredicate)
+ {
+ return node switch
+ {
+ BinaryOperatorNode binaryNode => RewriteBinaryOperator(binaryNode),
+ UnaryOperatorNode unaryNode => new UnaryOperatorNode(
+ unaryNode.OperatorKind,
+ RewriteNode(unaryNode.Operand, isPredicate: true)),
+ ConvertNode convertNode => NormalizeBooleanPredicate(
+ new ConvertNode(
+ RewriteNode(convertNode.Source, isPredicate: false),
+ convertNode.TypeReference),
+ isPredicate),
+ ParameterAliasNode aliasNode => RewriteAlias(aliasNode, isPredicate),
+ ConstantNode constantNode => NormalizeBooleanPredicate(constantNode, isPredicate),
+ SingleValuePropertyAccessNode propertyNode => NormalizeBooleanPredicate(propertyNode, isPredicate),
+ _ => throw new ODataException(
+ $"Database policy expression node '{node.Kind}' is not supported for typed claim binding.")
+ };
+ }
+
+ private SingleValueNode RewriteBinaryOperator(BinaryOperatorNode node)
+ {
+ bool operandsArePredicates = node.OperatorKind is BinaryOperatorKind.And or BinaryOperatorKind.Or;
+ return new BinaryOperatorNode(
+ node.OperatorKind,
+ RewriteNode(node.Left, operandsArePredicates),
+ RewriteNode(node.Right, operandsArePredicates));
+ }
+
+ private SingleValueNode RewriteAlias(ParameterAliasNode aliasNode, bool isPredicate)
+ {
+ if (!_parameterAliasNodes.TryGetValue(aliasNode.Alias, out SingleValueNode? valueNode))
+ {
+ throw new ODataException($"No value was supplied for database policy parameter alias '{aliasNode.Alias}'.");
+ }
+
+ return RewriteNode(valueNode, isPredicate);
+ }
+
+ private static SingleValueNode NormalizeBooleanPredicate(SingleValueNode node, bool isPredicate)
+ {
+ if (!isPredicate ||
+ node.TypeReference?.PrimitiveKind() is not EdmPrimitiveTypeKind.Boolean ||
+ IsPredicateExpression(node))
+ {
+ return node;
+ }
+
+ return new BinaryOperatorNode(
+ BinaryOperatorKind.Equal,
+ node,
+ new ConstantNode(true));
+ }
+
+ ///
+ /// Returns whether a Boolean node already represents a predicate rather than a bare value.
+ /// OData can wrap comparison predicates in one or more conversion nodes when binding logical
+ /// operators. Such predicates must not be rewritten as "predicate eq true", which is invalid SQL.
+ ///
+ private static bool IsPredicateExpression(SingleValueNode node)
+ {
+ return node switch
+ {
+ BinaryOperatorNode => true,
+ UnaryOperatorNode => true,
+ ConvertNode convertNode => IsPredicateExpression(convertNode.Source),
+ _ => false
+ };
+ }
+}
diff --git a/src/Core/Resolvers/AuthorizationPolicyHelpers.cs b/src/Core/Resolvers/AuthorizationPolicyHelpers.cs
index 58f4d1461d..f2efc35cf9 100644
--- a/src/Core/Resolvers/AuthorizationPolicyHelpers.cs
+++ b/src/Core/Resolvers/AuthorizationPolicyHelpers.cs
@@ -105,13 +105,17 @@ public static void ProcessAuthorizationPolicies(
cosmosQueryStructure.TableCounter.Next();
fromClause = pathConfig.JoinStatement;
- predicates = filterClause?.Expression.Accept(new ODataASTCosmosVisitor(pathConfig.Alias));
+ predicates = filterClause?.Expression.Accept(new ODataASTCosmosVisitor(
+ pathConfig.Alias,
+ cosmosQueryStructure));
existQuery = CosmosQueryBuilder.BuildExistsQueryForCosmos(fromClause, predicates);
}
else
{
- predicates = filterClause?.Expression.Accept(new ODataASTCosmosVisitor($"{pathConfig.Path}.{pathConfig.ColumnName}"));
+ predicates = filterClause?.Expression.Accept(new ODataASTCosmosVisitor(
+ $"{pathConfig.Path}.{pathConfig.ColumnName}",
+ cosmosQueryStructure));
}
if (pathConfig.EntityName == entity.Key)
@@ -160,11 +164,11 @@ private static List ProcessFilter(
List filterClauses = new();
foreach (EntityActionOperation elementalOperation in elementalOperations)
{
- string dbQueryPolicy = authorizationResolver.ProcessDBPolicy(
- entityName,
- clientRoleHeader,
- elementalOperation,
- context);
+ ResolvedDatabasePolicy dbQueryPolicy = authorizationResolver.ResolveDBPolicy(
+ entityName,
+ clientRoleHeader,
+ elementalOperation,
+ context);
FilterClause? filterClause = GetDBPolicyClauseForQueryStructure(
dbQueryPolicy,
@@ -179,31 +183,36 @@ private static List ProcessFilter(
}
///
- /// Given a dbPolicyClause string, appends the string formatting needed to be processed by ODataParser
+ /// Appends the filter query formatting to a resolved database policy and parses it with ODataParser.
///
- /// string representation of a processed database authorization policy.
+ /// Database authorization policy text and separately bound claim values.
/// Name of the entity.
/// Name of the schema. e.g. `dbo` for MsSql.
/// Provides helper method to process ODataFilterClause.
public static FilterClause? GetDBPolicyClauseForQueryStructure(
- string dbPolicyClause,
+ ResolvedDatabasePolicy dbPolicy,
string entityName,
string resourcePath,
ISqlMetadataProvider sqlMetadataProvider)
{
- if (!string.IsNullOrEmpty(dbPolicyClause))
+ if (!string.IsNullOrEmpty(dbPolicy.Policy))
{
+ Dictionary claimValueNodes = dbPolicy.ClaimValues.ToDictionary(
+ claimValue => claimValue.Key,
+ claimValue => (SingleValueNode)new ConstantNode(claimValue.Value));
+
// Since dbPolicy is nothing but filters to be added by virtue of database policy, we prefix it with
// ?$filter= so that it conforms with the format followed by other filter predicates.
// This enables the ODataVisitor helpers to parse the policy text properly.
- dbPolicyClause = $"?{RequestParser.FILTER_URL}={dbPolicyClause}";
+ string dbPolicyClause = $"?{RequestParser.FILTER_URL}={dbPolicy.Policy}";
// Parse and save the values that are needed to later generate SQL query predicates
// FilterClauseInDbPolicy is an Abstract Syntax Tree representing the parsed policy text.
return sqlMetadataProvider.GetODataParser().GetFilterClause(
filterQueryString: dbPolicyClause,
resourcePath: resourcePath,
- customResolver: new ClaimsTypeDataUriResolver());
+ customResolver: new ClaimsTypeDataUriResolver(claimValueNodes),
+ parameterAliasNodes: claimValueNodes);
}
return null;
diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs
index 29c435d955..897345260a 100644
--- a/src/Core/Resolvers/CosmosQueryStructure.cs
+++ b/src/Core/Resolvers/CosmosQueryStructure.cs
@@ -67,14 +67,6 @@ public CosmosQueryStructure(
Init(parameters);
}
- ///
- public override string MakeDbConnectionParam(object? value, string? columnName = null, bool lengthOverride = false)
- {
- string encodedParamName = $"{PARAM_NAME_PREFIX}param{Counter.Next()}";
- Parameters.Add(encodedParamName, new(value));
- return encodedParamName;
- }
-
private static IEnumerable GenerateQueryColumns(SelectionSetNode selectionSet, DocumentNode document, string tableName)
{
foreach (ISelectionNode selectionNode in selectionSet.Selections)
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index a40839f8e8..e5896cf6ce 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -1286,14 +1286,14 @@ public void AreColumnsAllowedForOperationWithRoleWithDifferentCasing(
/// The policy which is expected to be generated after parsing.
[DataTestMethod]
[DataRow("@claims.user_email ne @item.col1 and @claims.contact_no eq @item.col2 and not(@claims.name eq @item.col3)",
- "'xyz@microsoft.com' ne col1 and 1234 eq col2 and not('Aaron' eq col3)",
+ "@dabClaim0 ne col1 and @dabClaim1 eq col2 and not(@dabClaim2 eq col3)",
DisplayName = "Valid policy parsing test for string and int64 claimvaluetypes.")]
[DataRow("(@claims.isemployee eq @item.col1 and @item.col2 ne @claims.user_email) or" +
- "('David' ne @item.col3 and @claims.contact_no ne @item.col3)", "(true eq col1 and col2 ne 'xyz@microsoft.com') or" +
- "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetypes.")]
+ "('David' ne @item.col3 and @claims.contact_no ne @item.col3)", "(@dabClaim0 eq col1 and col2 ne @dabClaim1) or" +
+ "('David' ne col3 and @dabClaim2 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetypes.")]
[DataRow("(@item.rating gt @claims.emprating) and (@claims.isemployee eq true)",
- "(rating gt 4.2) and (true eq true)", DisplayName = "Valid policy parsing test for double and boolean claimvaluetypes.")]
- [DataRow("@item.rating eq @claims.emprating)", "rating eq 4.2)", DisplayName = "Valid policy parsing test for double claimvaluetype.")]
+ "(rating gt @dabClaim0) and (@dabClaim1 eq true)", DisplayName = "Valid policy parsing test for double and boolean claimvaluetypes.")]
+ [DataRow("@item.rating eq @claims.emprating)", "rating eq @dabClaim0)", DisplayName = "Valid policy parsing test for double claimvaluetype.")]
public void ParseValidDbPolicy(string policy, string expectedParsedPolicy)
{
RuntimeConfig runtimeConfig = InitRuntimeConfig(
@@ -1317,40 +1317,33 @@ public void ParseValidDbPolicy(string policy, string expectedParsedPolicy)
context.Setup(x => x.User).Returns(principal);
context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE);
- string parsedPolicy = authZResolver.ProcessDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
- Assert.AreEqual(parsedPolicy, expectedParsedPolicy);
+ ResolvedDatabasePolicy parsedPolicy = authZResolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
+ Assert.AreEqual(parsedPolicy.Policy, expectedParsedPolicy);
}
///
- /// Validates that single quote characters embedded in a string-typed claim value are
- /// escaped (doubled) per OData 4.01 ABNF when substituted into a database authorization
- /// policy. Without escaping, an attacker who can influence a referenced JWT claim could
- /// break out of the string literal and inject additional OData predicates - bypassing
- /// row-level authorization. The substituted claim must remain enclosed in a single
- /// string literal regardless of its contents.
+ /// Validates that a string claim is kept out of database authorization policy text and
+ /// associated with an inert OData parameter alias. This prevents claim contents from
+ /// being reinterpreted as policy syntax during URI parsing.
///
/// The raw claim value (as it appears in the JWT) to substitute.
- /// The parsed policy after safe substitution.
[DataTestMethod]
[DataRow(
"alice' or 1 eq 1 or '",
- "col1 eq 'alice'' or 1 eq 1 or '''",
- DisplayName = "Injection attempt with OR predicate is neutralized by escaping single quotes")]
+ DisplayName = "Literal quote injection remains outside policy text")]
[DataRow(
"O'Brien",
- "col1 eq 'O''Brien'",
- DisplayName = "Legitimate single-quote-bearing value (e.g. surname) is safely escaped")]
+ DisplayName = "Legitimate single-quote-bearing value remains unchanged")]
[DataRow(
- "''",
- "col1 eq ''''''",
- DisplayName = "Value composed solely of single quotes is fully escaped")]
+ "alice%27 or 1 eq 1 or %27",
+ DisplayName = "Encoded quote injection remains outside policy text")]
[DataRow(
- "no quotes here",
- "col1 eq 'no quotes here'",
- DisplayName = "Value without single quotes is unchanged aside from enclosing quotes")]
- public void DbPolicy_StringClaim_SingleQuotesEscaped_PreventsODataInjection(
- string claimValue,
- string expectedParsedPolicy)
+ "alice%2527 or 1 eq 1 or %2527",
+ DisplayName = "Double-encoded quote injection remains outside policy text")]
+ [DataRow(
+ "50% complete",
+ DisplayName = "Legitimate percent characters remain unchanged")]
+ public void DbPolicy_StringClaim_UsesTypedParameterAlias(string claimValue)
{
const string policyDefinition = "@item.col1 eq @claims.userId";
@@ -1370,9 +1363,10 @@ public void DbPolicy_StringClaim_SingleQuotesEscaped_PreventsODataInjection(
context.Setup(x => x.User).Returns(principal);
context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE);
- string parsedPolicy = authZResolver.ProcessDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
+ ResolvedDatabasePolicy parsedPolicy = authZResolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
- Assert.AreEqual(expectedParsedPolicy, parsedPolicy);
+ Assert.AreEqual("col1 eq @dabClaim0", parsedPolicy.Policy);
+ Assert.AreEqual(claimValue, parsedPolicy.ClaimValues["@dabClaim0"]);
}
///
@@ -1403,11 +1397,7 @@ public void DbPolicy_StringClaim_SingleQuotesEscaped_PreventsODataInjection(
#pragma warning restore format
public void DbPolicy_ClaimValueTypeParsing(string claimValueType, string claimValue, bool supportedValueType)
{
- // To adhere with OData 4 ABNF construction rules (Section 7: Literal Data Values)
- // - Primitive string literals in URLS must be enclosed within single quotes.
- // - http://docs.oasis-open.org/odata/odata/v4.01/cs01/abnf/odata-abnf-construction-rules.txt
- string odataClaimValue = (claimValueType == ClaimValueTypes.String) ? "'" + claimValue + "'" : claimValue;
- string expectedPolicy = odataClaimValue + " eq col1";
+ string expectedPolicy = "@dabClaim0 eq col1";
string policyDefinition = "@claims.testClaim eq @item.col1";
RuntimeConfig runtimeConfig = InitRuntimeConfig(
@@ -1431,9 +1421,21 @@ public void DbPolicy_ClaimValueTypeParsing(string claimValueType, string claimVa
try
{
- string parsedPolicy = authZResolver.ProcessDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
+ ResolvedDatabasePolicy parsedPolicy = authZResolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
Assert.IsTrue(supportedValueType);
- Assert.AreEqual(expectedPolicy, parsedPolicy);
+ Assert.AreEqual(expectedPolicy, parsedPolicy.Policy);
+
+ object? typedClaimValue = parsedPolicy.ClaimValues["@dabClaim0"];
+ if (claimValueType == JsonClaimValueTypes.JsonNull)
+ {
+ Assert.IsNull(typedClaimValue);
+ }
+ else
+ {
+ Assert.AreEqual(
+ claimValue.ToLowerInvariant(),
+ Convert.ToString(typedClaimValue, System.Globalization.CultureInfo.InvariantCulture)?.ToLowerInvariant());
+ }
}
catch (DataApiBuilderException ex)
{
@@ -1446,6 +1448,42 @@ public void DbPolicy_ClaimValueTypeParsing(string claimValueType, string claimVa
}
}
+ ///
+ /// A claim whose value does not match its declared primitive type must fail before
+ /// policy parsing and must never be interpreted as OData syntax.
+ ///
+ [DataTestMethod]
+ [DataRow(ClaimValueTypes.Integer, "1 or 1 eq 1", DisplayName = "Malformed integer")]
+ [DataRow(ClaimValueTypes.Double, "NaN", DisplayName = "NaN")]
+ [DataRow(ClaimValueTypes.Double, "Infinity", DisplayName = "Positive infinity")]
+ [DataRow(ClaimValueTypes.Double, "-Infinity", DisplayName = "Negative infinity")]
+ [DataRow(ClaimValueTypes.Double, "1e9999", DisplayName = "Exponent overflow")]
+ public void DbPolicy_InvalidPrimitiveClaim_FailsClosed(string claimValueType, string claimValue)
+ {
+ RuntimeConfig runtimeConfig = InitRuntimeConfig(
+ entityName: TEST_ENTITY,
+ roleName: TEST_ROLE,
+ operation: TEST_OPERATION,
+ includedCols: new HashSet { "col1" },
+ databasePolicy: "@claims.testClaim eq @item.col1");
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ Mock context = new();
+ ClaimsIdentity identity = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE);
+ identity.AddClaim(new Claim("testClaim", claimValue, claimValueType));
+ context.Setup(x => x.User).Returns(new ClaimsPrincipal(identity));
+ context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE);
+
+ DataApiBuilderException exception = Assert.ThrowsException(() =>
+ authZResolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object));
+
+ Assert.AreEqual(HttpStatusCode.Forbidden, exception.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.UnsupportedClaimValueType, exception.SubStatusCode);
+ Assert.AreEqual(
+ "The claim value for claim: testClaim belonging to the user is invalid for its declared data type.",
+ exception.Message);
+ }
+
///
/// Test to validate that we are correctly throwing an appropriate exception when the user request
/// lacks a claim required by the policy.
@@ -1479,7 +1517,7 @@ public void ParseInvalidDbPolicyWithUserNotPossessingAllClaims(string policy)
try
{
- authZResolver.ProcessDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
+ authZResolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
}
catch (DataApiBuilderException ex)
{
@@ -1531,16 +1569,18 @@ public void ParsePolicyWithDuplicateUserClaims()
context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE);
// Act
- string parsedPolicy = authZResolver.ProcessDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
+ ResolvedDatabasePolicy parsedPolicy = authZResolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
// Assert
- string expectedPolicy = $"'profile' eq col2 and '1111' eq col3";
- Assert.AreEqual(expected: expectedPolicy, actual: parsedPolicy);
+ string expectedPolicy = "@dabClaim0 eq col2 and @dabClaim1 eq col3";
+ Assert.AreEqual(expected: expectedPolicy, actual: parsedPolicy.Policy);
+ Assert.AreEqual("profile", parsedPolicy.ClaimValues["@dabClaim0"]);
+ Assert.AreEqual("1111", parsedPolicy.ClaimValues["@dabClaim1"]);
}
// Indirectly tests the AuthorizationResolver private method:
// GetDBPolicyForRequest(string entityName, string roleName, string operation)
- // by calling public method TryProcessDBPolicy(TEST_ENTITY, clientRole, requestOperation, context.Object)
+ // by calling public method ResolveDBPolicy(TEST_ENTITY, clientRole, requestOperation, context.Object)
// The result of executing that method will determine whether execution behaves as expected.
// When string.Empty is returned,
// then no policy is found for the provided entity, role, and operation combination, therefore,
@@ -1583,15 +1623,15 @@ public void GetDBPolicyTest(
context.Setup(x => x.User).Returns(principal);
context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(clientRole);
- string parsedPolicy = authZResolver.ProcessDBPolicy(TEST_ENTITY, clientRole, requestOperation, context.Object);
- string errorMessage = "TryProcessDBPolicy returned unexpected value.";
+ ResolvedDatabasePolicy parsedPolicy = authZResolver.ResolveDBPolicy(TEST_ENTITY, clientRole, requestOperation, context.Object);
+ string errorMessage = "ResolveDBPolicy returned unexpected value.";
if (expectPolicy)
{
- Assert.AreEqual(actual: parsedPolicy, expected: policy, message: errorMessage);
+ Assert.AreEqual(actual: parsedPolicy.Policy, expected: policy, message: errorMessage);
}
else
{
- Assert.AreEqual(actual: parsedPolicy, expected: string.Empty, message: errorMessage);
+ Assert.AreEqual(actual: parsedPolicy.Policy, expected: string.Empty, message: errorMessage);
}
}
diff --git a/src/Service.Tests/Authorization/REST/RestAuthorizationHandlerUnitTests.cs b/src/Service.Tests/Authorization/REST/RestAuthorizationHandlerUnitTests.cs
index d6e7d55bfe..8db19814d0 100644
--- a/src/Service.Tests/Authorization/REST/RestAuthorizationHandlerUnitTests.cs
+++ b/src/Service.Tests/Authorization/REST/RestAuthorizationHandlerUnitTests.cs
@@ -99,12 +99,13 @@ public void TestWildcardPolicyResolvesToEmpty(string httpMethod)
AuthorizationResolver authorizationResolver = SetupAuthResolverWithWildcardOperation();
HttpContext httpContext = CreateHttpContext(httpMethod: httpMethod, clientRole: "admin");
- Assert.AreEqual(expected: string.Empty, actual: authorizationResolver.ProcessDBPolicy(
+ ResolvedDatabasePolicy resolvedPolicy = authorizationResolver.ResolveDBPolicy(
entityName: AuthorizationHelpers.TEST_ENTITY,
roleName: "admin",
operation: RestService.HttpVerbToOperations(httpVerbName: httpMethod),
- httpContext: httpContext)
- );
+ httpContext: httpContext);
+
+ Assert.AreEqual(expected: string.Empty, actual: resolvedPolicy.Policy);
}
///
diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs
index 3bf6e37012..2bcb97a931 100644
--- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs
+++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs
@@ -717,6 +717,13 @@ private static Mock CreateMockSqlQueryStructure(string entity
.Returns(entityToDatabaseObject);
Mock mockMetadataProviderFactory = new();
Mock mockAuthorizationResolver = new();
+ mockAuthorizationResolver
+ .Setup(resolver => resolver.ResolveDBPolicy(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(ResolvedDatabasePolicy.Empty);
Mock mockRestRequestContext = new(
entityName,
new DatabaseTable());
@@ -817,6 +824,13 @@ private static SqlQueryEngine CreateQueryEngine(DabCacheService cache, string qu
Mock mockMetadataProviderFactory = new();
Mock mockHttpContextAccessor = new();
Mock mockAuthorizationResolver = new();
+ mockAuthorizationResolver
+ .Setup(resolver => resolver.ResolveDBPolicy(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(ResolvedDatabasePolicy.Empty);
Mock> mockLogger = new();
Mock mockRuntimeConfigProvider = CreateMockRuntimeConfigProvider(entityName);
Mock mockFilterParser = new(mockRuntimeConfigProvider.Object, mockMetadataProviderFactory.Object);
diff --git a/src/Service.Tests/UnitTests/DatabasePolicyClaimBindingUnitTests.cs b/src/Service.Tests/UnitTests/DatabasePolicyClaimBindingUnitTests.cs
new file mode 100644
index 0000000000..70d6622177
--- /dev/null
+++ b/src/Service.Tests/UnitTests/DatabasePolicyClaimBindingUnitTests.cs
@@ -0,0 +1,450 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+using System.Security.Claims;
+using Azure.DataApiBuilder.Auth;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Authorization;
+using Azure.DataApiBuilder.Core.Parsers;
+using Azure.DataApiBuilder.Core.Resolvers;
+using Azure.DataApiBuilder.Core.Services;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Azure.DataApiBuilder.Service.Tests.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.OData.UriParser;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Tests the complete database-policy claim binding path from an authenticated claim
+ /// through OData AST creation and SQL/Cosmos query parameter collection.
+ ///
+ [TestClass]
+ public class DatabasePolicyClaimBindingUnitTests
+ {
+ private const string ENTITY_NAME = AuthorizationHelpers.TEST_ENTITY;
+ private const string ROLE_NAME = AuthorizationHelpers.TEST_ROLE;
+ private const EntityActionOperation OPERATION = EntityActionOperation.Read;
+
+ ///
+ /// Verifies that encoded and literal syntax remains claim data throughout the complete
+ /// SQL and Cosmos policy pipelines.
+ ///
+ [DataTestMethod]
+ [DataRow("alice%27 or 1 eq 1 or %27", DisplayName = "Percent-encoded quote")]
+ [DataRow("alice%2527 or 1 eq 1 or %2527", DisplayName = "Double-encoded quote")]
+ [DataRow("alice%252527 or 1 eq 1 or %27", DisplayName = "Mixed nested encodings")]
+ [DataRow("alice' or 1 eq 1 or '", DisplayName = "Literal quote")]
+ [DataRow("50% complete", DisplayName = "Legitimate percent character")]
+ public void StringClaim_RemainsBoundParameterAcrossSqlAndCosmos(string claimValue)
+ {
+ const string policy = "@item.textCol eq @claims.value";
+ (AuthorizationResolver resolver, DefaultHttpContext context) = CreateAuthorizationContext(
+ policy,
+ new Claim("value", claimValue, ClaimValueTypes.String));
+ Mock metadataProvider = CreateMetadataProvider();
+
+ TestSqlQueryStructure sqlStructure = new(metadataProvider.Object, resolver);
+ AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(
+ OPERATION,
+ sqlStructure,
+ context,
+ resolver,
+ metadataProvider.Object);
+
+ Assert.AreEqual("([textCol] = @param0)", sqlStructure.GetDbPolicyForOperation(OPERATION));
+ AssertParameterValues(sqlStructure, claimValue);
+
+ FilterClause filterClause = ResolveFilterClause(resolver, context, metadataProvider.Object);
+ TestQueryStructure cosmosStructure = new(metadataProvider.Object, resolver);
+ string cosmosPredicate = filterClause.Expression.Accept(
+ new ODataASTCosmosVisitor("c", cosmosStructure));
+
+ Assert.AreEqual("(c.textCol = @param0)", cosmosPredicate);
+ AssertParameterValues(cosmosStructure, claimValue);
+ }
+
+ ///
+ /// Verifies aliases in root and unary Boolean positions are replaced throughout the AST
+ /// and generate executable, parameterized predicates for both SQL and Cosmos DB.
+ ///
+ [DataTestMethod]
+ [DataRow("@claims.value", "true", "(@param0 = @param1)", DisplayName = "Root Boolean claim")]
+ [DataRow("not @claims.value", "false", "(NOT (@param0 = @param1) )", DisplayName = "Unary Boolean claim")]
+ public void BooleanClaim_InRootOrUnaryPosition_IsResolvedAcrossSqlAndCosmos(
+ string policy,
+ string claimValue,
+ string expectedPredicate)
+ {
+ bool expectedValue = bool.Parse(claimValue);
+ (AuthorizationResolver resolver, DefaultHttpContext context) = CreateAuthorizationContext(
+ policy,
+ new Claim("value", claimValue, ClaimValueTypes.Boolean));
+ Mock metadataProvider = CreateMetadataProvider();
+
+ TestSqlQueryStructure sqlStructure = new(metadataProvider.Object, resolver);
+ AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(
+ OPERATION,
+ sqlStructure,
+ context,
+ resolver,
+ metadataProvider.Object);
+
+ Assert.AreEqual(expectedPredicate, sqlStructure.GetDbPolicyForOperation(OPERATION));
+ AssertParameterValues(sqlStructure, expectedValue, true);
+
+ FilterClause filterClause = ResolveFilterClause(resolver, context, metadataProvider.Object);
+ TestQueryStructure cosmosStructure = new(metadataProvider.Object, resolver);
+ string cosmosPredicate = filterClause.Expression.Accept(
+ new ODataASTCosmosVisitor("c", cosmosStructure));
+
+ Assert.AreEqual(expectedPredicate, cosmosPredicate);
+ AssertParameterValues(cosmosStructure, expectedValue, true);
+ }
+
+ ///
+ /// Verifies aliases remain resolved when Boolean predicates are nested under logical operators.
+ ///
+ [TestMethod]
+ public void BooleanClaims_InNestedLogicalExpression_AreResolvedAcrossSqlAndCosmos()
+ {
+ const string policy = "@claims.first and not @claims.second";
+ const string expectedPredicate = "((@param0 = @param1) AND (NOT (@param2 = @param3) ))";
+ (AuthorizationResolver resolver, DefaultHttpContext context) = CreateAuthorizationContext(
+ policy,
+ new Claim("first", "true", ClaimValueTypes.Boolean),
+ new Claim("second", "false", ClaimValueTypes.Boolean));
+ Mock metadataProvider = CreateMetadataProvider();
+
+ TestSqlQueryStructure sqlStructure = new(metadataProvider.Object, resolver);
+ AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(
+ OPERATION,
+ sqlStructure,
+ context,
+ resolver,
+ metadataProvider.Object);
+
+ Assert.AreEqual(expectedPredicate, sqlStructure.GetDbPolicyForOperation(OPERATION));
+ AssertParameterValues(sqlStructure, true, true, false, true);
+
+ FilterClause filterClause = ResolveFilterClause(resolver, context, metadataProvider.Object);
+ TestQueryStructure cosmosStructure = new(metadataProvider.Object, resolver);
+ string cosmosPredicate = filterClause.Expression.Accept(
+ new ODataASTCosmosVisitor("c", cosmosStructure));
+
+ Assert.AreEqual(expectedPredicate, cosmosPredicate);
+ AssertParameterValues(cosmosStructure, true, true, false, true);
+ }
+
+ ///
+ /// Verifies ordinary comparison predicates are not rewritten as comparisons to Boolean true.
+ ///
+ [TestMethod]
+ public void StaticComparisonPolicy_RemainsValidAcrossSqlAndCosmos()
+ {
+ const string policy = "@item.intCol ne 6 and @item.doubleCol gt 0";
+ const string expectedSqlPredicate = "(([intCol] != @param0) AND ([doubleCol] > @param1))";
+ const string expectedCosmosPredicate = "((c.intCol != @param0) AND (c.doubleCol > @param1))";
+ (AuthorizationResolver resolver, DefaultHttpContext context) = CreateAuthorizationContext(policy);
+ Mock metadataProvider = CreateMetadataProvider();
+
+ TestSqlQueryStructure sqlStructure = new(metadataProvider.Object, resolver);
+ AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(
+ OPERATION,
+ sqlStructure,
+ context,
+ resolver,
+ metadataProvider.Object);
+
+ Assert.AreEqual(expectedSqlPredicate, sqlStructure.GetDbPolicyForOperation(OPERATION));
+ AssertParameterValues(sqlStructure, 6, 0d);
+
+ FilterClause filterClause = ResolveFilterClause(resolver, context, metadataProvider.Object);
+ TestQueryStructure cosmosStructure = new(metadataProvider.Object, resolver);
+ string cosmosPredicate = filterClause.Expression.Accept(
+ new ODataASTCosmosVisitor("c", cosmosStructure));
+
+ Assert.AreEqual(expectedCosmosPredicate, cosmosPredicate);
+ AssertParameterValues(cosmosStructure, 6, 0d);
+ }
+
+ ///
+ /// Verifies a bare Boolean claim can be combined with an ordinary comparison without
+ /// rewriting the comparison predicate as "predicate equals true".
+ ///
+ [TestMethod]
+ public void BooleanClaim_CombinedWithComparison_OnlyNormalizesBareClaim()
+ {
+ const string policy = "@item.intCol ne 6 and @claims.allowed";
+ const string expectedSqlPredicate = "(([intCol] != @param0) AND (@param1 = @param2))";
+ const string expectedCosmosPredicate = "((c.intCol != @param0) AND (@param1 = @param2))";
+ (AuthorizationResolver resolver, DefaultHttpContext context) = CreateAuthorizationContext(
+ policy,
+ new Claim("allowed", "true", ClaimValueTypes.Boolean));
+ Mock metadataProvider = CreateMetadataProvider();
+
+ TestSqlQueryStructure sqlStructure = new(metadataProvider.Object, resolver);
+ AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(
+ OPERATION,
+ sqlStructure,
+ context,
+ resolver,
+ metadataProvider.Object);
+
+ Assert.AreEqual(expectedSqlPredicate, sqlStructure.GetDbPolicyForOperation(OPERATION));
+ AssertParameterValues(sqlStructure, 6, true, true);
+
+ FilterClause filterClause = ResolveFilterClause(resolver, context, metadataProvider.Object);
+ TestQueryStructure cosmosStructure = new(metadataProvider.Object, resolver);
+ string cosmosPredicate = filterClause.Expression.Accept(
+ new ODataASTCosmosVisitor("c", cosmosStructure));
+
+ Assert.AreEqual(expectedCosmosPredicate, cosmosPredicate);
+ AssertParameterValues(cosmosStructure, 6, true, true);
+ }
+
+ ///
+ /// Verifies string claims are promoted to the target column's numeric type before
+ /// SQL and Cosmos parameters are created.
+ ///
+ [TestMethod]
+ public void StringClaim_IsPromotedToNumericColumnType()
+ {
+ const string policy = "@item.intCol eq @claims.value";
+ (AuthorizationResolver resolver, DefaultHttpContext context) = CreateAuthorizationContext(
+ policy,
+ new Claim("value", "42", ClaimValueTypes.String));
+ Mock metadataProvider = CreateMetadataProvider();
+
+ TestSqlQueryStructure sqlStructure = new(metadataProvider.Object, resolver);
+ AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(
+ OPERATION,
+ sqlStructure,
+ context,
+ resolver,
+ metadataProvider.Object);
+
+ Assert.AreEqual("([intCol] = @param0)", sqlStructure.GetDbPolicyForOperation(OPERATION));
+ AssertParameterValues(sqlStructure, 42);
+ Assert.AreEqual(DbType.Int32, sqlStructure.Parameters["@param0"].DbType);
+
+ FilterClause filterClause = ResolveFilterClause(resolver, context, metadataProvider.Object);
+ TestQueryStructure cosmosStructure = new(metadataProvider.Object, resolver);
+ string cosmosPredicate = filterClause.Expression.Accept(
+ new ODataASTCosmosVisitor("c", cosmosStructure));
+
+ Assert.AreEqual("(c.intCol = @param0)", cosmosPredicate);
+ AssertParameterValues(cosmosStructure, 42);
+ }
+
+ ///
+ /// Verifies null claims remain typed null AST constants and do not create provider parameters.
+ ///
+ [TestMethod]
+ public void NullClaim_ProducesNullPredicateWithoutParameter()
+ {
+ const string policy = "@item.textCol eq @claims.value";
+ (AuthorizationResolver resolver, DefaultHttpContext context) = CreateAuthorizationContext(
+ policy,
+ new Claim("value", "null", JsonClaimValueTypes.JsonNull));
+ Mock metadataProvider = CreateMetadataProvider();
+
+ TestSqlQueryStructure sqlStructure = new(metadataProvider.Object, resolver);
+ AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(
+ OPERATION,
+ sqlStructure,
+ context,
+ resolver,
+ metadataProvider.Object);
+
+ Assert.AreEqual("([textCol] IS NULL)", sqlStructure.GetDbPolicyForOperation(OPERATION));
+ Assert.AreEqual(0, sqlStructure.Parameters.Count);
+
+ FilterClause filterClause = ResolveFilterClause(resolver, context, metadataProvider.Object);
+ TestQueryStructure cosmosStructure = new(metadataProvider.Object, resolver);
+ string cosmosPredicate = filterClause.Expression.Accept(
+ new ODataASTCosmosVisitor("c", cosmosStructure));
+
+ Assert.AreEqual("(c.textCol IS NULL)", cosmosPredicate);
+ Assert.AreEqual(0, cosmosStructure.Parameters.Count);
+ }
+
+ ///
+ /// Verifies non-finite floating-point claims fail before either query structure can
+ /// collect a provider parameter.
+ ///
+ [DataTestMethod]
+ [DataRow("NaN")]
+ [DataRow("Infinity")]
+ [DataRow("-Infinity")]
+ [DataRow("1e9999")]
+ public void NonFiniteDoubleClaim_FailsBeforeParameterCollection(string claimValue)
+ {
+ const string policy = "@item.doubleCol eq @claims.value";
+ (AuthorizationResolver resolver, DefaultHttpContext context) = CreateAuthorizationContext(
+ policy,
+ new Claim("value", claimValue, ClaimValueTypes.Double));
+ Mock metadataProvider = CreateMetadataProvider();
+ TestSqlQueryStructure sqlStructure = new(metadataProvider.Object, resolver);
+
+ DataApiBuilderException exception = Assert.ThrowsException(() =>
+ AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(
+ OPERATION,
+ sqlStructure,
+ context,
+ resolver,
+ metadataProvider.Object));
+
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.UnsupportedClaimValueType, exception.SubStatusCode);
+ Assert.AreEqual(0, sqlStructure.Parameters.Count);
+ }
+
+ ///
+ /// Verifies resolved policies own a read-only snapshot, including the shared empty value.
+ ///
+ [TestMethod]
+ public void ResolvedPolicyClaimValues_AreImmutableSnapshots()
+ {
+ Dictionary source = new() { ["@claim"] = "original" };
+ ResolvedDatabasePolicy policy = new("value eq @claim", source);
+ source["@claim"] = "modified";
+
+ Assert.AreEqual("original", policy.ClaimValues["@claim"]);
+ Assert.IsInstanceOfType>(ResolvedDatabasePolicy.Empty.ClaimValues);
+ IDictionary emptyValues = (IDictionary)ResolvedDatabasePolicy.Empty.ClaimValues;
+ Assert.ThrowsException(() => emptyValues.Add("@claim", "value"));
+ }
+
+ private static FilterClause ResolveFilterClause(
+ AuthorizationResolver resolver,
+ DefaultHttpContext context,
+ ISqlMetadataProvider metadataProvider)
+ {
+ ResolvedDatabasePolicy resolvedPolicy = resolver.ResolveDBPolicy(
+ ENTITY_NAME,
+ ROLE_NAME,
+ OPERATION,
+ context);
+
+ return AuthorizationPolicyHelpers.GetDBPolicyClauseForQueryStructure(
+ resolvedPolicy,
+ ENTITY_NAME,
+ $"{ENTITY_NAME}.{metadataProvider.EntityToDatabaseObject[ENTITY_NAME].FullName}",
+ metadataProvider)!;
+ }
+
+ private static (AuthorizationResolver Resolver, DefaultHttpContext Context) CreateAuthorizationContext(
+ string policy,
+ params Claim[] claims)
+ {
+ RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig(
+ entityName: ENTITY_NAME,
+ roleName: ROLE_NAME,
+ operation: OPERATION,
+ databasePolicy: policy);
+ AuthorizationResolver resolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ ClaimsIdentity identity = new(
+ claims,
+ authenticationType: "TestAuth",
+ nameType: ClaimTypes.Name,
+ roleType: ClaimTypes.Role);
+ DefaultHttpContext context = new()
+ {
+ User = new ClaimsPrincipal(identity)
+ };
+ context.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = ROLE_NAME;
+
+ return (resolver, context);
+ }
+
+ private static Mock CreateMetadataProvider()
+ {
+ SourceDefinition sourceDefinition = new();
+ sourceDefinition.Columns.Add("id", new ColumnDefinition(typeof(int)) { DbType = DbType.Int32 });
+ sourceDefinition.Columns.Add("flag", new ColumnDefinition(typeof(bool)) { DbType = DbType.Boolean });
+ sourceDefinition.Columns.Add("textCol", new ColumnDefinition(typeof(string)) { DbType = DbType.String });
+ sourceDefinition.Columns.Add("intCol", new ColumnDefinition(typeof(int)) { DbType = DbType.Int32 });
+ sourceDefinition.Columns.Add("doubleCol", new ColumnDefinition(typeof(double)) { DbType = DbType.Double });
+ sourceDefinition.PrimaryKey.Add("id");
+
+ DatabaseObject databaseObject = new DatabaseTable(schemaName: "dbo", tableName: "PolicyTable");
+ Dictionary entities = new()
+ {
+ [ENTITY_NAME] = databaseObject
+ };
+
+ Mock metadataProvider = new();
+ metadataProvider.SetupGet(provider => provider.EntityToDatabaseObject).Returns(entities);
+ metadataProvider.Setup(provider => provider.GetEntityNamesAndDbObjects()).Returns(entities);
+ metadataProvider.Setup(provider => provider.GetLinkingEntities())
+ .Returns(new Dictionary());
+ metadataProvider.Setup(provider => provider.GetSourceDefinition(ENTITY_NAME)).Returns(sourceDefinition);
+ metadataProvider.Setup(provider => provider.GetDatabaseType()).Returns(DatabaseType.MSSQL);
+ metadataProvider.Setup(provider => provider.GetQueryBuilder()).Returns(new MsSqlQueryBuilder());
+
+ string? exposedName;
+ metadataProvider
+ .Setup(provider => provider.TryGetExposedColumnName(It.IsAny(), It.IsAny(), out exposedName))
+ .Callback(new ColumnNameCallback((string _, string column, out string? name) => name = column))
+ .Returns(true);
+
+ string? backingName;
+ metadataProvider
+ .Setup(provider => provider.TryGetBackingColumn(It.IsAny(), It.IsAny(), out backingName))
+ .Callback(new ColumnNameCallback((string _, string column, out string? name) => name = column))
+ .Returns(true);
+
+ ODataParser parser = new();
+ parser.BuildModel(metadataProvider.Object);
+ metadataProvider.Setup(provider => provider.GetODataParser()).Returns(parser);
+ return metadataProvider;
+ }
+
+ private static void AssertParameterValues(BaseQueryStructure structure, params object?[] expectedValues)
+ {
+ object?[] actualValues = structure.Parameters.Values
+ .Select(parameter => parameter.Value)
+ .ToArray();
+ CollectionAssert.AreEqual(expectedValues, actualValues);
+ }
+
+ private delegate void ColumnNameCallback(string entity, string column, out string? name);
+
+ private sealed class TestSqlQueryStructure : BaseSqlQueryStructure
+ {
+ public TestSqlQueryStructure(
+ ISqlMetadataProvider metadataProvider,
+ IAuthorizationResolver authorizationResolver)
+ : base(
+ metadataProvider,
+ authorizationResolver,
+ gQLFilterParser: null!,
+ entityName: ENTITY_NAME)
+ {
+ }
+ }
+
+ private sealed class TestQueryStructure : BaseQueryStructure
+ {
+ public TestQueryStructure(
+ ISqlMetadataProvider metadataProvider,
+ IAuthorizationResolver authorizationResolver)
+ : base(
+ metadataProvider,
+ authorizationResolver,
+ gQLFilterParser: null!,
+ entityName: ENTITY_NAME)
+ {
+ }
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/DwSqlQueryBuilderUpsertTests.cs b/src/Service.Tests/UnitTests/DwSqlQueryBuilderUpsertTests.cs
index 3d70162832..ac5de6109a 100644
--- a/src/Service.Tests/UnitTests/DwSqlQueryBuilderUpsertTests.cs
+++ b/src/Service.Tests/UnitTests/DwSqlQueryBuilderUpsertTests.cs
@@ -139,12 +139,15 @@ private static SqlUpsertQueryStructure CreateUpsertStructure()
=> _columnMapping.TryGetValue(field, out column)))
.Returns((string entity, string field, string? column) => _columnMapping.ContainsKey(field));
- // The update policy is injected directly onto the structure, so the resolver only needs
- // to return an empty policy (no throw) during construction.
+ // The update policy is injected directly onto the structure after construction.
Mock authorizationResolver = new();
authorizationResolver
- .Setup(x => x.ProcessDBPolicy(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
- .Returns(string.Empty);
+ .Setup(x => x.ResolveDBPolicy(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(ResolvedDatabasePolicy.Empty);
RuntimeConfigProvider runtimeConfigProvider = TestHelper.GetRuntimeConfigProvider(TestHelper.GetRuntimeConfigLoader());
Mock metadataProviderFactory = new();