Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/Auth/IAuthorizationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,15 @@ public interface IAuthorizationResolver
public string GetDBPolicyForRequest(string entityName, string roleName, EntityActionOperation operation);

/// <summary>
/// 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.
/// </summary>
/// <param name="entityName">Entity from request.</param>
/// <param name="roleName">Role defined in client role header.</param>
/// <param name="operation">Operation type: Create, Read, Update, Delete.</param>
/// <param name="httpContext">Contains token claims of the authenticated user used in policy evaluation.</param>
/// <returns>Returns the parsed policy, if successfully processed, or an exception otherwise.</returns>
public string ProcessDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext);
/// <returns>The policy text and typed claim values to bind to it.</returns>
public ResolvedDatabasePolicy ResolveDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext);

/// <summary>
/// Get list of roles defined for entity within runtime configuration.. This is applicable for GraphQL when creating authorization
Expand Down
46 changes: 46 additions & 0 deletions src/Auth/ResolvedDatabasePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.ObjectModel;

namespace Azure.DataApiBuilder.Auth;

/// <summary>
/// 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.
/// </summary>
public sealed record ResolvedDatabasePolicy
{
/// <summary>
/// Policy text containing OData parameter aliases.
/// </summary>
public string Policy { get; }

/// <summary>
/// Immutable snapshot of typed claim values keyed by parameter alias.
/// </summary>
public IReadOnlyDictionary<string, object?> ClaimValues { get; }

/// <summary>
/// Initializes a resolved database policy and takes an immutable snapshot of its claim values.
/// </summary>
/// <param name="policy">Policy text containing OData parameter aliases.</param>
/// <param name="claimValues">Typed claim values keyed by their parameter alias.</param>
public ResolvedDatabasePolicy(string policy, IReadOnlyDictionary<string, object?> claimValues)
Comment thread
aaronburtle marked this conversation as resolved.
{
ArgumentNullException.ThrowIfNull(policy);
ArgumentNullException.ThrowIfNull(claimValues);

Policy = policy;
ClaimValues = new ReadOnlyDictionary<string, object?>(
new Dictionary<string, object?>(claimValues, StringComparer.Ordinal));
}

/// <summary>
/// Represents an operation without a database authorization policy.
/// </summary>
public static ResolvedDatabasePolicy Empty { get; } = new(
Comment thread
aaronburtle marked this conversation as resolved.
string.Empty,
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>()));
}
147 changes: 104 additions & 43 deletions src/Core/Authorization/AuthorizationResolver.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -205,13 +206,13 @@ public bool AreColumnsAllowedForOperation(string entityName, string roleName, En
}

/// <inheritdoc />
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));
Expand Down Expand Up @@ -759,36 +760,47 @@ public static Dictionary<string, List<Claim>> GetAllAuthenticatedUserClaims(Http
}

/// <summary>
/// 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.
/// </summary>
/// <param name="policy">The policy to be processed.</param>
/// <param name="claimsInRequestContext">Dictionary holding all the claims available in the request.</param>
/// <returns>Processed policy with claim values substituted for claim types.</returns>
/// <returns>Policy text containing aliases and the typed values bound to those aliases.</returns>
/// <exception cref="DataApiBuilderException"></exception>
private static string GetPolicyWithClaimValues(string policy, Dictionary<string, List<Claim>> claimsInRequestContext)
private static ResolvedDatabasePolicy GetPolicyWithClaimValues(string policy, Dictionary<string, List<Claim>> 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<string, object?> 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);
}

/// <summary>
/// Helper function used to retrieve the claim value for the given claim type from the user's claims.
/// </summary>
/// <param name="claimTypeMatch">The claimType present in policy with a prefix of @claims..</param>
/// <param name="claimsInRequestContext">Dictionary populated with all the user claims.</param>
/// <returns>The claim value of the first claim whose claimType matches 'claimTypeMatch'.</returns>
/// <returns>The typed value of the first claim whose claimType matches 'claimTypeMatch'.</returns>
/// <exception cref="DataApiBuilderException"> Throws exception when the user does not possess the given claim.</exception>
private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<string, List<Claim>> claimsInRequestContext)
private static object? GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<string, List<Claim>> claimsInRequestContext)
Comment thread
aaronburtle marked this conversation as resolved.
{
// Gets <claimType> from @claims.<claimType>
string claimType = claimTypeMatch.Value.ToString().Substring(CLAIM_PREFIX.Length);
Expand All @@ -815,13 +827,12 @@ private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<st
}

/// <summary>
/// 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
Expand All @@ -834,41 +845,91 @@ private static string GetClaimValueFromClaim(Match claimTypeMatch, Dictionary<st
/// <seealso cref="https://www.iana.org/assignments/jwt/jwt.xhtml#claims"/>
/// <seealso cref="https://www.rfc-editor.org/rfc/rfc7519.html#section-4"/>
/// <seealso cref="https://github.com/microsoft/referencesource/blob/dae14279dd0672adead5de00ac8f117dcf74c184/mscorlib/system/security/claims/Claim.cs#L107"/>
private static string GetClaimValue(Claim claim)
private static object? GetClaimValue(Claim claim)
{
/* An example Claim object:
* claim.Type: "user_email"
* claim.Value: "authz@microsoft.com"
* 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)
Comment thread
aaronburtle marked this conversation as resolved.
{
throw CreateUnsupportedClaimValueException(claim, ex);
}
}

/// <summary>
/// Parses a floating-point claim and rejects values that database providers cannot
/// represent consistently, including NaN and positive or negative infinity.
/// </summary>
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);
Comment thread
aaronburtle marked this conversation as resolved.
}

/// <summary>
/// Parses an XML Schema integer claim into the narrowest OData-supported CLR integer type.
/// </summary>
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);
}

/// <inheritdoc />
Expand Down
40 changes: 35 additions & 5 deletions src/Core/Parsers/ClaimsTypeDataUriResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ namespace Azure.DataApiBuilder.Core.Parsers
/// <seealso cref="https://devblogs.microsoft.com/odata/tutorial-sample-odatauriparser-extension-support/#write-customized-extensions-from-scratch"/>
public class ClaimsTypeDataUriResolver : ODataUriResolver
{
private readonly IReadOnlyDictionary<string, SingleValueNode> _claimValueNodes;

public ClaimsTypeDataUriResolver(IReadOnlyDictionary<string, SingleValueNode>? claimValueNodes = null)
{
_claimValueNodes = claimValueNodes ?? new Dictionary<string, SingleValueNode>();
}

/// <summary>
/// Between two nodes in the filter clause, determine the:
/// - PrimaryOperand: Node representing an OData EDM model object and has Kind == QueryNodeKind.SingleValuePropertyAccess.
Expand All @@ -27,19 +34,29 @@ public class ClaimsTypeDataUriResolver : ODataUriResolver
/// <param name="typeReference">type reference for the result BinaryOperatorNode.</param>
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
);
}
Expand All @@ -48,6 +65,19 @@ public override void PromoteBinaryOperandTypes(BinaryOperatorKind binaryOperator
base.PromoteBinaryOperandTypes(binaryOperatorKind, ref leftNode, ref rightNode, out typeReference);
}

/// <summary>
/// Replaces a policy parameter alias with its typed claim constant before OData
/// performs type promotion. The claim value therefore never enters URI text.
/// </summary>
private void ResolveClaimAlias(ref SingleValueNode node)
{
if (node is ParameterAliasNode aliasNode &&
_claimValueNodes.TryGetValue(aliasNode.Alias, out SingleValueNode? claimValueNode))
{
node = claimValueNode;
}
}

/// <summary>
/// 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.
Expand Down
Loading