diff --git a/config-generators/cosmosdb_nosql-commands.txt b/config-generators/cosmosdb_nosql-commands.txt index a04795671a..42e7a6330d 100644 --- a/config-generators/cosmosdb_nosql-commands.txt +++ b/config-generators/cosmosdb_nosql-commands.txt @@ -1,33 +1,48 @@ init --config "dab-config.CosmosDb_NoSql.json" --database-type "cosmosdb_nosql" --connection-string "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" --cosmosdb_nosql-database "graphqldb" --cosmosdb_nosql-container "planet" --graphql-schema "schema.gql" --host-mode Development --cors-origin "http://localhost:5000" -add Planet --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Planet:Planets" -update Planet --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "*" -update Planet --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete" -update Planet --config "dab-config.CosmosDb_NoSql.json" --permissions "limited-read-role:read" +add PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Planet:Planets" +update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "*" +update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete" +update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "limited-read-role:read" +update PlanetAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "item-level-permission-role:read" add Character --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Character:Characters" -add StarAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Star:Stars" -update StarAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "authenticated:create,read,update,delete" -add TagAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Tag:Tags" +update Character --config "dab-config.CosmosDb_NoSql.json" --permissions "item-level-permission-role:read" +add Star --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Star:Stars" +update Star --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "authenticated:create,read,update,delete" +add Tag --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Tag:Tags" add Moon --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql true update Moon --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "authenticated:create,read,update,delete" add Earth --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "field-mutation-with-read-permission:read" --graphql "Earth:Earths" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "field-mutation-with-read-permission:create" --fields.include "id" --fields.exclude "name" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "field-mutation-with-read-permission:delete" --fields.include "id,type" --fields.exclude "name" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "field-mutation-with-read-permission:update" --fields.include "id,type" --fields.exclude "name" +update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:create,update,delete" +update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "*" --fields.exclude "*" update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete" update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "limited-read-role:read" --fields.include "id,type" --fields.exclude "name" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "wildcard-exclude-fields-role:create" --fields.exclude "*" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "wildcard-exclude-fields-role:update" --fields.exclude "*" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "wildcard-exclude-fields-role:delete" --fields.exclude "*" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "wildcard-exclude-fields-role:read" --fields.exclude "*" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "only-create-role:create" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "only-update-role:update" -update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "only-delete-role:delete" +update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "item-level-permission-role:read" --policy-database "@item.type eq 'earth0'" add Sun --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,update,delete" --graphql true update Sun --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "*" --fields.exclude "name" add AdditionalAttribute --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:*" --graphql "AdditionalAttribute:AdditionalAttributes" +update AdditionalAttribute --config "dab-config.CosmosDb_NoSql.json" --permissions "item-level-permission-role:read" --policy-database "@item.name eq 'volcano0'" add MoonAdditionalAttribute --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:*" --graphql "MoonAdditionalAttribute:MoonAdditionalAttributes" -add MoreAttrAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:delete" --graphql "MoreAttribute:MoreAttributes" -update MoreAttrAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:create" --fields.include "id" --fields.exclude "name" -update MoreAttrAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "id" --fields.exclude "name" -update MoreAttrAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:update" --fields.exclude "*" -update MoreAttrAlias --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete" +update MoonAdditionalAttribute --config "dab-config.CosmosDb_NoSql.json" --permissions "item-level-permission-role:read" --policy-database "@item.name eq 'moonattr0'" +add MoreAttribute --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:delete" --graphql "MoreAttribute:MoreAttributes" +update MoreAttribute --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:create" --fields.include "id" --fields.exclude "name" +update MoreAttribute --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "id" --fields.exclude "name" +update MoreAttribute --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:update" --fields.exclude "*" +update MoreAttribute --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete" +add PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.newcontainer" --permissions "field-mutation-with-read-permission:read" --graphql "PlanetAgain:PlanetAgains" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "field-mutation-with-read-permission:create" --fields.include "id" --fields.exclude "name" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "field-mutation-with-read-permission:delete" --fields.include "id,type" --fields.exclude "name" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "field-mutation-with-read-permission:update" --fields.include "id,type" --fields.exclude "name" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "limited-read-role:read" --fields.include "id,type" --fields.exclude "name" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "wildcard-exclude-fields-role:create" --fields.exclude "*" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "wildcard-exclude-fields-role:update" --fields.exclude "*" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "wildcard-exclude-fields-role:delete" --fields.exclude "*" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "wildcard-exclude-fields-role:read" --fields.exclude "*" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "only-create-role:create" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "only-update-role:update" +update PlanetAgain --config "dab-config.CosmosDb_NoSql.json" --permissions "only-delete-role:delete" +add InvalidAuthModel --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.invalidAuthModelContainer" --permissions "anonymous:delete" --graphql "InvalidAuthModel:InvalidAuthModels" +update InvalidAuthModel --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:create" --fields.include "id" --fields.exclude "name" +update InvalidAuthModel --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "id" --fields.exclude "name" +update InvalidAuthModel --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:update" --fields.exclude "*" +update InvalidAuthModel --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete" diff --git a/src/Config/ObjectModel/EntityDbPolicyCosmosModel.cs b/src/Config/ObjectModel/EntityDbPolicyCosmosModel.cs new file mode 100644 index 0000000000..42ca0de8dc --- /dev/null +++ b/src/Config/ObjectModel/EntityDbPolicyCosmosModel.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Config.ObjectModel +{ + /// + /// It contains Entity information along with Pre-Generated JOIN statements for all the entities using configured DB policy. + /// So that, it can be used for generating CosmosDB SQL queries. + /// + public record EntityDbPolicyCosmosModel + { + /// + /// Path to the given entity with "." delimiter, it will be used as prefix while generating conditions for CosmosDB SQL queries. + /// e.g. c.stars, c.earth.type + /// + public string Path { get; } + + /// + /// Column name representation of the entity + /// + public string? ColumnName { get; } + + /// + /// If entity is of array type then we would have a generated alias of the entity + /// e.g table0, table1 etc + /// + public string? Alias { get; } + + /// + /// Entity Name + /// + public string? EntityName { get; } + + /// + /// Pre-generated (If define DB policies are available and entity type is array) JOIN statement for the entity + /// + public string? JoinStatement { get; set; } + + public EntityDbPolicyCosmosModel(string Path, string? EntityName, string? ColumnName = null, string? Alias = null) + { + this.Path = Path; + this.ColumnName = ColumnName; + this.Alias = Alias; + this.EntityName = EntityName; + + // Generate JOIN statement only when Alias is there + if (!string.IsNullOrEmpty(Alias)) + { + this.JoinStatement = $" {Alias} IN {Path}.{ColumnName}"; + } + } + } +} diff --git a/src/Core/Models/GraphQLFilterParsers.cs b/src/Core/Models/GraphQLFilterParsers.cs index 1e07c20481..b0a7df7ee4 100644 --- a/src/Core/Models/GraphQLFilterParsers.cs +++ b/src/Core/Models/GraphQLFilterParsers.cs @@ -16,7 +16,6 @@ using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; using static Azure.DataApiBuilder.Core.Authorization.AuthorizationResolver; -using static Azure.DataApiBuilder.Core.Resolvers.CosmosQueryStructure; namespace Azure.DataApiBuilder.Core.Models; @@ -272,12 +271,12 @@ public Predicate Parse( } /// - /// For CosmosDB, a nested filter represents a join between - /// the parent entity being filtered and the related entity representing the - /// non-scalar filter input. This function: - /// 1. Recursively parses any more(possibly nested) filters. - /// 2. Adds join predicates between the related entities. - /// 3. Adds the subquery to the existing list of predicates. + /// For CosmosDB, a nested filter represents an EXISTS clause with a subquery. + /// This function: + /// 1. Defines the Exists Query structure + /// 2. Recursively parses any more(possibly nested) filters on the Exists sub query. + /// 3. Adds join predicates between the related entities to the Exists sub query. + /// 4. Adds the Exists subquery to the existing list of predicates. /// /// The middleware context. /// The nested filter field. @@ -285,7 +284,9 @@ public Predicate Parse( /// Current Column Name /// Current Entity Type /// The predicates parsed so far. - /// The query structure of the entity being filtered. + /// The query structure of the entity being filtered, it would be modified to contain EXIST predicates + /// Cosmos Metadata Provider, to get metadata information for a given entity + /// private void HandleNestedFilterForCosmos( IMiddlewareContext ctx, IInputField filterField, @@ -296,28 +297,9 @@ private void HandleNestedFilterForCosmos( CosmosQueryStructure queryStructure, ISqlMetadataProvider metadataProvider) { - string entityName = metadataProvider.GetEntityName(entityType); - - HashSet? jstruct = queryStructure.Joins?.ToHashSet(); - - string? tableAlias = null; - foreach (CosmosJoinStructure join in jstruct ?? new HashSet()) - { - if (join.DbObject.Name == columnName) - { - tableAlias = join.TableAlias; - break; - } - } - - if (string.IsNullOrEmpty(tableAlias)) - { - tableAlias = $"table{_tableCounter?.Next()}"; - } - // Validate that the field referenced in the nested input filter can be accessed. bool entityAccessPermitted = queryStructure.AuthorizationResolver.AreRoleAndOperationDefinedForEntity( - entityIdentifier: entityName, + entityIdentifier: entityType, roleName: GetHttpContextFromMiddlewareContext(ctx).Request.Headers[CLIENT_ROLE_HEADER], operation: EntityActionOperation.Read); @@ -329,43 +311,39 @@ private void HandleNestedFilterForCosmos( subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed); } - IDictionary subParameters = new Dictionary(); - CosmosQueryStructure comosQueryStructure = - new( - context: ctx, - parameters: subParameters, - metadataProvider: metadataProvider, - authorizationResolver: queryStructure.AuthorizationResolver, - gQLFilterParser: this, - counter: queryStructure.Counter); - - comosQueryStructure.DatabaseObject.SchemaName = queryStructure.SourceAlias; - comosQueryStructure.DatabaseObject.Name = tableAlias; - comosQueryStructure.SourceAlias = tableAlias; - comosQueryStructure.EntityName = entityName; - - PredicateOperand joinpredicate = new( - Parse( - ctx: ctx, - filterArgumentSchema: filterField, - fields: subfields, - queryStructure: comosQueryStructure)); - predicates.Push(joinpredicate); - - queryStructure.Joins ??= new Stack(); - if (comosQueryStructure.Joins is not null and { Count: > 0 }) - { - queryStructure.Joins = comosQueryStructure.Joins; - } + List predicatesForExistsQuery = new(); + CosmosExistsQueryStructure existsQuery = new( + ctx, + new Dictionary(), + metadataProvider, + queryStructure.AuthorizationResolver, + this, + queryStructure.Counter, + predicatesForExistsQuery); - queryStructure - .Joins - .Push(new CosmosJoinStructure( - DbObject: new DatabaseTable(schemaName: queryStructure.SourceAlias, tableName: columnName), - TableAlias: tableAlias)); + existsQuery.DatabaseObject.SchemaName = $"{queryStructure.SourceAlias}.{columnName}"; + existsQuery.DatabaseObject.Name = existsQuery.SourceAlias; + existsQuery.EntityName = metadataProvider.GetEntityName(entityType); + + // Recursively parse and obtain the predicates for the Exists clause subquery + Predicate existsQueryFilterPredicate = Parse(ctx, + filterField, + subfields, + existsQuery); + + predicatesForExistsQuery.Push(existsQueryFilterPredicate); + + // The right operand is the SqlExistsQueryStructure. + PredicateOperand right = new(existsQuery); + + // Create a new unary Exists Predicate + Predicate existsPredicate = new(left: null, PredicateOperation.EXISTS, right); + + // Add it to the rest of the existing predicates. + predicates.Push(new PredicateOperand(existsPredicate)); // Add all parameters from the exists subquery to the main queryStructure. - foreach ((string key, DbConnectionParam value) in comosQueryStructure.Parameters) + foreach ((string key, DbConnectionParam value) in existsQuery.Parameters) { queryStructure.Parameters.Add(key, value); } @@ -541,7 +519,7 @@ private Predicate ParseAndOr( BaseQueryStructure baseQuery, PredicateOperation op) { - if (fields.Count == 0 && (baseQuery is CosmosQueryStructure cosmosQueryStructure && cosmosQueryStructure.Joins?.Count == 0)) + if (fields.Count == 0) { return Predicate.MakeFalsePredicate(); } diff --git a/src/Core/Models/SqlQueryStructures.cs b/src/Core/Models/SqlQueryStructures.cs index a86f810e84..f922cd4345 100644 --- a/src/Core/Models/SqlQueryStructures.cs +++ b/src/Core/Models/SqlQueryStructures.cs @@ -165,7 +165,7 @@ public class PredicateOperand /// private readonly Predicate? _predicateOperand; - private readonly BaseSqlQueryStructure? _queryStructure; + private readonly BaseQueryStructure? _queryStructure; /// /// Initialize operand as Column @@ -186,7 +186,7 @@ public PredicateOperand(Column? column) /// /// Initialize operand as a query structure. /// - public PredicateOperand(BaseSqlQueryStructure? queryStructure) + public PredicateOperand(BaseQueryStructure? queryStructure) { if (queryStructure == null) { @@ -260,11 +260,29 @@ public PredicateOperand(Predicate? predicate) /// Resolve operand as a BaseSqlQueryStructure /// /// null if operand is not intialized as BaseSqlQueryStructure - public BaseSqlQueryStructure? AsSqlQueryStructure() + public BaseQueryStructure? AsBaseQueryStructure() { return _queryStructure; } + /// + /// Resolve operand as a BaseSqlQueryStructure + /// + /// null if operand is not intialized as BaseSqlQueryStructure + public BaseSqlQueryStructure? AsSqlQueryStructure() + { + return _queryStructure as BaseSqlQueryStructure; + } + + /// + /// Resolve operand as a CosmosQueryStructure + /// + /// null if operand is not intialized as CosmosQueryStructure + public CosmosQueryStructure? AsCosmosQueryStructure() + { + return _queryStructure as CosmosQueryStructure; + } + /// /// Used to check if the predicate operand is a predicate itself /// diff --git a/src/Core/Parsers/EdmModelBuilder.cs b/src/Core/Parsers/EdmModelBuilder.cs index 8180e58db0..63dabb3616 100644 --- a/src/Core/Parsers/EdmModelBuilder.cs +++ b/src/Core/Parsers/EdmModelBuilder.cs @@ -4,6 +4,7 @@ using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Services; +using HotChocolate.Language; using Microsoft.OData.Edm; namespace Azure.DataApiBuilder.Core.Parsers @@ -37,6 +38,41 @@ public EdmModelBuilder BuildModel(ISqlMetadataProvider sqlMetadataProvider) .BuildEntitySets(sqlMetadataProvider); } + /// + /// Build the model from the provided schema. + /// + /// holds the whole schema. + /// An EdmModelBuilder that can be used to get a model. + public EdmModelBuilder BuildModel(DocumentNode graphQLSchemaRoot) + { + return BuildEdmModelsForCosmos(graphQLSchemaRoot); + } + + private EdmModelBuilder BuildEdmModelsForCosmos(DocumentNode graphQLSchemaRoot) + { + EdmEntityContainer container = new(DEFAULT_NAMESPACE, DEFAULT_CONTAINER_NAME); + + foreach (ObjectTypeDefinitionNode typeDefinition in graphQLSchemaRoot.Definitions) + { + EdmEntityType edmEntity = new(DEFAULT_NAMESPACE, typeDefinition.Name.Value); + foreach (FieldDefinitionNode field in typeDefinition.Fields) + { + edmEntity.AddStructuralProperty( + name: field.Name.Value, + type: TypeHelper.GetEdmPrimitiveTypeFromITypeNode(field.Type), + isNullable: !field.Type.IsNonNullType()); + } + + container.AddEntitySet( + name: typeDefinition.Name.Value, + elementType: edmEntity); + } + + _model.AddElement(container); + + return this; + } + /// /// Build EdmEntityType objects for runtime config defined entities and add the created objects to the EdmModel. /// diff --git a/src/Core/Parsers/FilterParser.cs b/src/Core/Parsers/FilterParser.cs index 498e2c682d..ec765e26a6 100644 --- a/src/Core/Parsers/FilterParser.cs +++ b/src/Core/Parsers/FilterParser.cs @@ -4,6 +4,7 @@ using System.Net; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Service.Exceptions; +using HotChocolate.Language; using Microsoft.OData; using Microsoft.OData.Edm; using Microsoft.OData.UriParser; @@ -26,6 +27,12 @@ public void BuildModel(ISqlMetadataProvider sqlMetadataProvider) _model = builder.BuildModel(sqlMetadataProvider).GetModel(); } + public void BuildModel(DocumentNode graphQLSchemaRoot) + { + EdmModelBuilder builder = new(); + _model = builder.BuildModel(graphQLSchemaRoot).GetModel(); + } + /// /// Parses a given filter part of a query string. /// diff --git a/src/Core/Parsers/ODataASTCosmosVisitor.cs b/src/Core/Parsers/ODataASTCosmosVisitor.cs new file mode 100644 index 0000000000..1fca34d624 --- /dev/null +++ b/src/Core/Parsers/ODataASTCosmosVisitor.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.OData.UriParser; + +/// +/// This class is a visitor for an AST generated when parsing a $filter query string +/// with the OData Uri Parser for Cosmos DB Db Policies +/// +namespace Azure.DataApiBuilder.Core.Parsers +{ + internal class ODataASTCosmosVisitor : QueryNodeVisitor + { + private string _prefix; + + /// + /// 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) + { + this._prefix = prefix; + } + + /// + /// Represents visiting a SingleValuePropertyAccessNode, which is what + /// holds an exposed field name in the AST. + /// + /// The node visited. + /// String representing the Field name + public override string Visit(SingleValuePropertyAccessNode nodeIn) + { + return ($"{_prefix}.{nodeIn.Property.Name}"); + } + + /// + /// Represents visiting a BinaryOperatorNode, which will hold either + /// a Predicate operation (eq, gt, lt, etc), or a Logical operation (And, Or). + /// + /// The node visited. + /// String concatenation of (left op right). + public override string Visit(BinaryOperatorNode nodeIn) + { + string left = nodeIn.Left.Accept(this); + string right = nodeIn.Right.Accept(this); + + if (left.Equals("NULL", StringComparison.OrdinalIgnoreCase) || right.Equals("NULL", StringComparison.OrdinalIgnoreCase)) + { + return CreateNullResult(nodeIn.OperatorKind, left, right); + } + + return $"({left} {GetFilterPredicateOperator(nodeIn.OperatorKind)} {right})"; + } + + /// + /// Create the correctly formed response with NULLs. + /// + /// The binary operation + /// The value representing a field. + /// The correct format for a NULL given the op and left hand side. + private static string CreateNullResult(BinaryOperatorKind op, string left, string right) + { + switch (op) + { + case BinaryOperatorKind.Equal: + if (!left.Equals("NULL", StringComparison.OrdinalIgnoreCase)) + { + return $"({left} IS NULL)"; + } + + return $"({right} IS NULL)"; + case BinaryOperatorKind.NotEqual: + if (!left.Equals("NULL", StringComparison.OrdinalIgnoreCase)) + { + return $"({left} IS NOT NULL)"; + } + + return $"({right} IS NOT NULL)"; + case BinaryOperatorKind.GreaterThan: + case BinaryOperatorKind.GreaterThanOrEqual: + case BinaryOperatorKind.LessThan: + case BinaryOperatorKind.LessThanOrEqual: + return $"({left} {GetFilterPredicateOperator(op)} {right})"; + default: + throw new NotSupportedException($"{op} is not supported with {left} and {right}"); + } + } + + /// + /// Represents visiting a UnaryNode, which is what holds unary + /// operators such as NOT. + /// + /// The node visited. + /// String concatenation of (op children) + public override string Visit(UnaryOperatorNode nodeIn) + { + string child = nodeIn.Operand.Accept(this); + return $"({GetFilterPredicateOperator(nodeIn.OperatorKind)} {child} )"; + } + + /// + /// Represents visiting a ConvertNode, which holds + /// some other node as its source. + /// + /// The node visited. + /// + public override string Visit(ConvertNode nodeIn) + { + return nodeIn.Source.Accept(this); + } + + /// + /// Represents visiting a ConstantNode, which is what + /// holds a value in the AST. + /// + /// The node visited. + /// String representing param that holds given value. + public override string Visit(ConstantNode nodeIn) + { + if (nodeIn.TypeReference is null) + { + // Represents a NULL value, we support NULL in queries so return "NULL" here + return "NULL"; + } + + return $"'{nodeIn.Value}'"; + } + + /// + /// Return the correct string for the binary operator that will be a part of the filter predicates + /// that will make up the filter of the query. + /// + /// The op we will translate. + /// The string which is a translation of the op. + private static string GetFilterPredicateOperator(BinaryOperatorKind op) + { + switch (op) + { + case BinaryOperatorKind.Equal: + return "="; + case BinaryOperatorKind.GreaterThan: + return ">"; + case BinaryOperatorKind.GreaterThanOrEqual: + return ">="; + case BinaryOperatorKind.LessThan: + return "<"; + case BinaryOperatorKind.LessThanOrEqual: + return "<="; + case BinaryOperatorKind.NotEqual: + return "!="; + case BinaryOperatorKind.And: + return "AND"; + case BinaryOperatorKind.Or: + return "OR"; + default: + throw new ArgumentException($"Unknown Predicate Operation of {op}"); + } + } + + /// + /// Return the correct string for the unary operator that will be a part of the filter predicates + /// that will make up the filter of the query. + /// + /// The op we will translate. + /// The string which is a translation of the op. + private static string GetFilterPredicateOperator(UnaryOperatorKind op) + { + switch (op) + { + case UnaryOperatorKind.Not: + return "NOT"; + default: + throw new ArgumentException($"Unknown Predicate Operation of {op}"); + } + } + } +} diff --git a/src/Core/Resolvers/AuthorizationPolicyHelpers.cs b/src/Core/Resolvers/AuthorizationPolicyHelpers.cs index f73a02d4b4..58f4d1461d 100644 --- a/src/Core/Resolvers/AuthorizationPolicyHelpers.cs +++ b/src/Core/Resolvers/AuthorizationPolicyHelpers.cs @@ -2,10 +2,12 @@ // Licensed under the MIT License. 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.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -25,14 +27,14 @@ public static class AuthorizationPolicyHelpers /// Then, the OData clause is processed for the passed in SqlQueryStructure /// by calling OData visitor helpers. /// - /// Action to provide the authorizationResolver during policy lookup. + /// Action to provide the authorizationResolver during policy lookup. /// SqlQueryStructure object, could be a subQueryStructure which is of the same type. /// The GraphQL Middleware context with request metadata like HttpContext. /// Used to lookup authorization policies. /// Provides helper method to process ODataFilterClause. public static void ProcessAuthorizationPolicies( EntityActionOperation operationType, - BaseSqlQueryStructure queryStructure, + BaseQueryStructure queryStructure, HttpContext context, IAuthorizationResolver authorizationResolver, ISqlMetadataProvider sqlMetadataProvider) @@ -46,23 +48,134 @@ public static void ProcessAuthorizationPolicies( } string clientRoleHeader = roleHeaderValue.ToString(); - List elementalOperations = ResolveCompoundOperationToElementalOperations(operationType); + List? elementalOperations = ResolveCompoundOperationToElementalOperations(operationType); + Dictionary entitiesToProcess = new(); + if (queryStructure is BaseSqlQueryStructure baseSqlQueryStructure) + { + ProcessFilter( + context: context, + authorizationResolver: authorizationResolver, + sqlMetadataProvider: sqlMetadataProvider, + clientRoleHeader: clientRoleHeader, + elementalOperations: elementalOperations, + entityName: queryStructure.EntityName, + entityDBObject: queryStructure.DatabaseObject, + postProcessCallback: (filterClause, elementalOperation) => + { + baseSqlQueryStructure.ProcessOdataClause(filterClause, elementalOperation); + }); + ; + } + else if (sqlMetadataProvider is CosmosSqlMetadataProvider cosmosSqlMetadataProvider && + queryStructure is CosmosQueryStructure cosmosQueryStructure) + { + Dictionary> entityPaths = cosmosSqlMetadataProvider.EntityWithJoins; + + foreach (KeyValuePair> entity in entityPaths) + { + ProcessFilter( + context: context, + authorizationResolver: authorizationResolver, + sqlMetadataProvider: cosmosSqlMetadataProvider, + clientRoleHeader: clientRoleHeader, + elementalOperations: elementalOperations, + entityName: entity.Key, + entityDBObject: null, + postProcessCallback: (filterClause, _) => + { + if (filterClause is null) + { + return; + } + + foreach (EntityDbPolicyCosmosModel pathConfig in entity.Value) + { + string? existQuery = null; + string? fromClause = string.Empty; + string? predicates = string.Empty; + + if (pathConfig.Alias is not null) + { + if (pathConfig.ColumnName is null || pathConfig.EntityName is null) + { + continue; + } + //Increment Table counter with the new JOIN so that we can have unique alias for each join + cosmosQueryStructure.TableCounter.Next(); + + fromClause = pathConfig.JoinStatement; + predicates = filterClause?.Expression.Accept(new ODataASTCosmosVisitor(pathConfig.Alias)); + + existQuery = CosmosQueryBuilder.BuildExistsQueryForCosmos(fromClause, predicates); + } + else + { + predicates = filterClause?.Expression.Accept(new ODataASTCosmosVisitor($"{pathConfig.Path}.{pathConfig.ColumnName}")); + } + + if (pathConfig.EntityName == entity.Key) + { + if (!cosmosQueryStructure.DbPolicyPredicatesForOperations.TryGetValue(operationType, out string? _)) + { + cosmosQueryStructure.DbPolicyPredicatesForOperations[operationType] + = existQuery ?? predicates; + } + else + { + cosmosQueryStructure.DbPolicyPredicatesForOperations[operationType] + += $" AND {existQuery ?? predicates}"; + } + } + } + }); + } + } + } + + /// + /// Read the DB policy from the config file and process it to generate OData Filter Clause. + /// Here, we are processing the DB policy for each elemental operation and then calling the postProcessCallback. + /// PostProcessCallback is a callback function which can be used to generate filter clause according to specific database. + /// + /// HttpContext, provides information related to http request + /// Required to read DB policy from config file + /// Metadata Provider + /// User Role + /// Operation to be made + /// Entity Name + /// Contains entity information. + /// Call back to be called after DB policy information is fetched. + /// OData Filter Clause + private static List ProcessFilter( + HttpContext context, + IAuthorizationResolver authorizationResolver, + ISqlMetadataProvider sqlMetadataProvider, + string clientRoleHeader, + List elementalOperations, + string entityName, + DatabaseObject? entityDBObject, + Action postProcessCallback) + { + List filterClauses = new(); foreach (EntityActionOperation elementalOperation in elementalOperations) { string dbQueryPolicy = authorizationResolver.ProcessDBPolicy( - queryStructure.EntityName, + entityName, clientRoleHeader, elementalOperation, context); FilterClause? filterClause = GetDBPolicyClauseForQueryStructure( dbQueryPolicy, - entityName: queryStructure.EntityName, - resourcePath: queryStructure.DatabaseObject.FullName, + entityName: entityName, + resourcePath: (entityDBObject is not null) ? $"{entityName}.{entityDBObject.FullName}" : entityName, sqlMetadataProvider); - queryStructure.ProcessOdataClause(filterClause, elementalOperation); + + postProcessCallback(filterClause, elementalOperation); } + + return filterClauses; } /// @@ -89,7 +202,7 @@ public static void ProcessAuthorizationPolicies( // FilterClauseInDbPolicy is an Abstract Syntax Tree representing the parsed policy text. return sqlMetadataProvider.GetODataParser().GetFilterClause( filterQueryString: dbPolicyClause, - resourcePath: $"{entityName}.{resourcePath}", + resourcePath: resourcePath, customResolver: new ClaimsTypeDataUriResolver()); } diff --git a/src/Core/Resolvers/BaseQueryStructure.cs b/src/Core/Resolvers/BaseQueryStructure.cs index e8cb912770..cfe4a050ec 100644 --- a/src/Core/Resolvers/BaseQueryStructure.cs +++ b/src/Core/Resolvers/BaseQueryStructure.cs @@ -3,6 +3,7 @@ using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Service.GraphQLBuilder; @@ -30,7 +31,7 @@ public class BaseQueryStructure /// /// The DatabaseObject associated with the entity, represents the - /// databse object to be queried. + /// database object to be queried. /// public DatabaseObject DatabaseObject { get; protected set; } = null!; @@ -57,7 +58,7 @@ public class BaseQueryStructure public List Predicates { get; } /// - /// Used for parsing graphql filter arguments. + /// Used for parsing GraphQL filter arguments. /// public GQLFilterParser GraphQLFilterParser { get; protected set; } @@ -67,6 +68,12 @@ public class BaseQueryStructure /// public IAuthorizationResolver AuthorizationResolver { get; } + /// + /// DbPolicyPredicates is a string that represents the filter portion of our query + /// in the WHERE Clause added by virtue of the database policy. + /// + public Dictionary DbPolicyPredicatesForOperations { get; set; } = new(); + public const string PARAM_NAME_PREFIX = "@"; public BaseQueryStructure( diff --git a/src/Core/Resolvers/CosmosExistsQueryStructure.cs b/src/Core/Resolvers/CosmosExistsQueryStructure.cs new file mode 100644 index 0000000000..798a85dbce --- /dev/null +++ b/src/Core/Resolvers/CosmosExistsQueryStructure.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Core.Services; +using HotChocolate.Resolvers; + +namespace Azure.DataApiBuilder.Core.Resolvers +{ + public class CosmosExistsQueryStructure : CosmosQueryStructure + { + /// + /// Constructor for Exists query. + /// + public CosmosExistsQueryStructure(IMiddlewareContext context, + IDictionary parameters, + ISqlMetadataProvider metadataProvider, + IAuthorizationResolver authorizationResolver, + GQLFilterParser gQLFilterParser, + IncrementingInteger? counter = null, + List? predicates = null) + : base(context, + parameters, + metadataProvider, + authorizationResolver, + gQLFilterParser, + counter, + predicates) + { + SourceAlias = CreateTableAlias(); + } + } +} diff --git a/src/Core/Resolvers/CosmosQueryBuilder.cs b/src/Core/Resolvers/CosmosQueryBuilder.cs index 158b470b48..e6f835429f 100644 --- a/src/Core/Resolvers/CosmosQueryBuilder.cs +++ b/src/Core/Resolvers/CosmosQueryBuilder.cs @@ -2,9 +2,8 @@ // Licensed under the MIT License. using System.Text; -using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Models; -using static Azure.DataApiBuilder.Core.Resolvers.CosmosQueryStructure; namespace Azure.DataApiBuilder.Core.Resolvers { @@ -24,14 +23,15 @@ public string Build(CosmosQueryStructure structure) + $" FROM {_containerAlias}"); string predicateString = Build(structure.Predicates); - if (structure.Joins != null && structure.Joins.Count > 0) + structure.DbPolicyPredicatesForOperations.TryGetValue(EntityActionOperation.Read, out string? policy); + // If there is a predicate or policy, add a WHERE clause + if (!string.IsNullOrEmpty(predicateString) || !string.IsNullOrEmpty(policy)) { - queryStringBuilder.Append($" {Build(structure.Joins)}"); - } - - if (!string.IsNullOrEmpty(predicateString)) - { - queryStringBuilder.Append($" WHERE {predicateString}"); + queryStringBuilder + .Append(" WHERE ") + .Append(string.IsNullOrEmpty(predicateString) || string.IsNullOrEmpty(policy) + ? predicateString + policy + : string.Join(" AND ", predicateString, policy)); } if (structure.OrderByColumns.Count > 0) @@ -61,7 +61,7 @@ protected override string Build(KeysetPaginationPredicate? predicate) public override string QuoteIdentifier(string ident) { - throw new System.NotImplementedException(); + return ident; } /// @@ -108,6 +108,8 @@ protected override string Build(PredicateOperation op) return ""; case PredicateOperation.IS_NOT: return "NOT"; + case PredicateOperation.EXISTS: + return "EXISTS"; case PredicateOperation.ARRAY_CONTAINS: return "ARRAY_CONTAINS"; case PredicateOperation.NOT_ARRAY_CONTAINS: @@ -129,17 +131,26 @@ protected override string Build(Predicate? predicate) } string predicateString; - if (predicate.Op == PredicateOperation.ARRAY_CONTAINS || predicate.Op == PredicateOperation.NOT_ARRAY_CONTAINS) + if (predicate.Left is not null) { - predicateString = $" {Build(predicate.Op)} ( {ResolveOperand(predicate.Left)}, {ResolveOperand(predicate.Right)})"; - } - else if (ResolveOperand(predicate.Right).Equals(GQLFilterParser.NullStringValue)) - { - predicateString = $" {Build(predicate.Op)} IS_NULL({ResolveOperand(predicate.Left)})"; + if (predicate.Op == PredicateOperation.ARRAY_CONTAINS || predicate.Op == PredicateOperation.NOT_ARRAY_CONTAINS) + { + predicateString = $" {Build(predicate.Op)} ( {ResolveOperand(predicate.Left)}, {ResolveOperand(predicate.Right)})"; + } + else if (ResolveOperand(predicate.Right).Equals(GQLFilterParser.NullStringValue)) + { + // For Binary predicates: + predicateString = $" {Build(predicate.Op)} IS_NULL({ResolveOperand(predicate.Left)})"; + } + else + { + predicateString = $"{ResolveOperand(predicate.Left)} {Build(predicate.Op)} {ResolveOperand(predicate.Right)} "; + } } else { - predicateString = $"{ResolveOperand(predicate.Left)} {Build(predicate.Op)} {ResolveOperand(predicate.Right)} "; + // For Unary predicates, there is always a parenthesis around the operand. + predicateString = $"{Build(predicate.Op)} ({ResolveOperand(predicate.Right)})"; } if (predicate.AddParenthesis) @@ -153,29 +164,79 @@ protected override string Build(Predicate? predicate) } /// - /// Build JOIN statements which will be used in the query. - /// It makes sure that the same table is not joined multiple times by maintaining a set of table names. + /// Resolves the operand either as a column, another predicate, + /// a SqlQueryStructure or returns it directly as string /// - /// - /// - private static string Build(Stack joinstructure) + protected new string ResolveOperand(PredicateOperand? operand) { - StringBuilder joinBuilder = new(); - - HashSet tableNames = new(); - foreach (CosmosJoinStructure structure in joinstructure) + if (operand == null) { - if (tableNames.Contains(structure.DbObject)) - { - continue; - } + throw new ArgumentNullException(nameof(operand)); + } - joinBuilder.Append($" JOIN {structure.TableAlias} IN {structure.DbObject.FullName}"); - tableNames.Add(structure.DbObject); + Column? column; + string? stringType; + Predicate? predicate; + BaseQueryStructure? sqlQueryStructure; + if ((column = operand.AsColumn()) != null) + { + return Build(column); + } + else if ((stringType = operand.AsString()) != null) + { + return stringType; + } + else if ((predicate = operand.AsPredicate()) != null) + { + return Build(predicate); } + else if ((sqlQueryStructure = operand.AsCosmosQueryStructure()) is not null + && sqlQueryStructure is CosmosExistsQueryStructure cosmosExistsQueryStructure) + { + return Build(cosmosExistsQueryStructure); + } + else if ((sqlQueryStructure = operand.AsCosmosQueryStructure()) is not null + && sqlQueryStructure is CosmosQueryStructure cosmosQueryStructure) + { + return Build(cosmosQueryStructure); + } + else + { + throw new ArgumentException("Cannot get a value from PredicateOperand to build."); + } + } + + /// + public virtual string Build(CosmosExistsQueryStructure structure) + { + string query = $"SELECT 1 " + + $"FROM {QuoteIdentifier(structure.SourceAlias)} IN {QuoteIdentifier(structure.DatabaseObject.SchemaName)} " + + $"WHERE {Build(structure.Predicates)}"; - return joinBuilder.ToString(); + return query; } + /// + /// Generate Cosmos DB Query for the given fromClause and predicates. + /// + /// Use to generate FROM part in sql along with table and JOINS + /// Query Conditions + /// CosmosDB Exist Query + public static string BuildExistsQueryForCosmos(string? fromClause, string? predicates) + { + string? existQuery = $"EXISTS " + + $"(SELECT VALUE 1 " + + $"FROM {fromClause} "; + if (!string.IsNullOrEmpty(predicates)) + { + existQuery += $"WHERE {predicates})"; + } + else + { + existQuery += ")"; + } + + return existQuery; + } } } diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs index 881d2b50eb..bd84e3fba5 100644 --- a/src/Core/Resolvers/CosmosQueryStructure.cs +++ b/src/Core/Resolvers/CosmosQueryStructure.cs @@ -4,20 +4,30 @@ using System.Diagnostics.CodeAnalysis; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Service.GraphQLBuilder; using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using HotChocolate.Language; using HotChocolate.Resolvers; +using Microsoft.AspNetCore.Http; namespace Azure.DataApiBuilder.Core.Resolvers { public class CosmosQueryStructure : BaseQueryStructure { private readonly IMiddlewareContext _context; - private readonly string _containerAlias = "c"; + + /// + /// For any CosmosDB Query, the default alias for the container is 'c' + /// + public const string COSMOSDB_CONTAINER_DEFAULT_ALIAS = "c"; + + private readonly string _containerAlias = COSMOSDB_CONTAINER_DEFAULT_ALIAS; + public IncrementingInteger TableCounter { get; internal set; } = new(); public override string SourceAlias { get => base.SourceAlias; set => base.SourceAlias = value; } @@ -29,16 +39,11 @@ public class CosmosQueryStructure : BaseQueryStructure public int? MaxItemCount { get; internal set; } public string? PartitionKeyValue { get; internal set; } public List OrderByColumns { get; internal set; } - // Order of the join matters - public Stack? Joins { get; internal set; } - /// - /// A simple class that is used to hold the information about joins that - /// are part of a Cosmos query. - /// - /// The name of the database object containing table metadata like joined tables. - /// The alias of the table that is joined with. - public record CosmosJoinStructure(DatabaseObject DbObject, string TableAlias); + public string GetTableAlias() + { + return $"table{TableCounter.Next()}"; + } public CosmosQueryStructure( IMiddlewareContext context, @@ -46,8 +51,9 @@ public CosmosQueryStructure( ISqlMetadataProvider metadataProvider, IAuthorizationResolver authorizationResolver, GQLFilterParser gQLFilterParser, - IncrementingInteger? counter = null) - : base(metadataProvider, authorizationResolver, gQLFilterParser, entityName: string.Empty, counter: counter) + IncrementingInteger? counter = null, + List? predicates = null) + : base(metadataProvider, authorizationResolver, gQLFilterParser, predicates: predicates, entityName: string.Empty, counter: counter) { _context = context; SourceAlias = _containerAlias; @@ -138,6 +144,17 @@ private void Init(IDictionary queryParams) Container = MetadataProvider.GetDatabaseObjectName(entityName); } + HttpContext httpContext = GraphQLFilterParser.GetHttpContextFromMiddlewareContext(_context); + if (httpContext is not null) + { + AuthorizationPolicyHelpers.ProcessAuthorizationPolicies( + EntityActionOperation.Read, + this, + httpContext, + AuthorizationResolver, + (CosmosSqlMetadataProvider)MetadataProvider); + } + // first and after will not be part of query parameters. They will be going into headers instead. // TODO: Revisit 'first' while adding support for TOP queries if (queryParams.ContainsKey(QueryBuilder.PAGE_START_ARGUMENT_NAME)) diff --git a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs index b90bb01958..16e13fd3bc 100644 --- a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs @@ -42,12 +42,6 @@ public abstract class BaseSqlQueryStructure : BaseQueryStructure /// public string? FilterPredicates { get; set; } - /// - /// DbPolicyPredicates is a string that represents the filter portion of our query - /// in the WHERE Clause added by virtue of the database policy. - /// - public Dictionary DbPolicyPredicatesForOperations { get; set; } = new(); - /// /// Collection of all the fields referenced in the database policy for create action. /// The fields referenced in the database policy should be a subset of the fields that are being inserted via the insert statement, diff --git a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs index 8503dfd62d..3c7d9fc42e 100644 --- a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs @@ -6,22 +6,37 @@ using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Parsers; using Azure.DataApiBuilder.Core.Resolvers; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder; +using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives; using HotChocolate.Language; +using Microsoft.OData.Edm; namespace Azure.DataApiBuilder.Core.Services.MetadataProviders { public class CosmosSqlMetadataProvider : ISqlMetadataProvider { + private ODataParser _oDataParser = new(); + private readonly IFileSystem _fileSystem; private readonly DatabaseType _databaseType; private CosmosDbNoSQLDataSourceOptions _cosmosDb; private readonly RuntimeConfig _runtimeConfig; private Dictionary _partitionKeyPaths = new(); + /// + /// This contains each entity into EDM model convention which will be used to traverse DB Policy filter using ODataParser + /// + public EdmModel EdmModel { get; set; } = new(); + + /// + /// This dictionary contains entity name as key (or its alias) and its path(s) in the graphQL schema as value which will be used in the generated conditions for the entity + /// + public Dictionary> EntityWithJoins { get; set; } = new(); + /// public Dictionary GraphQLStoredProcedureExposedNameToEntityNameMap { get; set; } = new(); @@ -65,6 +80,181 @@ public CosmosSqlMetadataProvider(RuntimeConfigProvider runtimeConfigProvider, IF } ParseSchemaGraphQLFieldsForGraphQLType(); + ParseSchemaGraphQLFieldsForJoins(); + + InitODataParser(); + } + + /// + /// Initialize OData parser by building OData model. + /// The parser will be used for parsing filter clause and order by clause. + /// + private void InitODataParser() + { + _oDataParser.BuildModel(GraphQLSchemaRoot); + } + + /// + /// Parse the schema to get the entity paths for prefixes. + /// It will collect all the paths for each entity and its field, starting from the container model. + /// + /// e.g. If we have the following schema: + /// type Planet @model(name:""PlanetAlias"") { + /// id : ID!, + /// name : String, + /// character: Character, + /// stars: [Star], + /// sun: Star + /// } + /// + /// type Star { + /// id : ID, + /// name : String + /// } + /// + /// type Character { + /// id : ID, + /// name : String, + /// type: String, + /// homePlanet: Int, + /// primaryFunction: String, + /// star: Star + /// } + /// It would generate the following EntityWithJoins dictionary: + /// KEY: PlanetAlias + /// VALUE: + /// a) Path = c, EntityName = PlanetAlias + /// + /// KEY: Star + /// VALUE: + /// a) Path = c, ColumnName = stars , EntityName = Star, Alias = table0, JoinStatement = table0 IN c.stars + /// b) Path = c , ColumnName = sun, EntityName = Star + /// c) Path = c.character, ColumnName = star , EntityName = Star + /// + /// KEY: Character + /// VALUE: + /// a) Path = c, ColumnName = character , EntityName = Character + /// + /// EntityWithJoins dictionary indicates the paths for each entity. There "Planet" has one path i.e. "c" on the other hand Star has 3 paths.with one join statement. + /// This information is getting used to resolve DB Policy and generate cosmos DB sql query conditions for them. + /// + private void ParseSchemaGraphQLFieldsForJoins() + { + IncrementingInteger tableCounter = new(); + + Dictionary schemaDefinitions = new(); + + // Step1: Collect all the schema definitions in a dictionary for easy lookup of the corresponding fields + foreach (ObjectTypeDefinitionNode typeDefinition in GraphQLSchemaRoot.Definitions) + { + schemaDefinitions.Add(typeDefinition.Name.Value, typeDefinition); + } + + // Step2: + // a) Traverse the schema to find the container model + // b) Once it is found, start collecting all the paths for each entity and its field. + foreach (IDefinitionNode typeDefinition in GraphQLSchemaRoot.Definitions) + { + if (typeDefinition is ObjectTypeDefinitionNode node && node.Directives.Any(a => a.Name.Value == ModelDirectiveType.DirectiveName)) + { + string modelName = GraphQLNaming.ObjectTypeToEntityName(node); + + if (EntityWithJoins.TryGetValue(modelName, out List? entityWithJoins)) + { + entityWithJoins.Add(new(Path: CosmosQueryStructure.COSMOSDB_CONTAINER_DEFAULT_ALIAS, EntityName: modelName)); + } + else + { + EntityWithJoins.Add( + modelName, + new List + { + new (Path: CosmosQueryStructure.COSMOSDB_CONTAINER_DEFAULT_ALIAS, EntityName: modelName) + }); + } + + ProcessSchema(node.Fields, schemaDefinitions, CosmosQueryStructure.COSMOSDB_CONTAINER_DEFAULT_ALIAS, tableCounter); + } + } + } + + /// + /// Once container is found, it will traverse the fields and inner fields to get the paths for each entity. + /// Following steps are implemented here: + /// 1. If the entity is not in the runtime config, skip it. + /// 2. If the field is an array type, we need to create a table alias which will be used when creating JOINs to that table. + /// 3. Create a new EntityDbPolicyCosmosModel object with all the entity related information and add it to the EntityWithJoins dictionary. + /// 4. Check if we get previous entity with join information, if yes append it to the current entity also + /// 5. Recursively call this function, to process the schema + /// + /// + /// + /// + /// indicates the parent entity for which we are processing the schema. + private void ProcessSchema(IReadOnlyList fields, + Dictionary schemaDocument, + string currentPath, + IncrementingInteger tableCounter, + EntityDbPolicyCosmosModel? previousEntity = null) + { + // Traverse the fields and add them to the path + foreach (FieldDefinitionNode field in fields) + { + string entityType = field.Type.NamedType().Name.Value; + // If the entity is not in the runtime config, skip it + if (!_runtimeConfig.Entities.ContainsKey(entityType)) + { + continue; + } + + string? alias = null; + bool isArrayType = field.Type is ListTypeNode; + if (isArrayType) + { + // Since we don't have query structure here, + // we are going to generate alias and use this counter to generate unique alias for each table at later stage. + alias = $"table{tableCounter.Next()}"; + } + + EntityDbPolicyCosmosModel currentEntity = new( + Path: currentPath, + EntityName: entityType, + ColumnName: field.Name.Value, + Alias: alias); + + if (EntityWithJoins.ContainsKey(entityType)) + { + EntityWithJoins[entityType].Add(currentEntity); + } + else + { + EntityWithJoins.Add( + entityType, + new List() { + currentEntity + }); + } + + if (previousEntity is not null) + { + if (string.IsNullOrEmpty(currentEntity.JoinStatement)) + { + currentEntity.JoinStatement = previousEntity.JoinStatement; + } + else + { + currentEntity.JoinStatement = previousEntity.JoinStatement + " JOIN " + currentEntity.JoinStatement; + } + } + + // If the field is an array type, we need to create a table alias which will be used when creating JOINs to that table. + ProcessSchema( + fields: schemaDocument[entityType].Fields, + schemaDocument: schemaDocument, + currentPath: isArrayType ? $"{alias}" : $"{currentPath}.{field.Name.Value}", + tableCounter: tableCounter, + previousEntity: isArrayType ? currentEntity : null); + } } /// @@ -246,7 +436,7 @@ public List GetSchemaGraphQLFieldNamesForEntityName(string entityName) public ODataParser GetODataParser() { - throw new NotImplementedException(); + return _oDataParser; } public IQueryBuilder GetQueryBuilder() diff --git a/src/Core/Services/TypeHelper.cs b/src/Core/Services/TypeHelper.cs index b39b4a3d81..dd57cdaa46 100644 --- a/src/Core/Services/TypeHelper.cs +++ b/src/Core/Services/TypeHelper.cs @@ -6,6 +6,7 @@ using System.Net; using Azure.DataApiBuilder.Core.Services.OpenAPI; using Azure.DataApiBuilder.Service.Exceptions; +using HotChocolate.Language; using Microsoft.OData.Edm; namespace Azure.DataApiBuilder.Core.Services @@ -158,6 +159,43 @@ public static EdmPrimitiveTypeKind GetEdmPrimitiveTypeFromSystemType(Type column return type; } + /// + /// Given GraphQl type, returns the corresponding primitive type kind. + /// + /// Type of the column. + /// EdmPrimitiveTypeKind + /// Throws when the column + public static EdmPrimitiveTypeKind GetEdmPrimitiveTypeFromITypeNode(ITypeNode columnSystemType) + { + string graphQlType; + if (columnSystemType.IsListType()) + { + graphQlType = ((ListTypeNode)columnSystemType).NamedType().Name.Value; + } + else if (columnSystemType.IsNonNullType()) + { + graphQlType = ((NonNullTypeNode)columnSystemType).NamedType().Name.Value; + } + else + { + graphQlType = ((NamedTypeNode)columnSystemType).Name.Value; + } + + // https://graphql.org/learn/schema/#scalar-types + EdmPrimitiveTypeKind type = graphQlType switch + { + "String" => EdmPrimitiveTypeKind.String, + "ID" => EdmPrimitiveTypeKind.Guid, + "Int" => EdmPrimitiveTypeKind.Int32, + "Float" => EdmPrimitiveTypeKind.Decimal, + "Boolean" => EdmPrimitiveTypeKind.Boolean, + "Date" => EdmPrimitiveTypeKind.Date, + _ => EdmPrimitiveTypeKind.PrimitiveType + }; + + return type; + } + /// /// Converts the .NET Framework (System/CLR) type to JsonDataType. /// Primitive data types in the OpenAPI standard (OAS) are based on the types supported diff --git a/src/Service.Tests/CosmosTests/CosmosTestHelper.cs b/src/Service.Tests/CosmosTests/CosmosTestHelper.cs index 94e232c72c..2f8a06583d 100644 --- a/src/Service.Tests/CosmosTests/CosmosTestHelper.cs +++ b/src/Service.Tests/CosmosTests/CosmosTestHelper.cs @@ -169,7 +169,8 @@ public static object GetItem(string id, string name = null, int numericVal = 4) earth = new { id = id, - name = "blue earth" + name = "blue earth" + numericVal, + type = "earth" + numericVal }, additionalAttributes = new[] { diff --git a/src/Service.Tests/CosmosTests/MutationTests.cs b/src/Service.Tests/CosmosTests/MutationTests.cs index 6e7c72b97c..02513d63ff 100644 --- a/src/Service.Tests/CosmosTests/MutationTests.cs +++ b/src/Service.Tests/CosmosTests/MutationTests.cs @@ -261,27 +261,26 @@ public async Task MutationMissingRequiredPartitionKeyValueReturnError() [TestMethod] [DataRow("field-mutation-with-read-permission", DataApiBuilderException.GRAPHQL_MUTATION_FIELD_AUTHZ_FAILURE, DisplayName = "AuthZ failure for create mutation because of reference to excluded/disallowed fields.")] [DataRow("authenticated", MutationTests.NO_ERROR_MESSAGE, DisplayName = "AuthZ success when role has no create/read operation restrictions.")] - [DataRow("only-create-role", "The mutation operation createEarth was successful " + + [DataRow("only-create-role", "The mutation operation createPlanetAgain was successful " + "but the current user is unauthorized to view the response due to lack of read permissions", DisplayName = "Successful create operation but AuthZ failure for read when role has ONLY create permission and NO read permission.")] [DataRow("wildcard-exclude-fields-role", DataApiBuilderException.GRAPHQL_MUTATION_FIELD_AUTHZ_FAILURE, DisplayName = "AuthZ failure for create mutation because of reference to excluded/disallowed field using wildcard.")] [DataRow("only-update-role", MutationTests.USER_NOT_AUTHORIZED, DisplayName = "AuthZ failure when create permission is NOT there.")] public async Task CreateItemWithAuthPermissions(string roleName, string expectedErrorMessage) { - // Run mutation Add Earth; + // Run mutation Add AuthTestModel; string id = Guid.NewGuid().ToString(); const string name = "test_name"; string mutation = $@" mutation {{ - createEarth (item: {{ id: ""{id}"", name: ""{name}"" }}) {{ + createPlanetAgain (item: {{ id: ""{id}"", name: ""{name}"" }}) {{ id name }} }}"; - string authtoken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); - JsonElement response = await ExecuteGraphQLRequestAsync("createEarth", mutation, variables: new(), authToken: authtoken, clientRoleHeader: roleName); + string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); + JsonElement response = await ExecuteGraphQLRequestAsync("createPlanetAgain", mutation, variables: new(), authToken: authToken, clientRoleHeader: roleName); // Validate the result contains the GraphQL authorization error code. - Console.WriteLine(response.ToString()); if (string.IsNullOrEmpty(expectedErrorMessage)) { Assert.AreEqual(id, response.GetProperty("id").GetString()); @@ -301,8 +300,8 @@ public async Task CreateItemWithAuthPermissions(string roleName, string expected [TestMethod] [DataRow("field-mutation-with-read-permission", DataApiBuilderException.GRAPHQL_MUTATION_FIELD_AUTHZ_FAILURE, DisplayName = "AuthZ failure for update mutation because of reference to excluded/disallowed fields.")] [DataRow("authenticated", NO_ERROR_MESSAGE, DisplayName = "AuthZ success when role has no update/read operation restrictions.")] - [DataRow("only-update-role", "The mutation operation updateEarth was successful " + - "but the current user is unauthorized to view the response due to lack of read permissions", DisplayName = "AuthZ failure but sucessful operation where role has ONLY update permission and NO read permission.")] + [DataRow("only-update-role", "The mutation operation updatePlanetAgain was successful " + + "but the current user is unauthorized to view the response due to lack of read permissions", DisplayName = "AuthZ failure but successful operation where role has ONLY update permission and NO read permission.")] [DataRow("wildcard-exclude-fields-role", DataApiBuilderException.GRAPHQL_MUTATION_FIELD_AUTHZ_FAILURE, DisplayName = "AuthZ failure for update mutation because of reference to excluded/disallowed field using wildcard.")] [DataRow("only-create-role", MutationTests.USER_NOT_AUTHORIZED, DisplayName = "AuthZ failure when update permission is NOT there.")] public async Task UpdateItemWithAuthPermissions(string roleName, string expectedErrorMessage) @@ -312,13 +311,13 @@ public async Task UpdateItemWithAuthPermissions(string roleName, string expected const string name = "test_name"; string createMutation = $@" mutation {{ - createEarth (item: {{ id: ""{id}"", name: ""{name}"" }}) {{ + createPlanetAgain (item: {{ id: ""{id}"", name: ""{name}"" }}) {{ id name }} }}"; - JsonElement createResponse = await ExecuteGraphQLRequestAsync("createEarth", createMutation, + JsonElement createResponse = await ExecuteGraphQLRequestAsync("createPlanetAgain", createMutation, variables: new(), authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: AuthorizationType.Authenticated.ToString()), clientRoleHeader: AuthorizationType.Authenticated.ToString()); @@ -326,10 +325,10 @@ public async Task UpdateItemWithAuthPermissions(string roleName, string expected // Making sure item is created successfully Assert.AreEqual(id, createResponse.GetProperty("id").GetString()); - // Run mutation Update Earth; + // Run mutation Update AuthTestModel; string mutation = @" -mutation ($id: ID!, $partitionKeyValue: String!, $item: UpdateEarthInput!) { - updateEarth (id: $id, _partitionKeyValue: $partitionKeyValue, item: $item) { +mutation ($id: ID!, $partitionKeyValue: String!, $item: UpdatePlanetAgainInput!) { + updatePlanetAgain (id: $id, _partitionKeyValue: $partitionKeyValue, item: $item) { id name } @@ -340,15 +339,14 @@ public async Task UpdateItemWithAuthPermissions(string roleName, string expected name = "new_name" }; - string authtoken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); + string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); JsonElement response = await ExecuteGraphQLRequestAsync( - queryName: "updateEarth", + queryName: "updatePlanetAgain", query: mutation, variables: new() { { "id", id }, { "partitionKeyValue", id }, { "item", update } }, - authToken: authtoken, + authToken: authToken, clientRoleHeader: roleName); - Console.WriteLine(response.ToString()); if (string.IsNullOrEmpty(expectedErrorMessage)) { Assert.AreEqual(id, response.GetProperty("id").GetString()); @@ -368,8 +366,8 @@ public async Task UpdateItemWithAuthPermissions(string roleName, string expected [TestMethod] [DataRow("field-mutation-with-read-permission", MutationTests.NO_ERROR_MESSAGE, DisplayName = "AuthZ success and blank response for delete mutation because of reference to excluded/disallowed fields.")] [DataRow("authenticated", MutationTests.NO_ERROR_MESSAGE, DisplayName = "AuthZ success and blank response when role has no delete operation restrictions.")] - [DataRow("only-delete-role", "The mutation operation deleteEarth was successful " + - "but the current user is unauthorized to view the response due to lack of read permissions", DisplayName = "AuthZ failure but sucessful operation where role has ONLY delete permission and NO read permission.")] + [DataRow("only-delete-role", "The mutation operation deletePlanetAgain was successful " + + "but the current user is unauthorized to view the response due to lack of read permissions", DisplayName = "AuthZ failure but successful operation where role has ONLY delete permission and NO read permission.")] [DataRow("wildcard-exclude-fields-role", MutationTests.NO_ERROR_MESSAGE, DisplayName = "AuthZ success and blank response for delete mutation because of reference to excluded/disallowed fields using wildcard")] [DataRow("only-create-role", MutationTests.USER_NOT_AUTHORIZED, DisplayName = "AuthZ failure when delete permission is NOT there.")] public async Task DeleteItemWithAuthPermissions(string roleName, string expectedErrorMessage) @@ -379,13 +377,13 @@ public async Task DeleteItemWithAuthPermissions(string roleName, string expected const string name = "test_name"; string createMutation = $@" mutation {{ - createEarth (item: {{ id: ""{id}"", name: ""{name}"" }}) {{ + createPlanetAgain (item: {{ id: ""{id}"", name: ""{name}"" }}) {{ id name }} }}"; - JsonElement createResponse = await ExecuteGraphQLRequestAsync("createEarth", createMutation, + JsonElement createResponse = await ExecuteGraphQLRequestAsync("createPlanetAgain", createMutation, variables: new(), authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: AuthorizationType.Authenticated.ToString()), clientRoleHeader: AuthorizationType.Authenticated.ToString()); @@ -393,24 +391,22 @@ public async Task DeleteItemWithAuthPermissions(string roleName, string expected // Making sure item is created successfully Assert.AreEqual(id, createResponse.GetProperty("id").GetString()); - // Run mutation Update Earth; + // Run mutation Update AuthTestModel; string mutation = @" mutation ($id: ID!, $partitionKeyValue: String!) { - deleteEarth (id: $id, _partitionKeyValue: $partitionKeyValue) { + deletePlanetAgain (id: $id, _partitionKeyValue: $partitionKeyValue) { id name } }"; - string authtoken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); + string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); JsonElement response = await ExecuteGraphQLRequestAsync( - queryName: "deleteEarth", + queryName: "deletePlanetAgain", query: mutation, variables: new() { { "id", id }, { "partitionKeyValue", id } }, - authToken: authtoken, + authToken: authToken, clientRoleHeader: roleName); - Console.WriteLine(response.ToString()); - if (string.IsNullOrEmpty(expectedErrorMessage)) { Assert.IsTrue(string.IsNullOrEmpty(response.ToString())); diff --git a/src/Service.Tests/CosmosTests/QueryFilterTests.cs b/src/Service.Tests/CosmosTests/QueryFilterTests.cs index 4f75b5b0b5..0dac6a1c1c 100644 --- a/src/Service.Tests/CosmosTests/QueryFilterTests.cs +++ b/src/Service.Tests/CosmosTests/QueryFilterTests.cs @@ -99,10 +99,17 @@ public async Task TestStringMultiFiltersWithAndCondition() } }"; - string dbQueryWithJoin = "SELECT c.name FROM c " + - "JOIN a IN c.additionalAttributes " + - "JOIN b IN c.moons " + - "WHERE a.name = \"volcano1\" and b.name = \"1 moon\" and b.details LIKE \"%11%\""; + string dbQueryWithJoin = "SELECT c.name " + + "FROM c " + + "WHERE (EXISTS (SELECT 1 " + + "FROM table0 IN c.additionalAttributes " + + "WHERE table0.name = \"volcano1\" ) AND " + + "EXISTS (SELECT 1 " + + "FROM table2 IN c.moons " + + "WHERE table2.name = \"1 moon\" ) AND " + + "EXISTS (SELECT 1 " + + "FROM table4 IN c.moons " + + "WHERE table4.details LIKE \"%11%\" ) )"; await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQueryWithJoin); } @@ -147,9 +154,11 @@ public async Task TestStringFiltersOnNestedArrayType() }"; string dbQueryWithJoin = "SELECT c.name FROM c " + - "JOIN a IN c.moons " + - "JOIN b IN a.moonAdditionalAttributes " + - "WHERE b.name = \"moonattr0\""; + "WHERE EXISTS (SELECT 1 " + + "FROM table0 IN c.moons " + + "WHERE EXISTS (SELECT 1 " + + "FROM table1 IN table0.moonAdditionalAttributes " + + "WHERE table1.name = \"moonattr0\" ))"; await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQueryWithJoin); } @@ -171,10 +180,13 @@ public async Task TestStringFiltersOnTwoLevelNestedArrayType() }"; string dbQueryWithJoin = "SELECT c.name FROM c " + - "JOIN a IN c.moons " + - "JOIN b IN a.moonAdditionalAttributes " + - "JOIN d IN b.moreAttributes " + - "WHERE d.name = \"moonattr0\""; + "WHERE EXISTS(SELECT 1 " + + "FROM table0 IN c.moons " + + "WHERE EXISTS(SELECT 1 " + + "FROM table1 IN table0.moonAdditionalAttributes " + + "WHERE EXISTS(SELECT 1 " + + "FROM table2 IN table1.moreAttributes " + + "WHERE table2.name = \"moonattr0\")))"; await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQueryWithJoin); } @@ -254,10 +266,14 @@ public async Task TestStringMultiFiltersOnArrayTypeWithOrCondition() } }"; - string dbQueryWithJoin = "SELECT c.name FROM c " + - "JOIN a IN c.additionalAttributes " + - "JOIN b IN c.moons " + - "WHERE a.name = \"volcano1\" OR b.name = \"1 moon\""; + string dbQueryWithJoin = "SELECT c.name " + + "FROM c " + + "WHERE (EXISTS (SELECT 1 " + + "FROM table0 IN c.additionalAttributes " + + "WHERE table0.name = \"volcano1\" ) OR " + + "EXISTS (SELECT 1 " + + "FROM table2 IN c.moons " + + "WHERE table2.name = \"1 moon\" ) )"; await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQueryWithJoin); } @@ -881,6 +897,51 @@ public async Task TestFilterWithEntityNameAlias() await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery); } + /// + /// For "item-level-permission-role" role, DB policies are defined. This test confirms that all the DB policies are considered. + /// For the reference, Below conditions are applied for an Entity in Db Config file. + /// MoonAdditionalAttributes (array inside moon object which is an array in container): "@item.name eq 'moonattr0'" + /// Earth(object in object): "@item.type eq 'earth0'" + /// AdditionalAttribute (array in container): "@item.type eq 'volcano0'" + /// + [TestMethod] + public async Task TestQueryFilterFieldAuth_Only_AuthorizedArrayItem() + { + string gqlQuery = @"{ + planets(first: 1, " + QueryBuilder.FILTER_FIELD_NAME + @" : { character: {type: {eq: ""Mars""}}}) + { + items { + id + } + } + }"; + + // Now get the item with item level permission + string clientRoleHeader = "item-level-permission-role"; + // string clientRoleHeader = "authenticated"; + JsonElement actual = await ExecuteGraphQLRequestAsync( + queryName: _graphQLQueryName, + query: gqlQuery, + authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), + clientRoleHeader: clientRoleHeader); + + string dbQuery = $"SELECT c.id " + + $"FROM c " + + $"WHERE c.character.type = 'Mars' " + + $"AND c.earth.type = 'earth0' " + // From DB Policy + $"AND EXISTS (SELECT VALUE 1 " + + $"FROM table1 IN c.additionalAttributes " + + $"WHERE (table1.name = 'volcano0')) " + // From DB Policy + $"AND EXISTS (SELECT VALUE 1 " + + $"FROM table2 IN c.moons " + + $"JOIN table3 IN table2.moonAdditionalAttributes " + + $"WHERE (table3.name = 'moonattr0'))"; // From DB Policy + + JsonDocument expected = await ExecuteCosmosRequestAsync(dbQuery, _pageSize, null, _containerName); + // Validate the result contains the GraphQL authorization error code. + ValidateResults(actual.GetProperty("items"), expected.RootElement, false); + } + #region Field Level Auth /// /// Tests that the field level query filter succeeds requests when filter fields are authorized @@ -1018,7 +1079,7 @@ public async Task TestQueryFilterNestedFieldAuth_UnauthorizedNestedField() /// /// Tests that the nested field level query filter fails authorization when nested object is - /// unauthorized. Here, Nested array type 'moreAttributes' is avaliable for 'Authenticated' role only and + /// unauthorized. Here, Nested array type 'moreAttributes' is available for 'Authenticated' role only and /// we are trying to access it with 'anonymous' role. /// [TestMethod] diff --git a/src/Service.Tests/CosmosTests/QueryTests.cs b/src/Service.Tests/CosmosTests/QueryTests.cs index f7be33d833..3dc9732c7c 100644 --- a/src/Service.Tests/CosmosTests/QueryTests.cs +++ b/src/Service.Tests/CosmosTests/QueryTests.cs @@ -59,10 +59,9 @@ public class QueryTests : TestBase "; public static readonly string MoonWithInvalidAuthorizationPolicy = @" query ($id: ID, $partitionKeyValue: String) { - moon_by_pk (id: $id, _partitionKeyValue: $partitionKeyValue){ + invalidAuthModel_by_pk (id: $id, _partitionKeyValue: $partitionKeyValue){ id name - details } }"; private static List _idList; @@ -103,7 +102,7 @@ public async Task GetWithInvalidAuthorizationPolicyInSchema() string id = _idList[0]; string clientRoleHeader = AuthorizationType.Authenticated.ToString(); JsonElement response = await ExecuteGraphQLRequestAsync( - queryName: "moon_by_pk", + queryName: "invalidAuthModel_by_pk", query: MoonWithInvalidAuthorizationPolicy, variables: new() { { "id", id }, { "partitionKeyValue", id } }, authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), @@ -307,11 +306,11 @@ public async Task GetByPrimaryKeyWhenEntityNameDoesntMatchGraphQLType() string id = _idList[0]; string query = @$" query {{ - star_by_pk (id: ""{id}"", _partitionKeyValue: ""{id}"") {{ + planet_by_pk (id: ""{id}"", _partitionKeyValue: ""{id}"") {{ id }} }}"; - JsonElement response = await ExecuteGraphQLRequestAsync("star_by_pk", query); + JsonElement response = await ExecuteGraphQLRequestAsync("planet_by_pk", query); // Validate results Assert.AreEqual(id, response.GetProperty("id").GetString()); diff --git a/src/Service.Tests/CosmosTests/TestBase.cs b/src/Service.Tests/CosmosTests/TestBase.cs index c4d4a1031b..12e881f52e 100644 --- a/src/Service.Tests/CosmosTests/TestBase.cs +++ b/src/Service.Tests/CosmosTests/TestBase.cs @@ -30,10 +30,10 @@ namespace Azure.DataApiBuilder.Service.Tests.CosmosTests; public class TestBase { internal const string DATABASE_NAME = "graphqldb"; - // Intentionally removed name attibute from Planet model to test scenario where the 'name' attribute + // Intentionally removed name attribute from Planet model to test scenario where the 'name' attribute // is not explicitly added in the schema internal const string GRAPHQL_SCHEMA = @" -type Character @model(name:""Character"") { +type Character { id : ID, name : String, type: String, @@ -42,7 +42,7 @@ type Character @model(name:""Character"") { star: Star } -type Planet @model { +type Planet @model(name:""PlanetAlias"") { id : ID!, name : String, character: Character, @@ -56,51 +56,64 @@ type Planet @model { suns: [Sun] } -type Star @model(name:""StarAlias"") { +type Star { id : ID, name : String, tag: Tag } -type Tag @model(name:""TagAlias"") { +type Tag { id : ID, name : String } -type Moon @model(name:""Moon"") @authorize(policy: ""Crater"") { +type Moon { id : ID, name : String, details : String, moonAdditionalAttributes: [MoonAdditionalAttribute] } -type Earth @model(name:""Earth"") { +type Earth { id : ID, name : String, type: String @authorize(roles: [""authenticated""]) } -type Sun @model(name:""Sun"") { +type Sun { id : ID, name : String } -type AdditionalAttribute @model(name:""AdditionalAttribute"") { +type AdditionalAttribute { id : ID, - name : String + name : String, + type: String } -type MoonAdditionalAttribute @model(name:""MoonAdditionalAttribute"") { +type MoonAdditionalAttribute { id : ID, name : String, moreAttributes: [MoreAttribute!] } -type MoreAttribute @model(name:""MoreAttrAlias"") { +type MoreAttribute { + id : ID, + name : String, + type: String @authorize(roles: [""authenticated""]) +} + +type InvalidAuthModel @model @authorize(policy: ""Crater"") { + id : ID!, + name : String +} + +type PlanetAgain @model { id : ID, name : String, type: String @authorize(roles: [""authenticated""]) -}"; +} +"; private static string[] _planets = { "Earth", "Mars", "Jupiter", "Tatooine", "Endor", "Dagobah", "Hoth", "Bespin", "Spec%ial" }; diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt index be3f09b998..51d8543ed5 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt @@ -31,7 +31,7 @@ }, Entities: [ { - Planet: { + PlanetAlias: { Source: { Object: graphqldb.planet }, @@ -90,6 +90,14 @@ Action: Read } ] + }, + { + Role: item-level-permission-role, + Actions: [ + { + Action: Read + } + ] } ] } @@ -124,12 +132,20 @@ Action: Delete } ] + }, + { + Role: item-level-permission-role, + Actions: [ + { + Action: Read + } + ] } ] } }, { - StarAlias: { + Star: { Source: { Object: graphqldb.planet }, @@ -180,7 +196,7 @@ } }, { - TagAlias: { + Tag: { Source: { Object: graphqldb.planet }, @@ -282,42 +298,32 @@ Role: field-mutation-with-read-permission, Actions: [ { - Action: Update, + Action: Read + } + ] + }, + { + Role: anonymous, + Actions: [ + { + Action: Read, Fields: { Exclude: [ - name + * ], Include: [ - id, - type + * ] } }, { - Action: Delete, - Fields: { - Exclude: [ - name - ], - Include: [ - id, - type - ] - } + Action: Create }, { - Action: Create, - Fields: { - Exclude: [ - name - ], - Include: [ - id - ] - } + Action: Update }, { - Action: Read + Action: Delete } ] }, @@ -356,63 +362,13 @@ ] }, { - Role: wildcard-exclude-fields-role, + Role: item-level-permission-role, Actions: [ { Action: Read, - Fields: { - Exclude: [ - * - ] - } - }, - { - Action: Delete, - Fields: { - Exclude: [ - * - ] + Policy: { + Database: @item.type eq 'earth0' } - }, - { - Action: Update, - Fields: { - Exclude: [ - * - ] - } - }, - { - Action: Create, - Fields: { - Exclude: [ - * - ] - } - } - ] - }, - { - Role: only-create-role, - Actions: [ - { - Action: Create - } - ] - }, - { - Role: only-update-role, - Actions: [ - { - Action: Update - } - ] - }, - { - Role: only-delete-role, - Actions: [ - { - Action: Delete } ] } @@ -482,6 +438,17 @@ Action: * } ] + }, + { + Role: item-level-permission-role, + Actions: [ + { + Action: Read, + Policy: { + Database: @item.name eq 'volcano0' + } + } + ] } ] } @@ -507,12 +474,23 @@ Action: * } ] + }, + { + Role: item-level-permission-role, + Actions: [ + { + Action: Read, + Policy: { + Database: @item.name eq 'moonattr0' + } + } + ] } ] } }, { - MoreAttrAlias: { + MoreAttribute: { Source: { Object: graphqldb.planet }, @@ -582,6 +560,233 @@ } ] } + }, + { + PlanetAgain: { + Source: { + Object: graphqldb.newcontainer + }, + GraphQL: { + Singular: PlanetAgain, + Plural: PlanetAgains, + Enabled: true + }, + Rest: { + Enabled: false + }, + Permissions: [ + { + Role: field-mutation-with-read-permission, + Actions: [ + { + Action: Update, + Fields: { + Exclude: [ + name + ], + Include: [ + id, + type + ] + } + }, + { + Action: Delete, + Fields: { + Exclude: [ + name + ], + Include: [ + id, + type + ] + } + }, + { + Action: Create, + Fields: { + Exclude: [ + name + ], + Include: [ + id + ] + } + }, + { + Action: Read + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: Create + }, + { + Action: Read + }, + { + Action: Update + }, + { + Action: Delete + } + ] + }, + { + Role: limited-read-role, + Actions: [ + { + Action: Read, + Fields: { + Exclude: [ + name + ], + Include: [ + id, + type + ] + } + } + ] + }, + { + Role: wildcard-exclude-fields-role, + Actions: [ + { + Action: Read, + Fields: { + Exclude: [ + * + ] + } + }, + { + Action: Delete, + Fields: { + Exclude: [ + * + ] + } + }, + { + Action: Update, + Fields: { + Exclude: [ + * + ] + } + }, + { + Action: Create, + Fields: { + Exclude: [ + * + ] + } + } + ] + }, + { + Role: only-create-role, + Actions: [ + { + Action: Create + } + ] + }, + { + Role: only-update-role, + Actions: [ + { + Action: Update + } + ] + }, + { + Role: only-delete-role, + Actions: [ + { + Action: Delete + } + ] + } + ] + } + }, + { + InvalidAuthModel: { + Source: { + Object: graphqldb.invalidAuthModelContainer + }, + GraphQL: { + Singular: InvalidAuthModel, + Plural: InvalidAuthModels, + Enabled: true + }, + Rest: { + Enabled: false + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Update, + Fields: { + Exclude: [ + * + ] + } + }, + { + Action: Read, + Fields: { + Exclude: [ + name + ], + Include: [ + id + ] + } + }, + { + Action: Create, + Fields: { + Exclude: [ + name + ], + Include: [ + id + ] + } + }, + { + Action: Delete + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: Create + }, + { + Action: Read + }, + { + Action: Update + }, + { + Action: Delete + } + ] + } + ] + } } ] } \ No newline at end of file diff --git a/src/Service.Tests/dab-config.CosmosDb_NoSql.json b/src/Service.Tests/dab-config.CosmosDb_NoSql.json index c9f429929e..c55314e592 100644 --- a/src/Service.Tests/dab-config.CosmosDb_NoSql.json +++ b/src/Service.Tests/dab-config.CosmosDb_NoSql.json @@ -34,7 +34,7 @@ } }, "entities": { - "Planet": { + "PlanetAlias": { "source": { "object": "graphqldb.planet" }, @@ -96,6 +96,14 @@ "action": "read" } ] + }, + { + "role": "item-level-permission-role", + "actions": [ + { + "action": "read" + } + ] } ] }, @@ -130,10 +138,18 @@ "action": "delete" } ] + }, + { + "role": "item-level-permission-role", + "actions": [ + { + "action": "read" + } + ] } ] }, - "StarAlias": { + "Star": { "source": { "object": "graphqldb.planet" }, @@ -184,7 +200,7 @@ } ] }, - "TagAlias": { + "Tag": { "source": { "object": "graphqldb.planet" }, @@ -288,42 +304,32 @@ "role": "field-mutation-with-read-permission", "actions": [ { - "action": "update", + "action": "read" + } + ] + }, + { + "role": "anonymous", + "actions": [ + { + "action": "read", "fields": { "exclude": [ - "name" + "*" ], "include": [ - "id", - "type" + "*" ] } }, { - "action": "delete", - "fields": { - "exclude": [ - "name" - ], - "include": [ - "id", - "type" - ] - } + "action": "create" }, { - "action": "create", - "fields": { - "exclude": [ - "name" - ], - "include": [ - "id" - ] - } + "action": "update" }, { - "action": "read" + "action": "delete" } ] }, @@ -362,63 +368,13 @@ ] }, { - "role": "wildcard-exclude-fields-role", + "role": "item-level-permission-role", "actions": [ { "action": "read", - "fields": { - "exclude": [ - "*" - ] - } - }, - { - "action": "delete", - "fields": { - "exclude": [ - "*" - ] + "policy": { + "database": "@item.type eq 'earth0'" } - }, - { - "action": "update", - "fields": { - "exclude": [ - "*" - ] - } - }, - { - "action": "create", - "fields": { - "exclude": [ - "*" - ] - } - } - ] - }, - { - "role": "only-create-role", - "actions": [ - { - "action": "create" - } - ] - }, - { - "role": "only-update-role", - "actions": [ - { - "action": "update" - } - ] - }, - { - "role": "only-delete-role", - "actions": [ - { - "action": "delete" } ] } @@ -488,6 +444,17 @@ "action": "*" } ] + }, + { + "role": "item-level-permission-role", + "actions": [ + { + "action": "read", + "policy": { + "database": "@item.name eq 'volcano0'" + } + } + ] } ] }, @@ -513,10 +480,21 @@ "action": "*" } ] + }, + { + "role": "item-level-permission-role", + "actions": [ + { + "action": "read", + "policy": { + "database": "@item.name eq 'moonattr0'" + } + } + ] } ] }, - "MoreAttrAlias": { + "MoreAttribute": { "source": { "object": "graphqldb.planet" }, @@ -587,6 +565,233 @@ ] } ] + }, + "PlanetAgain": { + "source": { + "object": "graphqldb.newcontainer" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "PlanetAgain", + "plural": "PlanetAgains" + } + }, + "rest": { + "enabled": false + }, + "permissions": [ + { + "role": "field-mutation-with-read-permission", + "actions": [ + { + "action": "update", + "fields": { + "exclude": [ + "name" + ], + "include": [ + "id", + "type" + ] + } + }, + { + "action": "delete", + "fields": { + "exclude": [ + "name" + ], + "include": [ + "id", + "type" + ] + } + }, + { + "action": "create", + "fields": { + "exclude": [ + "name" + ], + "include": [ + "id" + ] + } + }, + { + "action": "read" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + }, + { + "role": "limited-read-role", + "actions": [ + { + "action": "read", + "fields": { + "exclude": [ + "name" + ], + "include": [ + "id", + "type" + ] + } + } + ] + }, + { + "role": "wildcard-exclude-fields-role", + "actions": [ + { + "action": "read", + "fields": { + "exclude": [ + "*" + ] + } + }, + { + "action": "delete", + "fields": { + "exclude": [ + "*" + ] + } + }, + { + "action": "update", + "fields": { + "exclude": [ + "*" + ] + } + }, + { + "action": "create", + "fields": { + "exclude": [ + "*" + ] + } + } + ] + }, + { + "role": "only-create-role", + "actions": [ + { + "action": "create" + } + ] + }, + { + "role": "only-update-role", + "actions": [ + { + "action": "update" + } + ] + }, + { + "role": "only-delete-role", + "actions": [ + { + "action": "delete" + } + ] + } + ] + }, + "InvalidAuthModel": { + "source": { + "object": "graphqldb.invalidAuthModelContainer" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "InvalidAuthModel", + "plural": "InvalidAuthModels" + } + }, + "rest": { + "enabled": false + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "update", + "fields": { + "exclude": [ + "*" + ] + } + }, + { + "action": "read", + "fields": { + "exclude": [ + "name" + ], + "include": [ + "id" + ] + } + }, + { + "action": "create", + "fields": { + "exclude": [ + "name" + ], + "include": [ + "id" + ] + } + }, + { + "action": "delete" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + } + ] } } } \ No newline at end of file