diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index c4af5e2f77..6b97f3ebfb 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -14,6 +14,7 @@ add stocks_price --config "dab-config.MsSql.json" --source stocks_price --permis update stocks_price --config "dab-config.MsSql.json" --permissions "anonymous:read" update stocks_price --config "dab-config.MsSql.json" --permissions "TestNestedFilterFieldIsNull_ColumnForbidden:read" --fields.exclude "price" update stocks_price --config "dab-config.MsSql.json" --permissions "TestNestedFilterFieldIsNull_EntityReadForbidden:create" +update stocks_price --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_create:create,read,update,delete" add Tree --config "dab-config.MsSql.json" --source trees --permissions "anonymous:create,read,update,delete" add Shrub --config "dab-config.MsSql.json" --source trees --permissions "anonymous:create,read,update,delete" --rest plants add Fungus --config "dab-config.MsSql.json" --source fungi --permissions "anonymous:create,read,update,delete" --graphql "fungus:fungi" @@ -65,6 +66,8 @@ update Publisher --config "dab-config.MsSql.json" --permissions "database_policy update Publisher --config "dab-config.MsSql.json" --permissions "database_policy_tester:update" --policy-database "@item.id ne 1234" update Publisher --config "dab-config.MsSql.json" --permissions "database_policy_tester:create" --policy-database "@item.name ne 'New publisher'" update Stock --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" +update Stock --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_create:read,update,delete" +update Stock --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_create:create" --fields.exclude "piecesAvailable" update Stock --config "dab-config.MsSql.json" --rest commodities --graphql true --relationship stocks_price --target.entity stocks_price --cardinality one update Book --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" update Book --config "dab-config.MsSql.json" --relationship publishers --target.entity Publisher --cardinality one @@ -112,6 +115,7 @@ update WebsiteUser --config "dab-config.MsSql.json" --permissions "authenticated update Revenue --config "dab-config.MsSql.json" --permissions "database_policy_tester:create" --policy-database "@item.revenue gt 1000" update Comic --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship myseries --target.entity series --cardinality one update series --config "dab-config.MsSql.json" --relationship comics --target.entity Comic --cardinality many +update stocks_price --config "dab-config.MsSql.json" --relationship Stock --target.entity Stock --cardinality one update Broker --config "dab-config.MsSql.json" --permissions "authenticated:create,update,read,delete" --graphql false update Tree --config "dab-config.MsSql.json" --rest true --graphql false --permissions "authenticated:create,read,update,delete" --map "species:Scientific Name,region:United State's Region" update Shrub --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --map "species:fancyName" diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 93e4622f29..64785de703 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -13,6 +13,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Service.Exceptions; +using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -213,6 +214,31 @@ public string GetDBPolicyForRequest(string entityName, string roleName, EntityAc return dbPolicy is not null ? dbPolicy : string.Empty; } + /// + /// Helper method to get the role with which the GraphQL API request was executed. + /// + /// HotChocolate context for the GraphQL request. + /// Role of the current GraphQL API request. + /// Throws exception when no client role could be inferred from the context. + public static string GetRoleOfGraphQLRequest(IMiddlewareContext context) + { + string role = string.Empty; + if (context.ContextData.TryGetValue(key: CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals) + { + role = stringVals.ToString(); + } + + if (string.IsNullOrEmpty(role)) + { + throw new DataApiBuilderException( + message: "No ClientRoleHeader available to perform authorization.", + statusCode: HttpStatusCode.Forbidden, + subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed); + } + + return role; + } + #region Helpers /// /// Method to read in data from the config class into a Dictionary for quick lookup diff --git a/src/Core/Resolvers/CosmosMutationEngine.cs b/src/Core/Resolvers/CosmosMutationEngine.cs index 4f95e4f267..7138db6223 100644 --- a/src/Core/Resolvers/CosmosMutationEngine.cs +++ b/src/Core/Resolvers/CosmosMutationEngine.cs @@ -16,7 +16,6 @@ using HotChocolate.Resolvers; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Cosmos; -using Microsoft.Extensions.Primitives; using Newtonsoft.Json.Linq; namespace Azure.DataApiBuilder.Core.Resolvers @@ -64,7 +63,7 @@ private async Task ExecuteAsync(IMiddlewareContext context, IDictionary // If authorization fails, an exception will be thrown and request execution halts. string graphQLType = context.Selection.Field.Type.NamedType().Name.Value; string entityName = metadataProvider.GetEntityName(graphQLType); - AuthorizeMutationFields(context, queryArgs, entityName, resolver.OperationType); + AuthorizeMutation(context, queryArgs, entityName, resolver.OperationType); ItemResponse? response = resolver.OperationType switch { @@ -74,7 +73,7 @@ private async Task ExecuteAsync(IMiddlewareContext context, IDictionary _ => throw new NotSupportedException($"unsupported operation type: {resolver.OperationType}") }; - string roleName = GetRoleOfGraphQLRequest(context); + string roleName = AuthorizationResolver.GetRoleOfGraphQLRequest(context); // The presence of READ permission is checked in the current role (with which the request is executed) as well as Anonymous role. This is because, for GraphQL requests, // READ permission is inherited by other roles from Anonymous role when present. @@ -93,14 +92,13 @@ private async Task ExecuteAsync(IMiddlewareContext context, IDictionary } /// - public void AuthorizeMutationFields( + public void AuthorizeMutation( IMiddlewareContext context, IDictionary parameters, string entityName, EntityActionOperation mutationOperation) { - string role = GetRoleOfGraphQLRequest(context); - + string clientRole = AuthorizationResolver.GetRoleOfGraphQLRequest(context); List inputArgumentKeys; if (mutationOperation != EntityActionOperation.Delete) { @@ -114,9 +112,9 @@ public void AuthorizeMutationFields( bool isAuthorized = mutationOperation switch { EntityActionOperation.UpdateGraphQL => - _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: EntityActionOperation.Update, inputArgumentKeys), + _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: EntityActionOperation.Update, inputArgumentKeys), EntityActionOperation.Create => - _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: mutationOperation, inputArgumentKeys), + _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: mutationOperation, inputArgumentKeys), EntityActionOperation.Delete => true,// Field level authorization is not supported for delete mutations. A requestor must be authorized // to perform the delete operation on the entity to reach this point. _ => throw new DataApiBuilderException( @@ -261,29 +259,6 @@ private static async Task> HandleUpdateAsync(IDictionary - /// Helper method to get the role with which the GraphQL API request was executed. - /// - /// HotChocolate context for the GraphQL request - private static string GetRoleOfGraphQLRequest(IMiddlewareContext context) - { - string role = string.Empty; - if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals) - { - role = stringVals.ToString(); - } - - if (string.IsNullOrEmpty(role)) - { - throw new DataApiBuilderException( - message: "No ClientRoleHeader available to perform authorization.", - statusCode: HttpStatusCode.Unauthorized, - subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed); - } - - return role; - } - /// /// The method is for parsing the mutation input object with nested inner objects when input is passing inline. /// diff --git a/src/Core/Resolvers/IMutationEngine.cs b/src/Core/Resolvers/IMutationEngine.cs index da089ed199..30cb188efc 100644 --- a/src/Core/Resolvers/IMutationEngine.cs +++ b/src/Core/Resolvers/IMutationEngine.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Service.Exceptions; using HotChocolate.Resolvers; using Microsoft.AspNetCore.Mvc; @@ -41,12 +42,13 @@ public interface IMutationEngine /// /// Authorization check on mutation fields provided in a GraphQL Mutation request. /// - /// Middleware context of the mutation + /// GraphQL request context. + /// Client role header value extracted from the middleware context of the mutation /// parameters in the mutation query. /// entity name /// mutation operation /// - public void AuthorizeMutationFields( + public void AuthorizeMutation( IMiddlewareContext context, IDictionary parameters, string entityName, diff --git a/src/Core/Resolvers/SqlMutationEngine.cs b/src/Core/Resolvers/SqlMutationEngine.cs index 298a7d5d72..63de3f0f87 100644 --- a/src/Core/Resolvers/SqlMutationEngine.cs +++ b/src/Core/Resolvers/SqlMutationEngine.cs @@ -19,11 +19,12 @@ using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder; using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; +using Azure.DataApiBuilder.Service.Services; +using HotChocolate.Language; using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Primitives; namespace Azure.DataApiBuilder.Core.Resolvers { @@ -90,11 +91,10 @@ public SqlMutationEngine( Tuple? result = null; EntityActionOperation mutationOperation = MutationBuilder.DetermineMutationOperationTypeBasedOnInputType(graphqlMutationName); + string roleName = AuthorizationResolver.GetRoleOfGraphQLRequest(context); // If authorization fails, an exception will be thrown and request execution halts. - AuthorizeMutationFields(context, parameters, entityName, mutationOperation); - - string roleName = GetRoleOfGraphQLRequest(context); + AuthorizeMutation(context, parameters, entityName, mutationOperation); // The presence of READ permission is checked in the current role (with which the request is executed) as well as Anonymous role. This is because, for GraphQL requests, // READ permission is inherited by other roles from Anonymous role when present. @@ -218,6 +218,36 @@ await PerformMutationOperation( return result; } + /// + /// Helper method to determine whether a mutation is a mutate one or mutate many operation (eg. createBook/createBooks). + /// + /// GraphQL request context. + private static bool IsPointMutation(IMiddlewareContext context) + { + IOutputType outputType = context.Selection.Field.Type; + if (outputType.TypeName().Value.Equals(GraphQLUtils.DB_OPERATION_RESULT_TYPE)) + { + // Hit when the database type is DwSql. We don't support multiple mutation for DwSql yet. + return true; + } + + ObjectType underlyingFieldType = GraphQLUtils.UnderlyingGraphQLEntityType(outputType); + bool isPointMutation; + if (GraphQLUtils.TryExtractGraphQLFieldModelName(underlyingFieldType.Directives, out string? _)) + { + isPointMutation = true; + } + else + { + // Model directive is not added to the output type of 'mutate many' mutations. + // Thus, absence of model directive here indicates that we are dealing with a 'mutate many' + // mutation like createBooks. + isPointMutation = false; + } + + return isPointMutation; + } + /// /// Converts exposed column names from the parameters provided to backing column names. /// parameters.Value is not modified. @@ -1049,41 +1079,58 @@ private void PopulateParamsFromRestRequest(Dictionary parameter } } - /// - /// Authorization check on mutation fields provided in a GraphQL Mutation request. - /// - /// - /// - /// - /// - /// - public void AuthorizeMutationFields( + /// + public void AuthorizeMutation( IMiddlewareContext context, IDictionary parameters, string entityName, EntityActionOperation mutationOperation) { - string role = GetRoleOfGraphQLRequest(context); - - List inputArgumentKeys; - if (mutationOperation != EntityActionOperation.Delete) + string inputArgumentName = MutationBuilder.ITEM_INPUT_ARGUMENT_NAME; + string clientRole = AuthorizationResolver.GetRoleOfGraphQLRequest(context); + if (mutationOperation is EntityActionOperation.Create) { - inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, parameters); + if (!IsPointMutation(context)) + { + inputArgumentName = MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME; + } + + AuthorizeEntityAndFieldsForMutation(context, clientRole, entityName, mutationOperation, inputArgumentName, parameters); } else { - inputArgumentKeys = parameters.Keys.ToList(); + List inputArgumentKeys; + if (mutationOperation != EntityActionOperation.Delete) + { + inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(inputArgumentName, parameters); + } + else + { + inputArgumentKeys = parameters.Keys.ToList(); + } + + if (!AreFieldsAuthorizedForEntity(clientRole, entityName, mutationOperation, inputArgumentKeys)) + { + throw new DataApiBuilderException( + message: "Unauthorized due to one or more fields in this mutation.", + statusCode: HttpStatusCode.Forbidden, + subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed + ); + } } + } + private bool AreFieldsAuthorizedForEntity(string clientRole, string entityName, EntityActionOperation mutationOperation, IEnumerable inputArgumentKeys) + { bool isAuthorized; // False by default. switch (mutationOperation) { case EntityActionOperation.UpdateGraphQL: - isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: EntityActionOperation.Update, inputArgumentKeys); + isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: EntityActionOperation.Update, inputArgumentKeys); break; case EntityActionOperation.Create: - isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: mutationOperation, inputArgumentKeys); + isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: mutationOperation, inputArgumentKeys); break; case EntityActionOperation.Execute: case EntityActionOperation.Delete: @@ -1101,37 +1148,219 @@ public void AuthorizeMutationFields( ); } - if (!isAuthorized) + return isAuthorized; + } + + /// + /// Performs authorization checks on entity level permissions and field level permissions for every entity and field + /// referenced in a GraphQL mutation for the given client role. + /// + /// Middleware context. + /// Client role header value extracted from the middleware context of the mutation + /// Top level entity name. + /// Mutation operation + /// Name of the input argument (differs based on point/multiple mutation). + /// Dictionary of key/value pairs for the argument name/value. + /// Throws exception when an authorization check fails. + private void AuthorizeEntityAndFieldsForMutation( + IMiddlewareContext context, + string clientRole, + string topLevelEntityName, + EntityActionOperation operation, + string inputArgumentName, + IDictionary parametersDictionary + ) + { + if (context.Selection.Field.Arguments.TryGetField(inputArgumentName, out IInputField? schemaForArgument)) + { + // Dictionary to store all the entities and their corresponding exposed column names referenced in the mutation. + Dictionary> entityToExposedColumns = new(); + if (parametersDictionary.TryGetValue(inputArgumentName, out object? parameters)) + { + // Get all the entity names and field names referenced in the mutation. + PopulateMutationEntityAndFieldsToAuthorize(entityToExposedColumns, schemaForArgument, topLevelEntityName, context, parameters!); + } + else + { + throw new DataApiBuilderException( + message: $"{inputArgumentName} cannot be null for mutation:{context.Selection.Field.Name.Value}.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest + ); + } + + // Perform authorization checks at field level. + foreach ((string entityNameInMutation, HashSet exposedColumnsInEntity) in entityToExposedColumns) + { + if (!AreFieldsAuthorizedForEntity(clientRole, entityNameInMutation, operation, exposedColumnsInEntity)) + { + throw new DataApiBuilderException( + message: $"Unauthorized due to one or more fields in this mutation.", + statusCode: HttpStatusCode.Forbidden, + subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed + ); + } + } + } + else { throw new DataApiBuilderException( - message: "Unauthorized due to one or more fields in this mutation.", - statusCode: HttpStatusCode.Forbidden, - subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed - ); + message: $"Could not interpret the schema for the input argument: {inputArgumentName}", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } } /// - /// Helper method to get the role with which the GraphQL API request was executed. + /// Helper method to collect names of all the fields referenced from every entity in a GraphQL mutation. /// - /// HotChocolate context for the GraphQL request - private static string GetRoleOfGraphQLRequest(IMiddlewareContext context) + /// Dictionary to store all the entities and their corresponding exposed column names referenced in the mutation. + /// Schema for the input field. + /// Name of the entity. + /// Middleware Context. + /// Value for the input field. + /// 1. mutation { + /// createbook( + /// item: { + /// title: "book #1", + /// reviews: [{ content: "Good book." }, { content: "Great book." }], + /// publishers: { name: "Macmillan publishers" }, + /// authors: [{ birthdate: "1997-09-03", name: "Red house authors", author_name: "Dan Brown" }] + /// }) + /// { + /// id + /// } + /// 2. mutation { + /// createbooks( + /// items: [{ + /// title: "book #1", + /// reviews: [{ content: "Good book." }, { content: "Great book." }], + /// publishers: { name: "Macmillan publishers" }, + /// authors: [{ birthdate: "1997-09-03", name: "Red house authors", author_name: "Dan Brown" }] + /// }, + /// { + /// title: "book #2", + /// reviews: [{ content: "Awesome book." }, { content: "Average book." }], + /// publishers: { name: "Pearson Education" }, + /// authors: [{ birthdate: "1990-11-04", name: "Penguin Random House", author_name: "William Shakespeare" }] + /// }]) + /// { + /// items{ + /// id + /// title + /// } + /// } + private void PopulateMutationEntityAndFieldsToAuthorize( + Dictionary> entityToExposedColumns, + IInputField schema, + string entityName, + IMiddlewareContext context, + object parameters) { - string role = string.Empty; - if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals) + if (parameters is List listOfObjectFieldNode) { - role = stringVals.ToString(); + // For the example createbook mutation written above, the object value for `item` is interpreted as a List i.e. + // all the fields present for item namely- title, reviews, publishers, authors are interpreted as ObjectFieldNode. + ProcessObjectFieldNodesForAuthZ( + entityToExposedColumns: entityToExposedColumns, + schemaObject: ExecutionHelper.InputObjectTypeFromIInputField(schema), + entityName: entityName, + context: context, + fieldNodes: listOfObjectFieldNode); } - - if (string.IsNullOrEmpty(role)) + else if (parameters is List listOfIValueNode) { - throw new DataApiBuilderException( - message: "No ClientRoleHeader available to perform authorization.", - statusCode: HttpStatusCode.Unauthorized, - subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed); + // For the example createbooks mutation written above, the list value for `items` is interpreted as a List. + listOfIValueNode.ForEach(iValueNode => PopulateMutationEntityAndFieldsToAuthorize( + entityToExposedColumns: entityToExposedColumns, + schema: schema, + entityName: entityName, + context: context, + parameters: iValueNode)); + } + else if (parameters is ObjectValueNode objectValueNode) + { + // For the example createbook mutation written above, the node for publishers field is interpreted as an ObjectValueNode. + // Similarly the individual node (elements in the list) for the reviews, authors ListValueNode(s) are also interpreted as ObjectValueNode(s). + ProcessObjectFieldNodesForAuthZ( + entityToExposedColumns: entityToExposedColumns, + schemaObject: ExecutionHelper.InputObjectTypeFromIInputField(schema), + entityName: entityName, + context: context, + fieldNodes: objectValueNode.Fields); + } + else + { + ListValueNode listValueNode = (ListValueNode)parameters; + // For the example createbook mutation written above, the list values for reviews and authors fields are interpreted as ListValueNode. + // All the nodes in the ListValueNode are parsed one by one. + listValueNode.GetNodes().ToList().ForEach(objectValueNodeInListValueNode => PopulateMutationEntityAndFieldsToAuthorize( + entityToExposedColumns: entityToExposedColumns, + schema: schema, + entityName: entityName, + context: context, + parameters: objectValueNodeInListValueNode)); } + } - return role; + /// + /// Helper method to iterate over all the fields present in the input for the current field and add it to the dictionary + /// containing all entities and their corresponding fields. + /// + /// Dictionary to store all the entities and their corresponding exposed column names referenced in the mutation. + /// Input object type for the field. + /// Name of the entity. + /// Middleware context. + /// List of ObjectFieldNodes for the the input field. + private void ProcessObjectFieldNodesForAuthZ( + Dictionary> entityToExposedColumns, + InputObjectType schemaObject, + string entityName, + IMiddlewareContext context, + IReadOnlyList fieldNodes) + { + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + entityToExposedColumns.TryAdd(entityName, new HashSet()); + string dataSourceName = GraphQLUtils.GetDataSourceNameFromGraphQLContext(context, runtimeConfig); + ISqlMetadataProvider metadataProvider = _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName); + foreach (ObjectFieldNode field in fieldNodes) + { + Tuple fieldDetails = GraphQLUtils.GetFieldDetails(field.Value, context.Variables); + SyntaxKind underlyingFieldKind = fieldDetails.Item2; + + // For a column field, we do not have to recurse to process fields in the value - which is required for relationship fields. + if (GraphQLUtils.IsScalarField(underlyingFieldKind) || underlyingFieldKind is SyntaxKind.NullValue) + { + // This code block can be hit in 3 cases: + // Case 1. We are processing a column which belongs to this entity, + // + // Case 2. We are processing the fields for a linking input object. Linking input objects enable users to provide + // input for fields belonging to the target entity and the linking entity. Hence the backing column for fields + // belonging to the linking entity will not be present in the source definition of this target entity. + // We need to skip such fields belonging to linking table as we do not perform authorization checks on them. + // + // Case 3. When a relationship field is assigned a null value. Such a field also needs to be ignored. + if (metadataProvider.TryGetBackingColumn(entityName, field.Name.Value, out string? _)) + { + // Only add those fields to this entity's set of fields which belong to this entity and not the linking entity, + // i.e. for Case 1. + entityToExposedColumns[entityName].Add(field.Name.Value); + } + } + else + { + string relationshipName = field.Name.Value; + string targetEntityName = runtimeConfig.Entities![entityName].Relationships![relationshipName].TargetEntity; + + // Recurse to process fields in the value of this relationship field. + PopulateMutationEntityAndFieldsToAuthorize( + entityToExposedColumns, + schemaObject.Fields[relationshipName], + targetEntityName, + context, + fieldDetails.Item1!); + } + } } /// diff --git a/src/Service.GraphQLBuilder/GraphQLUtils.cs b/src/Service.GraphQLBuilder/GraphQLUtils.cs index 78ad23d925..bb786d0be7 100644 --- a/src/Service.GraphQLBuilder/GraphQLUtils.cs +++ b/src/Service.GraphQLBuilder/GraphQLUtils.cs @@ -7,6 +7,7 @@ using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; +using HotChocolate.Execution; using HotChocolate.Language; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -298,7 +299,7 @@ public static string GetEntityNameFromContext(IPureResolverContext context) if (graphQLTypeName is DB_OPERATION_RESULT_TYPE) { // CUD for a mutation whose result set we do not have. Get Entity name mutation field directive. - if (GraphQLUtils.TryExtractGraphQLFieldModelName(context.Selection.Field.Directives, out string? modelName)) + if (TryExtractGraphQLFieldModelName(context.Selection.Field.Directives, out string? modelName)) { entityName = modelName; } @@ -319,7 +320,7 @@ public static string GetEntityNameFromContext(IPureResolverContext context) // if name on schema is different from name in config. // Due to possibility of rename functionality, entityName on runtimeConfig could be different from exposed schema name. - if (GraphQLUtils.TryExtractGraphQLFieldModelName(underlyingFieldType.Directives, out string? modelName)) + if (TryExtractGraphQLFieldModelName(underlyingFieldType.Directives, out string? modelName)) { entityName = modelName; } @@ -333,6 +334,45 @@ private static string GenerateDataSourceNameKeyFromPath(IPureResolverContext con return $"{context.Path.ToList()[0]}"; } + /// + /// Helper method to determine whether a field is a column or complex (relationship) field based on its syntax kind. + /// If the SyntaxKind for the field is not ObjectValue and ListValue, it implies we are dealing with a column/scalar field which + /// has an IntValue, FloatValue, StringValue, BooleanValue or an EnumValue. + /// + /// SyntaxKind of the field. + /// true if the field is a scalar field, else false. + public static bool IsScalarField(SyntaxKind fieldSyntaxKind) + { + return fieldSyntaxKind is SyntaxKind.IntValue || fieldSyntaxKind is SyntaxKind.FloatValue || + fieldSyntaxKind is SyntaxKind.StringValue || fieldSyntaxKind is SyntaxKind.BooleanValue || + fieldSyntaxKind is SyntaxKind.EnumValue; + } + + /// + /// Helper method to get the field details i.e. (field value, field kind) from the GraphQL request body. + /// If the field value is being provided as a variable in the mutation, a recursive call is made to the method + /// to get the actual value of the variable. + /// + /// Value of the field. + /// Collection of variables declared in the GraphQL mutation request. + /// A tuple containing a constant field value and the field kind. + public static Tuple GetFieldDetails(IValueNode? value, IVariableValueCollection variables) + { + if (value is null) + { + return new(null, SyntaxKind.NullValue); + } + + if (value.Kind == SyntaxKind.Variable) + { + string variableName = ((VariableNode)value).Name.Value; + IValueNode? variableValue = variables.GetVariable(variableName); + return GetFieldDetails(variableValue, variables); + } + + return new(value, value.Kind); + } + /// /// Helper method to generate the linking entity name using the source and target entity names. /// diff --git a/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs new file mode 100644 index 0000000000..fef0473c10 --- /dev/null +++ b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs @@ -0,0 +1,456 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Service.Exceptions; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Authorization.GraphQL +{ + [TestClass, TestCategory(TestCategory.MSSQL)] + public class CreateMutationAuthorizationTests : SqlTestBase + { + /// + /// Set the database engine for the tests + /// + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + #region Point create mutation tests + + /// + /// Test to validate that a 'create one' point mutation request will fail if the user does not have create permission on the + /// top-level (the only) entity involved in the mutation. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnEntitiesForCreateOnePointMutations() + { + string createPublisherMutationName = "createPublisher"; + string createOnePublisherMutation = @"mutation{ + createPublisher(item: {name: ""Publisher #1""}) + { + id + name + } + }"; + + // The anonymous role does not have create permissions on the Publisher entity. + // Hence the request will fail during authorization check. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createPublisherMutationName, + graphQLMutation: createOnePublisherMutation, + expectedExceptionMessage: "The current user is not authorized to access this resource.", + isAuthenticated: false, + clientRoleHeader: "anonymous" + ); + } + + /// + /// Test to validate that a 'create one' point mutation will fail the AuthZ checks if the user does not have create permission + /// on one more columns belonging to the entity in the mutation. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnColumnsForCreateOnePointMutations() + { + string createOneStockMutationName = "createStock"; + string createOneStockWithPiecesAvailable = @"mutation { + createStock( + item: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0 + } + ) + { + categoryid + pieceid + } + }"; + + // The 'test_role_with_excluded_fields_on_create' role does not have create permissions on + // stocks.piecesAvailable field and hence the authorization check should fail. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createOneStockMutationName, + graphQLMutation: createOneStockWithPiecesAvailable, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create"); + } + + #endregion + + #region Multiple create mutation tests + /// + /// Test to validate that a 'create one' mutation request can only execute successfully when the user, has create permission + /// for all the entities involved in the mutation. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnEntitiesForCreateOneMultipleMutations() + { + string createBookMutationName = "createbook"; + string createOneBookMutation = @"mutation { + createbook(item: { title: ""Book #1"", publishers: { name: ""Publisher #1""}}) { + id + title + } + }"; + + // The anonymous role has create permissions on the Book entity but not on the Publisher entity. + // Hence the request will fail during authorization check. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createBookMutationName, + graphQLMutation: createOneBookMutation, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: false, + clientRoleHeader: "anonymous" + ); + + // The authenticated role has create permissions on both the Book and Publisher entities. + // Hence the authorization checks will pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createBookMutationName, + graphQLMutation: createOneBookMutation, + isAuthenticated: true, + clientRoleHeader: "authenticated" + ); + } + + /// + /// Test to validate that a 'create multiple' mutation request can only execute successfully when the user, has create permission + /// for all the entities involved in the mutation. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnEntitiesForCreateMultipleMutations() + { + string createMultipleBooksMutationName = "createbooks"; + string createMultipleBookMutation = @"mutation { + createbooks(items: [{ title: ""Book #1"", publisher_id: 1234 }, + { title: ""Book #2"", publishers: { name: ""Publisher #2""}}]) { + items{ + id + title + } + } + }"; + + // The anonymous role has create permissions on the Book entity but not on the Publisher entity. + // Hence the request will fail during authorization check. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createMultipleBooksMutationName, + graphQLMutation: createMultipleBookMutation, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: false, + clientRoleHeader: "anonymous"); + + // The authenticated role has create permissions on both the Book and Publisher entities. + // Hence the authorization checks will pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createMultipleBooksMutationName, + graphQLMutation: createMultipleBookMutation, + isAuthenticated: true, + clientRoleHeader: "authenticated", + expectedResult: "Expected item argument in mutation arguments." + ); + } + + /// + /// Test to validate that a 'create one' mutation request can only execute successfully when the user, in addition to having + /// create permission for all the entities involved in the create mutation, has the create permission for all the columns + /// present for each entity in the mutation. + /// If the user does not have any create permission on one or more column belonging to any of the entity in the + /// multiple-create mutation, the request will fail during authorization check. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnColumnsForCreateOneMultipleMutations() + { + string createOneStockMutationName = "createStock"; + string createOneStockWithPiecesAvailable = @"mutation { + createStock( + item: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0, + stocks_price: + { + is_wholesale_price: true + instant: ""1996-01-24"" + } + } + ) + { + categoryid + pieceid + } + }"; + + // The 'test_role_with_excluded_fields_on_create' role does not have create permissions on + // stocks.piecesAvailable field and hence the authorization check should fail. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createOneStockMutationName, + graphQLMutation: createOneStockWithPiecesAvailable, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create"); + + // As soon as we remove the 'piecesAvailable' column from the request body, + // the authorization check will pass. + string createOneStockWithoutPiecesAvailable = @"mutation { + createStock( + item: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"", + stocks_price: + { + is_wholesale_price: true + instant: ""1996-01-24"" + } + } + ) + { + categoryid + pieceid + } + }"; + + // Since the field stocks.piecesAvailable is not included in the mutation, + // the authorization check should pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createOneStockMutationName, + graphQLMutation: createOneStockWithoutPiecesAvailable, + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create", + expectedResult: ""); + + // Executing a similar mutation request but with stocks_price as top-level entity. + // This validates that the recursive logic to do authorization on fields belonging to related entities + // work as expected. + + string createOneStockPriceMutationName = "createstocks_price"; + string createOneStocksPriceWithPiecesAvailable = @"mutation { + createstocks_price( + item: + { + is_wholesale_price: true, + instant: ""1996-01-24"", + price: 49.6, + Stock: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0, + } + } + ) + { + categoryid + pieceid + } + }"; + + // The 'test_role_with_excluded_fields_on_create' role does not have create permissions on + // stocks.piecesAvailable field and hence the authorization check should fail. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createOneStockPriceMutationName, + graphQLMutation: createOneStocksPriceWithPiecesAvailable, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create"); + + string createOneStocksPriceWithoutPiecesAvailable = @"mutation { + createstocks_price( + item: + { + is_wholesale_price: true, + instant: ""1996-01-24"", + price: 49.6, + Stock: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + } + } + ) + { + categoryid + pieceid + } + }"; + + // Since the field stocks.piecesAvailable is not included in the mutation, + // the authorization check should pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createOneStockMutationName, + graphQLMutation: createOneStocksPriceWithoutPiecesAvailable, + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create", + expectedResult: ""); + } + + /// + /// Test to validate that a 'create multiple' mutation request can only execute successfully when the user, in addition to having + /// create permission for all the entities involved in the create mutation, has the create permission for all the columns + /// present for each entity in the mutation. + /// If the user does not have any create permission on one or more column belonging to any of the entity in the + /// multiple-create mutation, the request will fail during authorization check. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnColumnsForCreateMultipleMutations() + { + string createMultipleStockMutationName = "createStocks"; + string createMultipleStocksWithPiecesAvailable = @"mutation { + createStocks( + items: [ + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0, + stocks_price: + { + is_wholesale_price: true + instant: ""1996-01-24"" + } + } + ]) + { + items + { + categoryid + pieceid + } + } + }"; + + // The 'test_role_with_excluded_fields_on_create' role does not have create permissions on + // stocks.piecesAvailable field and hence the authorization check should fail. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createMultipleStockMutationName, + graphQLMutation: createMultipleStocksWithPiecesAvailable, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create"); + + // As soon as we remove the 'piecesAvailable' column from the request body, + // the authorization check will pass. + string createMultipleStocksWithoutPiecesAvailable = @"mutation { + createStocks( + items: [ + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0, + stocks_price: + { + is_wholesale_price: true + instant: ""1996-01-24"" + } + } + ]) + { + items + { + categoryid + pieceid + } + } + }"; + + // Since the field stocks.piecesAvailable is not included in the mutation, + // the authorization check should pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createMultipleStockMutationName, + graphQLMutation: createMultipleStocksWithoutPiecesAvailable, + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create", + expectedResult: ""); + } + + #endregion + + #region Test helpers + /// + /// Helper method to execute and validate response for negative GraphQL requests which expect an authorization failure + /// as a result of their execution. + /// + /// Name of the mutation. + /// Request body of the mutation. + /// Expected exception message. + /// Boolean indicating whether the request should be treated as authenticated or not. + /// Value of X-MS-API-ROLE client role header. + private async Task ValidateRequestIsUnauthorized( + string graphQLMutationName, + string graphQLMutation, + string expectedExceptionMessage, + string expectedExceptionStatusCode = null, + bool isAuthenticated = false, + string clientRoleHeader = "anonymous") + { + + JsonElement actual = await ExecuteGraphQLRequestAsync( + query: graphQLMutation, + queryName: graphQLMutationName, + isAuthenticated: isAuthenticated, + variables: null, + clientRoleHeader: clientRoleHeader); + + SqlTestHelper.TestForErrorInGraphQLResponse( + actual.ToString(), + message: expectedExceptionMessage, + statusCode: expectedExceptionStatusCode + ); + } + + /// + /// Helper method to execute and validate response for positive GraphQL requests which expect a successful execution + /// against the database, passing all the Authorization checks en route. + /// + /// Name of the mutation. + /// Request body of the mutation. + /// Expected result. + /// Boolean indicating whether the request should be treated as authenticated or not. + /// Value of X-MS-API-ROLE client role header. + private async Task ValidateRequestIsAuthorized( + string graphQLMutationName, + string graphQLMutation, + string expectedResult = "Value cannot be null", + bool isAuthenticated = false, + string clientRoleHeader = "anonymous") + { + + JsonElement actual = await ExecuteGraphQLRequestAsync( + query: graphQLMutation, + queryName: graphQLMutationName, + isAuthenticated: isAuthenticated, + variables: null, + clientRoleHeader: clientRoleHeader); + + SqlTestHelper.TestForErrorInGraphQLResponse( + actual.ToString(), + message: expectedResult + ); + } + + #endregion + } +} diff --git a/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs index a9355796ac..740684a579 100644 --- a/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs +++ b/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs @@ -44,13 +44,7 @@ public class GraphQLMutationAuthorizationTests /// If authorization fails, an exception is thrown and this test validates that scenario. /// If authorization succeeds, no exceptions are thrown for authorization, and function resolves silently. /// - /// - /// - /// - /// [DataTestMethod] - [DataRow(true, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, EntityActionOperation.Create, DisplayName = "Create Mutation Field Authorization - Success, Columns Allowed")] - [DataRow(false, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, EntityActionOperation.Create, DisplayName = "Create Mutation Field Authorization - Failure, Columns Forbidden")] [DataRow(true, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, EntityActionOperation.UpdateGraphQL, DisplayName = "Update Mutation Field Authorization - Success, Columns Allowed")] [DataRow(false, new string[] { "col1", "col2", "col3" }, new string[] { "col4" }, EntityActionOperation.UpdateGraphQL, DisplayName = "Update Mutation Field Authorization - Failure, Columns Forbidden")] [DataRow(true, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, EntityActionOperation.Delete, DisplayName = "Delete Mutation Field Authorization - Success, since authorization to perform the" + @@ -83,7 +77,7 @@ public void MutationFields_AuthorizationEvaluation(bool isAuthorized, string[] c bool authorizationResult = false; try { - engine.AuthorizeMutationFields( + engine.AuthorizeMutation( graphQLMiddlewareContext.Object, parameters, entityName: TEST_ENTITY, @@ -105,7 +99,6 @@ public void MutationFields_AuthorizationEvaluation(bool isAuthorized, string[] c /// Sets up test fixture for class, only to be run once per test run, as defined by /// MSTest decorator. /// - /// private static SqlMutationEngine SetupTestFixture(bool isAuthorized) { Mock _queryEngine = new(); diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 349c8f4343..387450789c 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -301,6 +301,28 @@ } ] }, + { + Role: test_role_with_excluded_fields_on_create, + Actions: [ + { + Action: Create, + Fields: { + Exclude: [ + piecesAvailable + ] + } + }, + { + Action: Read + }, + { + Action: Update + }, + { + Action: Delete + } + ] + }, { Role: TestNestedFilterFieldIsNull_ColumnForbidden, Actions: [ @@ -1303,8 +1325,30 @@ Action: Create } ] + }, + { + Role: test_role_with_excluded_fields_on_create, + Actions: [ + { + Action: Create + }, + { + Action: Read + }, + { + Action: Update + }, + { + Action: Delete + } + ] } - ] + ], + Relationships: { + Stock: { + TargetEntity: Stock + } + } } }, { diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index be8a96d2c3..1743052c28 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -324,6 +324,28 @@ } ] }, + { + "role": "test_role_with_excluded_fields_on_create", + "actions": [ + { + "action": "create", + "fields": { + "exclude": [ + "piecesAvailable" + ] + } + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + }, { "role": "TestNestedFilterFieldIsNull_ColumnForbidden", "actions": [ @@ -396,6 +418,28 @@ } ] }, + { + "role": "test_role_with_excluded_fields_on_create", + "actions": [ + { + "action": "create", + "fields": { + "exclude": [ + "piecesAvailable" + ] + } + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + }, { "role": "test_role_with_policy_excluded_fields", "actions": [ @@ -1385,8 +1429,52 @@ "action": "create" } ] + }, + { + "role": "test_role_with_excluded_fields_on_create", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + }, + { + "role": "test_role_with_excluded_fields_on_create", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] } - ] + ], + "relationships": { + "Stock": { + "cardinality": "one", + "target.entity": "Stock", + "source.fields": [], + "target.fields": [], + "linking.source.fields": [], + "linking.target.fields": [] + } + } }, "Tree": { "source": {