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
18 changes: 4 additions & 14 deletions src/Core/Models/GraphQLFilterParsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,7 @@ public static Predicate Parse(
else
{
op = PredicateOperation.LIKE;
value = $"%{EscapeLikeString((string)value)}%";
value = SqlLikePattern.Create(LikeMatch.Contains, (string)value);
lengthOverride = true;
}

Expand All @@ -760,19 +760,19 @@ public static Predicate Parse(
else
{
op = PredicateOperation.NOT_LIKE;
value = $"%{EscapeLikeString((string)value)}%";
value = SqlLikePattern.Create(LikeMatch.Contains, (string)value);
lengthOverride = true;
}

break;
case "startsWith":
op = PredicateOperation.LIKE;
value = $"{EscapeLikeString((string)value)}%";
value = SqlLikePattern.Create(LikeMatch.StartsWith, (string)value);
lengthOverride = true;
break;
case "endsWith":
op = PredicateOperation.LIKE;
value = $"%{EscapeLikeString((string)value)}";
value = SqlLikePattern.Create(LikeMatch.EndsWith, (string)value);
lengthOverride = true;
break;
case "isNull":
Expand Down Expand Up @@ -871,14 +871,4 @@ private static PredicateOperand GenerateRightOperand(

return new PredicateOperand(processLiteral ? $"{processLiterals(value, column.ColumnName, lengthOverride)}" : value.ToString());
}

private static string EscapeLikeString(string input)
{
input = input.Replace(@"\", @"\\");
input = input.Replace(@"%", @"\%");
input = input.Replace(@"[", @"\[");
input = input.Replace(@"]", @"\]");
input = input.Replace(@"_", @"\_");
return input;
}
}
24 changes: 24 additions & 0 deletions src/Core/Parsers/ODataASTCosmosVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public override string Visit(SingleValuePropertyAccessNode nodeIn)
/// <returns>String concatenation of (left op right).</returns>
public override string Visit(BinaryOperatorNode nodeIn)
{
ODataStringFunctionHelper.ThrowIfFunctionIsCompared(nodeIn);

string left = nodeIn.Left.Accept(this);
string right = nodeIn.Right.Accept(this);

Expand Down Expand Up @@ -113,6 +115,28 @@ public override string Visit(ConvertNode nodeIn)
return nodeIn.Source.Accept(this);
}

/// <summary>
/// Represents visiting a SingleValueFunctionCallNode for the string functions
/// contains, startswith and endswith, which map to the Cosmos DB functions of the same name.
/// </summary>
/// <param name="nodeIn">The node visited.</param>
/// <returns>String representing the function call.</returns>
public override string Visit(SingleValueFunctionCallNode nodeIn)
Comment thread
aaronburtle marked this conversation as resolved.
Resolved
{
(LikeMatch match, SingleValuePropertyAccessNode field, string value) = ODataStringFunctionHelper.GetFieldAndValue(nodeIn);
string cosmosFunction = match switch
{
LikeMatch.Contains => "CONTAINS",
LikeMatch.StartsWith => "STARTSWITH",
LikeMatch.EndsWith => "ENDSWITH",
_ => throw new NotSupportedException($"The function '{nodeIn.Name}' is not supported.")
};
string column = field.Accept(this);
string param = _queryStructure.MakeDbConnectionParam(value);

return $"{cosmosFunction}({column}, {param})";
}

/// <summary>
/// Represents visiting a ConstantNode, which is what
/// holds a value in the AST.
Expand Down
15 changes: 15 additions & 0 deletions src/Core/Parsers/ODataASTFieldVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,20 @@ public class ODataASTFieldVisitor : QueryNodeVisitor<object?>
{
return nodeIn.Source.Accept(this);
}

/// <summary>
/// Represents visiting a SingleValueFunctionCallNode, such as contains.
/// Calls accept on each argument to keep traversing AST.
/// </summary>
/// <param name="nodeIn">The node visited.</param>
public override object? Visit(SingleValueFunctionCallNode nodeIn)
{
foreach (QueryNode parameter in nodeIn.Parameters)
{
parameter.Accept(this);
}

return null;
}
}
}
37 changes: 30 additions & 7 deletions src/Core/Parsers/ODataASTVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public ODataASTVisitor(BaseSqlQueryStructure structure, ISqlMetadataProvider met
/// <returns>String concatenation of (left op right).</returns>
public override string Visit(BinaryOperatorNode nodeIn)
{
ODataStringFunctionHelper.ThrowIfFunctionIsCompared(nodeIn);
Comment thread
aaronburtle marked this conversation as resolved.
Resolved

// In order traversal but add parens to maintain order of logical operations
string left = nodeIn.Left.Accept(this);
string right = nodeIn.Right.Accept(this);
Expand Down Expand Up @@ -71,13 +73,7 @@ public override string Visit(UnaryOperatorNode nodeIn)
/// <returns>String representing the Field name</returns>
public override string Visit(SingleValuePropertyAccessNode nodeIn)
{
_metadataProvider.TryGetBackingColumn(_struct.EntityName, nodeIn.Property.Name, out string? backingColumnName);
if (_operation is EntityActionOperation.Create)
{
_struct.FieldsReferencedInDbPolicyForCreateAction.Add(backingColumnName!);
}

return _metadataProvider.GetQueryBuilder().QuoteIdentifier(backingColumnName!);
return _metadataProvider.GetQueryBuilder().QuoteIdentifier(GetBackingColumn(nodeIn));
}

/// <summary>
Expand Down Expand Up @@ -109,6 +105,33 @@ public override string Visit(ConvertNode nodeIn)
return nodeIn.Source.Accept(this);
}

/// <summary>
/// Represents visiting a SingleValueFunctionCallNode for the string functions
/// contains, startswith and endswith. These are translated to a LIKE predicate whose
/// pattern is a single parameter, with LIKE wildcards in the search value escaped.
/// </summary>
/// <param name="nodeIn">The node visited.</param>
/// <returns>String representing the LIKE predicate.</returns>
public override string Visit(SingleValueFunctionCallNode nodeIn)
Comment thread
aaronburtle marked this conversation as resolved.
Resolved
Comment thread
aaronburtle marked this conversation as resolved.
Resolved
{
(LikeMatch match, SingleValuePropertyAccessNode field, string value) = ODataStringFunctionHelper.GetFieldAndValue(nodeIn);
IQueryBuilder queryBuilder = _metadataProvider.GetQueryBuilder();
string backingColumnName = GetBackingColumn(field);
string param = _struct.MakeDbConnectionParam(SqlLikePattern.Create(match, value), backingColumnName, lengthOverride: true);
return $"({queryBuilder.BuildLikePredicate(queryBuilder.QuoteIdentifier(backingColumnName), param)})";
}

private string GetBackingColumn(SingleValuePropertyAccessNode nodeIn)
{
_metadataProvider.TryGetBackingColumn(_struct.EntityName, nodeIn.Property.Name, out string? backingColumnName);
if (_operation is EntityActionOperation.Create)
{
_struct.FieldsReferencedInDbPolicyForCreateAction.Add(backingColumnName!);
}

return backingColumnName!;
}

///<summary>
/// Gets the value of the parameter cast as the type the edm model associated to this parameter
///</summary>
Expand Down
103 changes: 103 additions & 0 deletions src/Core/Parsers/ODataStringFunctionHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.DataApiBuilder.Core.Resolvers;
using Microsoft.OData.UriParser;

namespace Azure.DataApiBuilder.Core.Parsers
{
/// <summary>
/// Shared handling for the OData string functions supported in $filter and database policies:
/// contains, startswith and endswith. The first argument must be a field and the second must
/// resolve to a non-empty string, either a constant (including a bound claim value) or a
/// concat of such values. Folding concat here keeps the whole search value in one parameter.
/// </summary>
internal static class ODataStringFunctionHelper
{
private const string CONTAINS = "contains";
private const string STARTSWITH = "startswith";
private const string ENDSWITH = "endswith";
private const string CONCAT = "concat";

/// <summary>
/// Validates a string function call and returns its match type, field and resolved search value.
/// </summary>
/// <param name="nodeIn">The function call node.</param>
/// <returns>The match type, the field being searched and the search value.</returns>
/// <exception cref="NotSupportedException">The function or its arguments are not supported.</exception>
public static (LikeMatch Match, SingleValuePropertyAccessNode Field, string Value) GetFieldAndValue(SingleValueFunctionCallNode nodeIn)
{
LikeMatch match = nodeIn.Name switch
{
CONTAINS => LikeMatch.Contains,
STARTSWITH => LikeMatch.StartsWith,
ENDSWITH => LikeMatch.EndsWith,
_ => throw new NotSupportedException($"The function '{nodeIn.Name}' is not supported.")
};

QueryNode[] arguments = nodeIn.Parameters.ToArray();
if (arguments.Length != 2 || Unwrap(arguments[0]) is not SingleValuePropertyAccessNode field)
{
throw new NotSupportedException($"The first argument of '{nodeIn.Name}' must be a field.");
}

string? value = FoldString(arguments[1]);
if (string.IsNullOrEmpty(value))
{
throw new NotSupportedException($"The second argument of '{nodeIn.Name}' must be a non-empty string value.");
}

return (match, field, value);
}

/// <summary>
/// String functions are only supported as conditions in their own right. Comparing their
/// result, as in <c>startswith(title, 'A') eq true</c>, is rejected rather than translated.
/// </summary>
/// <param name="nodeIn">The binary operator node.</param>
/// <exception cref="NotSupportedException">A function call is an operand of a comparison.</exception>
public static void ThrowIfFunctionIsCompared(BinaryOperatorNode nodeIn)
{
if (nodeIn.OperatorKind is not (BinaryOperatorKind.And or BinaryOperatorKind.Or) &&
(Unwrap(nodeIn.Left) is SingleValueFunctionCallNode || Unwrap(nodeIn.Right) is SingleValueFunctionCallNode))
{
throw new NotSupportedException("A function call cannot be an operand of a comparison; use it directly as a condition.");
}
}

/// <summary>
/// Resolves a node to a string when it is a string constant or a concat of string constants.
/// Returns null for anything else, including null values.
/// </summary>
private static string? FoldString(QueryNode node)
Comment thread
aaronburtle marked this conversation as resolved.
Resolved
{
switch (Unwrap(node))
{
case ConstantNode { Value: string value }:
return value;
case SingleValueFunctionCallNode { Name: CONCAT } concat:
QueryNode[] parts = concat.Parameters.ToArray();
if (parts.Length != 2)
{
return null;
}

string? left = FoldString(parts[0]);
string? right = FoldString(parts[1]);
return left is null || right is null ? null : left + right;
default:
return null;
}
}

private static QueryNode Unwrap(QueryNode node)
{
while (node is ConvertNode convertNode)
{
node = convertNode.Source;
}

return node;
}
}
}
10 changes: 10 additions & 0 deletions src/Core/Parsers/ParameterAliasRewriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ private SingleValueNode RewriteNode(SingleValueNode node, bool isPredicate)
ParameterAliasNode aliasNode => RewriteAlias(aliasNode, isPredicate),
ConstantNode constantNode => NormalizeBooleanPredicate(constantNode, isPredicate),
SingleValuePropertyAccessNode propertyNode => NormalizeBooleanPredicate(propertyNode, isPredicate),
SingleValueFunctionCallNode functionNode => new SingleValueFunctionCallNode(
functionNode.Name,
functionNode.Parameters
.Select(parameter => parameter is SingleValueNode valueNode
? RewriteNode(valueNode, isPredicate: false)
: throw new ODataException(
$"Database policy function argument '{parameter.Kind}' is not supported for typed claim binding."))
.ToList(),
functionNode.TypeReference),
_ => throw new ODataException(
$"Database policy expression node '{node.Kind}' is not supported for typed claim binding.")
};
Expand Down Expand Up @@ -129,6 +138,7 @@ private static bool IsPredicateExpression(SingleValueNode node)
{
BinaryOperatorNode => true,
UnaryOperatorNode => true,
SingleValueFunctionCallNode => true,
ConvertNode convertNode => IsPredicateExpression(convertNode.Source),
_ => false
};
Expand Down
57 changes: 46 additions & 11 deletions src/Core/Resolvers/AuthorizationPolicyHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net;
using System.Text.RegularExpressions;
using Azure.DataApiBuilder.Auth;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
using Azure.DataApiBuilder.Config.ObjectModel;
Expand All @@ -11,6 +13,7 @@
using Azure.DataApiBuilder.Service.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Microsoft.OData;
using Microsoft.OData.UriParser;

