diff --git a/src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs b/src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs index 115d926661..66876e6456 100644 --- a/src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs +++ b/src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs @@ -6,6 +6,7 @@ using Azure.DataApiBuilder.Service.Parsers; using Azure.DataApiBuilder.Service.Resolvers; using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.OData; using Microsoft.OData.Edm; using Microsoft.OData.UriParser; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -99,6 +100,50 @@ public void VisitorLeftFieldGreaterThanRightNullFilterTest() ); } + [DataTestMethod] + // Constant on left side and OData EDM object on right side of binary operator. (L->R) + [DataRow("'1' eq int_types", false, DisplayName = "L->R: Cast token claim of type string to integer, left to right ")] + [DataRow("'13B4F4EC-C45B-46EC-99F2-77BC22A256A7' eq guid_types", false, DisplayName = "L->R: Cast token claim of type string to GUID")] + [DataRow("'true' eq boolean_types", false, DisplayName = "L->R: Cast token claim of type string to bool (true)")] + [DataRow("'false' eq boolean_types", false, DisplayName = "L->R: Cast token claim of type string to bool (false)")] + [DataRow("1 eq string_types", false, DisplayName = "L->R: Cast token claim of type int to string")] + [DataRow("true eq string_types", false, DisplayName = "L->R: Cast token claim of type bool to string")] + // Constant on right side and OData EDM object on left side of binary operator. (R->L) + [DataRow("int_types eq '1'", false, DisplayName = "R->L: Cast token claim of type string to integer")] + [DataRow("guid_types eq '13B4F4EC-C45B-46EC-99F2-77BC22A256A7'", false, DisplayName = "R->L: Cast token claim of type string to GUID")] + [DataRow("boolean_types eq 'true'", false, DisplayName = "R->L: Cast token claim of type string to bool (true)")] + [DataRow("boolean_types eq 'false'", false, DisplayName = "R->L: Cast token claim of type string to bool (false)")] + [DataRow("string_types eq 1", false, DisplayName = "R->L: Cast token claim of type int to string")] + [DataRow("string_types eq true", false, DisplayName = "R->L: Cast token claim of type bool to string")] + // Comparisons expected to fail due to inability to cast + [DataRow("boolean_types eq 2", true, DisplayName = "Fail to cast arbitrary int to bool")] + [DataRow("guid_types eq 1", true, DisplayName = "Fail to cast arbitrary int to GUID")] + [DataRow("guid_types eq 'stringConstant'", true, DisplayName = "Fail to cast arbitrary string to GUID")] + public void CustomODataUriParserResolverTest(string resolvedAuthZPolicyText, bool errorExpected) + { + // Arrange + string entityName = "SupportedType"; + string tableName = "type_table"; + string filterQueryString = "?$filter=" + resolvedAuthZPolicyText; + string expectedErrorMessageFragment = "A binary operator with incompatible types was detected."; + + //Act + Assert + try + { + FilterClause ast = _sqlMetadataProvider + .GetODataParser() + .GetFilterClause( + filterQueryString, + resourcePath: $"{entityName}.{DEFAULT_SCHEMA_NAME}.{tableName}", + customResolver: new ClaimsTypeDataUriResolver()); + Assert.IsFalse(errorExpected, message: "Filter clause creation was expected to fail."); + } + catch (Exception e) when (e is DataApiBuilderException || e is ODataException) + { + Assert.IsTrue(errorExpected, message: "Filter clause creation was not expected to fail."); + Assert.IsTrue(e.Message.Contains(expectedErrorMessageFragment)); + } + } #endregion #region Negative Tests /// diff --git a/src/Service/Parsers/ClaimsTypeDataUriResolver.cs b/src/Service/Parsers/ClaimsTypeDataUriResolver.cs new file mode 100644 index 0000000000..672635a940 --- /dev/null +++ b/src/Service/Parsers/ClaimsTypeDataUriResolver.cs @@ -0,0 +1,94 @@ +using System; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Azure.DataApiBuilder.Service.Parsers +{ + /// + /// Custom OData Resolver which attempts to assist with processing resolved token claims + /// within an authorization policy string that will be used to create an OData filter clause. + /// This resolver's type coercion is meant to be utilized for authorization policy processing + /// and NOT URL query string processing. + /// + /// + public class ClaimsTypeDataUriResolver : ODataUriResolver + { + /// + /// Between two nodes in the filter clause, determine the: + /// - PrimaryOperand: Node representing an OData EDM model object and has Kind == QueryNodeKind.SingleValuePropertyAccess. + /// - OperandToConvert: Node representing a constant value and has kind QueryNodeKind.Constant. + /// This resolver will overwrite the OperandToConvert node to a new ConstantNode where the value type is that of the PrimaryOperand node. + /// + /// the operator kind + /// the left operand + /// the right operand + /// 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()) + { + if ((leftNode.Kind == QueryNodeKind.SingleValuePropertyAccess) && (rightNode is ConstantNode)) + { + TryConvertNodeToTargetType( + targetType: leftNode.TypeReference.PrimitiveKind(), + operandToConvert: ref rightNode + ); + } + else if (rightNode.Kind == QueryNodeKind.SingleValuePropertyAccess && leftNode is ConstantNode) + { + TryConvertNodeToTargetType( + targetType: rightNode.TypeReference.PrimitiveKind(), + operandToConvert: ref leftNode + ); + } + } + + base.PromoteBinaryOperandTypes(binaryOperatorKind, ref leftNode, ref rightNode, out typeReference); + } + + /// + /// Uses type specific parsers to attempt converting the supplied node to a new ConstantNode of type targetType. + /// + /// Primitive type (string, bool, int, etc.) of the primary node's value. + /// Node representing a constant value which should be converted to a ConstantNode of type targetType. + private static void TryConvertNodeToTargetType(EdmPrimitiveTypeKind targetType, ref SingleValueNode operandToConvert) + { + ConstantNode? preConvertedConstant = operandToConvert as ConstantNode; + + if (preConvertedConstant?.Value is null) + { + return; + } + + if (targetType == EdmPrimitiveTypeKind.Int32) + { + if (int.TryParse(preConvertedConstant.Value.ToString(), out int result)) + { + operandToConvert = new ConstantNode(constantValue: result); + } + } + else if (targetType == EdmPrimitiveTypeKind.String) + { + string? objectValue = preConvertedConstant.Value.ToString(); + if (objectValue is not null) + { + operandToConvert = new ConstantNode(constantValue: objectValue); + } + } + else if (targetType == EdmPrimitiveTypeKind.Boolean) + { + if (bool.TryParse(preConvertedConstant.Value.ToString(), out bool result)) + { + operandToConvert = new ConstantNode(constantValue: result); + } + } + else if (targetType == EdmPrimitiveTypeKind.Guid) + { + if (Guid.TryParse(preConvertedConstant.Value.ToString(), out Guid result)) + { + operandToConvert = new ConstantNode(constantValue: result); + } + } + } + } +} diff --git a/src/Service/Parsers/EdmModelBuilder.cs b/src/Service/Parsers/EdmModelBuilder.cs index 9383ca4aef..ea07f87a7b 100644 --- a/src/Service/Parsers/EdmModelBuilder.cs +++ b/src/Service/Parsers/EdmModelBuilder.cs @@ -77,9 +77,11 @@ SourceDefinition sourceDefinition switch (columnSystemType.Name) { case "String": - case "Guid": type = EdmPrimitiveTypeKind.String; break; + case "Guid": + type = EdmPrimitiveTypeKind.Guid; + break; case "Byte": type = EdmPrimitiveTypeKind.Byte; break; diff --git a/src/Service/Parsers/FilterParser.cs b/src/Service/Parsers/FilterParser.cs index d3177a84f4..c800f0e15e 100644 --- a/src/Service/Parsers/FilterParser.cs +++ b/src/Service/Parsers/FilterParser.cs @@ -29,8 +29,9 @@ public void BuildModel(ISqlMetadataProvider sqlMetadataProvider) /// /// 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. /// An AST FilterClause that represents the filter portion of the WHERE clause. - public FilterClause GetFilterClause(string filterQueryString, string resourcePath) + public FilterClause GetFilterClause(string filterQueryString, string resourcePath, ODataUriResolver? customResolver = null) { if (_model == null) { @@ -45,6 +46,12 @@ public FilterClause GetFilterClause(string filterQueryString, string resourcePat { Uri relativeUri = new(resourcePath + '/' + filterQueryString, UriKind.Relative); ODataUriParser parser = new(_model!, relativeUri); + + if (customResolver is not null) + { + parser.Resolver = customResolver; + } + return parser.ParseFilter(); } catch (ODataException e) diff --git a/src/Service/Resolvers/AuthorizationPolicyHelpers.cs b/src/Service/Resolvers/AuthorizationPolicyHelpers.cs index 46417b4210..0761e47a05 100644 --- a/src/Service/Resolvers/AuthorizationPolicyHelpers.cs +++ b/src/Service/Resolvers/AuthorizationPolicyHelpers.cs @@ -86,7 +86,10 @@ public static void ProcessAuthorizationPolicies( // 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(dbPolicyClause, $"{entityName}.{resourcePath}"); + return sqlMetadataProvider.GetODataParser().GetFilterClause( + filterQueryString: dbPolicyClause, + resourcePath: $"{entityName}.{resourcePath}", + customResolver: new ClaimsTypeDataUriResolver()); } return null; diff --git a/src/Service/Services/RestService.cs b/src/Service/Services/RestService.cs index 51841ed808..fab1ec867b 100644 --- a/src/Service/Services/RestService.cs +++ b/src/Service/Services/RestService.cs @@ -171,7 +171,10 @@ RuntimeConfigProvider runtimeConfigProvider // Parse and save the values that are needed to later generate queries in the given RestRequestContext. // DbPolicyClause is an Abstract Syntax Tree representing the parsed policy text. - context.DbPolicyClause = _sqlMetadataProvider.GetODataParser().GetFilterClause(dbPolicy, $"{context.EntityName}.{context.DatabaseObject.FullName}"); + context.DbPolicyClause = _sqlMetadataProvider.GetODataParser().GetFilterClause( + filterQueryString: dbPolicy, + resourcePath: $"{context.EntityName}.{context.DatabaseObject.FullName}", + customResolver: new ClaimsTypeDataUriResolver()); } // At this point for DELETE, the primary key should be populated in the Request Context.