namespace Azure.DataApiBuilder.Core.Resolvers
Expand Down Expand Up @@ -105,17 +108,13 @@ public static void ProcessAuthorizationPolicies(
cosmosQueryStructure.TableCounter.Next();

fromClause = pathConfig.JoinStatement;
predicates = filterClause?.Expression.Accept(new ODataASTCosmosVisitor(
pathConfig.Alias,
cosmosQueryStructure));
predicates = GetCosmosPolicyPredicate(filterClause, pathConfig.Alias, cosmosQueryStructure);

existQuery = CosmosQueryBuilder.BuildExistsQueryForCosmos(fromClause, predicates);
}
else
{
predicates = filterClause?.Expression.Accept(new ODataASTCosmosVisitor(
GetCosmosPolicyPathPrefix(pathConfig),
cosmosQueryStructure));
predicates = GetCosmosPolicyPredicate(filterClause, GetCosmosPolicyPathPrefix(pathConfig), cosmosQueryStructure);
}

if (pathConfig.EntityName == entity.Key)
Expand All @@ -137,6 +136,25 @@ public static void ProcessAuthorizationPolicies(
}
}

/// <summary>
/// Translates a policy to a Cosmos DB predicate.
/// </summary>
internal static string GetCosmosPolicyPredicate(FilterClause filterClause, string prefix, BaseQueryStructure queryStructure)
{
try
{
return filterClause.Expression.Accept(new ODataASTCosmosVisitor(prefix, queryStructure));
}
catch (Exception ex)
{
throw new DataApiBuilderException(
message: "Policy query parameter is not well formed for Cosmos DB Policy Processing.",
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed,
innerException: ex);
}
}

/// <summary>
/// Builds the Cosmos document path used to qualify fields in a database policy.
/// Root model paths do not have a column segment and must not end with a dot.
Expand Down Expand Up @@ -230,16 +248,33 @@ private static List<FilterClause> ProcessFilter(

// 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(claimValueNodes),
parameterAliasNodes: claimValueNodes);
try
{
return sqlMetadataProvider.GetODataParser().GetFilterClause(
filterQueryString: dbPolicyClause,
resourcePath: resourcePath,
customResolver: new ClaimsTypeDataUriResolver(claimValueNodes),
parameterAliasNodes: claimValueNodes);
}
catch (DataApiBuilderException ex) when (ex.InnerException is ODataException && UsesStringFunctionWithNonStringClaim(dbPolicy))
{
throw new DataApiBuilderException(
message: "Claims used in contains, startswith, endswith or concat in a database policy must be strings.",
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed,
innerException: ex);
}
}

return null;
}

private static bool UsesStringFunctionWithNonStringClaim(ResolvedDatabasePolicy dbPolicy)
{
return Regex.IsMatch(dbPolicy.Policy, @"\b(contains|startswith|endswith|concat)\s*\(") &&
dbPolicy.ClaimValues.Values.Any(value => value is not null and not string);
}

/// <summary>
/// Resolves compound operations like Upsert,UpsertIncremental into the corresponding constituent elemental operations i.e. Create,Update.
/// For simple operations, returns the operation itself.
Expand Down
Loading
Loading