diff --git a/DataGateway.Service.GraphQLBuilder/Mutations/MutationBuilder.cs b/DataGateway.Service.GraphQLBuilder/Mutations/MutationBuilder.cs index d28c7680c4..1bc423b81d 100644 --- a/DataGateway.Service.GraphQLBuilder/Mutations/MutationBuilder.cs +++ b/DataGateway.Service.GraphQLBuilder/Mutations/MutationBuilder.cs @@ -8,6 +8,14 @@ namespace Azure.DataGateway.Service.GraphQLBuilder.Mutations { public static class MutationBuilder { + /// + /// Within a mutation operation, item represents the field holding the metadata + /// used to mutate the underlying database object record. + /// The item field's metadata is of type OperationEntityInput + /// i.e. CreateBookInput + /// + public const string INPUT_ARGUMENT_NAME = "item"; + /// /// Creates a DocumentNode containing FieldDefinitionNodes representing mutations /// diff --git a/DataGateway.Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs b/DataGateway.Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs new file mode 100644 index 0000000000..9d083a5341 --- /dev/null +++ b/DataGateway.Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs @@ -0,0 +1,123 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Azure.DataGateway.Auth; +using Azure.DataGateway.Service.Authorization; +using Azure.DataGateway.Service.Exceptions; +using Azure.DataGateway.Service.GraphQLBuilder.Mutations; +using Azure.DataGateway.Service.Resolvers; +using Azure.DataGateway.Service.Services; +using HotChocolate.Language; +using HotChocolate.Resolvers; +using Microsoft.Extensions.Primitives; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Azure.DataGateway.Service.Tests.Authorization.GraphQL +{ + /// + /// Unit Tests validating mutation field authorization for GraphQL. + /// Ensures the authorization decision from the authorizationResolver properly triggers + /// an exception for failure (DataGatewayException.Forbidden), and proceeds normally for success. + /// + [TestClass] + public class GraphQLMutationAuthorizationTests + { + private const string TEST_ENTITY = "TEST_ENTITY"; + private const string TEST_COLUMN_VALUE = "COLUMN_VALUE"; + private const string MIDDLEWARE_CONTEXT_ROLEHEADER_VALUE = "roleName"; + + /// + /// This test ensures that data passed into AuthorizeMutationFields() within the SqlMutationEngine + /// are evaluated and provided to the authorization resolver for an authorization decision. + /// 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" }, Config.Operation.Create, DisplayName = "Create Mutation Field Authorization - Success, Columns Allowed")] + [DataRow(false, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, Config.Operation.Create, DisplayName = "Create Mutation Field Authorization - Failure, Columns Forbidden")] + [DataRow(true, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, Config.Operation.UpdateGraphQL, DisplayName = "Update Mutation Field Authorization - Success, Columns Allowed")] + [DataRow(false, new string[] { "col1", "col2", "col3" }, new string[] { "col4" }, Config.Operation.UpdateGraphQL, DisplayName = "Update Mutation Field Authorization - Failure, Columns Forbidden")] + [DataRow(true, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, Config.Operation.Delete, DisplayName = "Delete Mutation Field Authorization - Success, since authorization to perform the" + + "delete mutation operation occurs prior to column evaluation in the request pipeline.")] + public void MutationFields_AuthorizationEvaluation(bool isAuthorized, string[] columnsAllowed, string[] columnsRequested, Config.Operation operation) + { + SqlMutationEngine engine = SetupTestFixture(isAuthorized); + + // Setup mock mutation input, utilized in BaseSqlQueryStructure.InputArgumentToMutationParams() helper. + // This takes the test's "columnsRequested" and adds them to the mutation input. + List mutationInputRaw = new(); + foreach (string column in columnsRequested) + { + mutationInputRaw.Add(new ObjectFieldNode(name: column, value: TEST_COLUMN_VALUE)); + } + + Dictionary parameters = new() + { + { MutationBuilder.INPUT_ARGUMENT_NAME, mutationInputRaw } + }; + + Dictionary middlewareContextData = new() + { + { AuthorizationResolver.CLIENT_ROLE_HEADER, new StringValues(MIDDLEWARE_CONTEXT_ROLEHEADER_VALUE) } + }; + + Mock graphQLMiddlewareContext = new(); + graphQLMiddlewareContext.Setup(x => x.ContextData).Returns(middlewareContextData); + + bool authorizationResult = false; + try + { + engine.AuthorizeMutationFields( + graphQLMiddlewareContext.Object, + parameters, + entityName: TEST_ENTITY, + mutationOperation: operation + ); + + authorizationResult = true; + } + catch (DataGatewayException dgException) + { + Console.Error.WriteLine(dgException.Message); + Assert.IsFalse(isAuthorized, message: "Mutation fields authorized erroneously, no exception expected."); + } + + Assert.AreEqual(actual: authorizationResult, expected: isAuthorized, message: "Mutation field authorization incorrectly evaluated."); + } + + /// + /// 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(); + Mock _sqlMetadataProvider = new(); + Mock _queryExecutor = new(); + Mock _queryBuilder = new(); + + // Creates Mock AuthorizationResolver to return a preset result based on [TestMethod] input. + Mock _authorizationResolver = new(); + _authorizationResolver.Setup(x => x.AreColumnsAllowedForAction( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>() + )).Returns(isAuthorized); + + return new SqlMutationEngine( + _queryEngine.Object, + _queryExecutor.Object, + _queryBuilder.Object, + _sqlMetadataProvider.Object, + _authorizationResolver.Object + ); + } + } +} diff --git a/DataGateway.Service.Tests/Configuration/ConfigurationTests.cs b/DataGateway.Service.Tests/Configuration/ConfigurationTests.cs index bac4fea5eb..2b615ca0cf 100644 --- a/DataGateway.Service.Tests/Configuration/ConfigurationTests.cs +++ b/DataGateway.Service.Tests/Configuration/ConfigurationTests.cs @@ -111,7 +111,7 @@ public void TestLoadingLocalCosmosSettings() ValidateCosmosDbSetup(server); } - [TestMethod("Validates that local MsSql settings can be loaded and the correct classes are in the service provider.")] + [TestMethod("Validates that local MsSql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.MSSQL)] public void TestLoadingLocalMsSqlSettings() { Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MSSQL_ENVIRONMENT); @@ -133,7 +133,7 @@ public void TestLoadingLocalMsSqlSettings() Assert.IsInstanceOfType(sqlMetadataProvider, typeof(MsSqlMetadataProvider)); } - [TestMethod("Validates that local PostgreSql settings can be loaded and the correct classes are in the service provider.")] + [TestMethod("Validates that local PostgreSql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.POSTGRESQL)] public void TestLoadingLocalPostgresSettings() { Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, POSTGRESQL_ENVIRONMENT); @@ -155,7 +155,7 @@ public void TestLoadingLocalPostgresSettings() Assert.IsInstanceOfType(sqlMetadataProvider, typeof(PostgreSqlMetadataProvider)); } - [TestMethod("Validates that local MySql settings can be loaded and the correct classes are in the service provider.")] + [TestMethod("Validates that local MySql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.MYSQL)] public void TestLoadingLocalMySqlSettings() { Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MYSQL_ENVIRONMENT); diff --git a/DataGateway.Service.Tests/SqlTests/SqlTestBase.cs b/DataGateway.Service.Tests/SqlTests/SqlTestBase.cs index c5bf6f6d92..5f30c79e56 100644 --- a/DataGateway.Service.Tests/SqlTests/SqlTestBase.cs +++ b/DataGateway.Service.Tests/SqlTests/SqlTestBase.cs @@ -82,6 +82,12 @@ protected static async Task InitializeTestFixture(TestContext context, string te _httpContextAccessor = new Mock(); _httpContextAccessor.Setup(x => x.HttpContext.User).Returns(new ClaimsPrincipal()); + await ResetDbStateAsync(); + await _sqlMetadataProvider.InitializeAsync(); + + //Initialize the authorization resolver object + _authorizationResolver = new AuthorizationResolver(_runtimeConfigProvider, _sqlMetadataProvider); + _queryEngine = new SqlQueryEngine( _queryExecutor, _queryBuilder, @@ -92,12 +98,9 @@ protected static async Task InitializeTestFixture(TestContext context, string te _queryEngine, _queryExecutor, _queryBuilder, - _sqlMetadataProvider); - await ResetDbStateAsync(); - await _sqlMetadataProvider.InitializeAsync(); + _sqlMetadataProvider, + _authorizationResolver); - //Initialize the authorization resolver object - _authorizationResolver = new AuthorizationResolver(_runtimeConfigProvider, _sqlMetadataProvider); } protected static void SetUpSQLMetadataProvider() diff --git a/DataGateway.Service.Tests/Unittests/RestServiceUnitTests.cs b/DataGateway.Service.Tests/Unittests/RestServiceUnitTests.cs index 9f65cd80ed..e0d2e7f915 100644 --- a/DataGateway.Service.Tests/Unittests/RestServiceUnitTests.cs +++ b/DataGateway.Service.Tests/Unittests/RestServiceUnitTests.cs @@ -136,14 +136,16 @@ public static void InitializeTest(string path) sqlMetadataProvider, httpContextAccessor.Object); + AuthorizationResolver authZResolver = new(runtimeConfigProvider, sqlMetadataProvider); + SqlMutationEngine mutationEngine = new( queryEngine, queryExecutor, queryBuilder, - sqlMetadataProvider); + sqlMetadataProvider, + authZResolver); - AuthorizationResolver authZResolver = new(runtimeConfigProvider, sqlMetadataProvider); // Setup REST Service _restService = new RestService( queryEngine, diff --git a/DataGateway.Service/Controllers/GraphQLController.cs b/DataGateway.Service/Controllers/GraphQLController.cs index 5825d8de69..72fa16fea8 100644 --- a/DataGateway.Service/Controllers/GraphQLController.cs +++ b/DataGateway.Service/Controllers/GraphQLController.cs @@ -3,8 +3,10 @@ using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; +using Azure.DataGateway.Service.Authorization; using Azure.DataGateway.Service.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; namespace Azure.DataGateway.Service.Controllers { @@ -35,12 +37,15 @@ public async Task PostAsync() // recognizes the authenticated user. Anonymous requests are possible so check // for the HttpContext.User existence is necessary. Dictionary requestProperties = new(); + if (HttpContext.Request.Headers.TryGetValue(AuthorizationResolver.CLIENT_ROLE_HEADER, out StringValues clientRoleHeader)) + { + requestProperties.Add(key: AuthorizationResolver.CLIENT_ROLE_HEADER, value: clientRoleHeader); + } if (this.HttpContext.User.Identity != null && this.HttpContext.User.Identity.IsAuthenticated) { requestProperties.Add(nameof(ClaimsPrincipal), this.HttpContext.User); } - // JsonElement returned so that JsonDocument is disposed when thread exits string resultJson = await this._schemaManager.ExecuteAsync(requestBody, requestProperties); using JsonDocument jsonDoc = JsonDocument.Parse(resultJson); diff --git a/DataGateway.Service/Resolvers/SqlMutationEngine.cs b/DataGateway.Service/Resolvers/SqlMutationEngine.cs index e412e470b1..9d1dad713d 100644 --- a/DataGateway.Service/Resolvers/SqlMutationEngine.cs +++ b/DataGateway.Service/Resolvers/SqlMutationEngine.cs @@ -7,7 +7,9 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Azure.DataGateway.Auth; using Azure.DataGateway.Config; +using Azure.DataGateway.Service.Authorization; using Azure.DataGateway.Service.Exceptions; using Azure.DataGateway.Service.GraphQLBuilder.Mutations; using Azure.DataGateway.Service.Models; @@ -15,6 +17,7 @@ using HotChocolate.Resolvers; using HotChocolate.Types; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; namespace Azure.DataGateway.Service.Resolvers { @@ -27,6 +30,7 @@ public class SqlMutationEngine : IMutationEngine private readonly ISqlMetadataProvider _sqlMetadataProvider; private readonly IQueryExecutor _queryExecutor; private readonly IQueryBuilder _queryBuilder; + private readonly IAuthorizationResolver _authorizationResolver; /// /// Constructor @@ -35,12 +39,14 @@ public SqlMutationEngine( IQueryEngine queryEngine, IQueryExecutor queryExecutor, IQueryBuilder queryBuilder, - ISqlMetadataProvider sqlMetadataProvider) + ISqlMetadataProvider sqlMetadataProvider, + IAuthorizationResolver authorizationResolver) { _queryEngine = queryEngine; _queryExecutor = queryExecutor; _queryBuilder = queryBuilder; _sqlMetadataProvider = sqlMetadataProvider; + _authorizationResolver = authorizationResolver; } /// @@ -64,10 +70,14 @@ public async Task> ExecuteAsync(IMiddlewareContex MutationBuilder.DetermineMutationOperationTypeBasedOnInputType(graphqlMutationName); if (mutationOperation is Operation.Delete) { - // compute the mutation result before removing the element + // compute the mutation result before removing the element, + // since typical GraphQL delete mutations return the metadata of the deleted item. result = await _queryEngine.ExecuteAsync(context, parameters); } + // If authorization fails, an exception will be thrown and request execution halts. + AuthorizeMutationFields(context, parameters, entityName, mutationOperation); + using DbDataReader dbDataReader = await PerformMutationOperation( entityName, @@ -376,5 +386,70 @@ public string ConstructPrimaryKeyRoute(string entityName, Dictionary + /// Authorization check on mutation fields provided in a GraphQL Mutation request. + /// + /// + /// + /// + /// + /// + /// + /// + public void AuthorizeMutationFields( + IMiddlewareContext context, + IDictionary parameters, + string entityName, + Operation mutationOperation) + { + string role = string.Empty; + if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value)) + { + role = (StringValues)value!.ToString(); + } + + if (string.IsNullOrEmpty(role)) + { + throw new DataGatewayException( + message: "No ClientRoleHeader available to perform authorization.", + statusCode: HttpStatusCode.Unauthorized, + subStatusCode: DataGatewayException.SubStatusCodes.AuthorizationCheckFailed); + } + + List inputArgumentKeys = BaseSqlQueryStructure.InputArgumentToMutationParams(parameters, MutationBuilder.INPUT_ARGUMENT_NAME).Keys.ToList(); + bool isAuthorized; // False by default. + + switch (mutationOperation) + { + case Operation.UpdateGraphQL: + isAuthorized = _authorizationResolver.AreColumnsAllowedForAction(entityName, roleName: role, action: ActionType.UPDATE, inputArgumentKeys); + break; + case Operation.Create: + isAuthorized = _authorizationResolver.AreColumnsAllowedForAction(entityName, roleName: role, action: ActionType.CREATE, inputArgumentKeys); + break; + case Operation.Delete: + // Delete operations are not checked for authorization on field level, + // and instead at the mutation level and would be rejected before this time in the pipeline. + // Continuing on with operation. + isAuthorized = true; + break; + default: + throw new DataGatewayException( + message: "Invalid operation for GraphQL Mutation, must be Create, UpdateGraphQL, or Delete", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataGatewayException.SubStatusCodes.BadRequest + ); + } + + if (!isAuthorized) + { + throw new DataGatewayException( + message: "Unauthorized due to one or more fields in this mutation.", + statusCode: HttpStatusCode.Forbidden, + subStatusCode: DataGatewayException.SubStatusCodes.AuthorizationCheckFailed + ); + } + } } } diff --git a/DataGateway.Service/Services/ResolverMiddleware.cs b/DataGateway.Service/Services/ResolverMiddleware.cs index 3f5208ceda..00a3515ccc 100644 --- a/DataGateway.Service/Services/ResolverMiddleware.cs +++ b/DataGateway.Service/Services/ResolverMiddleware.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text.Json; using System.Threading.Tasks; +using Azure.DataGateway.Service.Authorization; using Azure.DataGateway.Service.GraphQLBuilder.CustomScalars; using Azure.DataGateway.Service.Models; using Azure.DataGateway.Service.Resolvers; @@ -9,6 +10,8 @@ using HotChocolate.Language; using HotChocolate.Resolvers; using HotChocolate.Types; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; namespace Azure.DataGateway.Service.Services { @@ -35,6 +38,15 @@ public ResolverMiddleware(FieldDelegate next, public async Task InvokeAsync(IMiddlewareContext context) { JsonElement jsonElement; + if (context.ContextData.TryGetValue("HttpContext", out object? value)) + { + if (value is not null) + { + HttpContext httpContext = (HttpContext)value; + StringValues clientRoleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]; + context.ContextData.TryAdd(key: AuthorizationResolver.CLIENT_ROLE_HEADER, value: clientRoleHeader); + } + } if (context.Selection.Field.Coordinate.TypeName.Value == "Mutation") { @@ -161,8 +173,10 @@ protected static bool IsInnerObject(IMiddlewareContext context) /// /// Extract parameters from the schema and the actual instance (query) of the field - /// Extracts defualt parameter values from the schema or null if no default + /// Extracts default parameter values from the schema or null if no default /// Overrides default values with actual values of parameters provided + /// Key: (string) argument field name + /// Value: (object) argument value /// public static IDictionary GetParametersFromSchemaAndQueryFields(IObjectField schema, FieldNode query, IVariableValueCollection variables) { diff --git a/DataGateway.Service/hawaii-config.MySql.overrides.example.json b/DataGateway.Service/hawaii-config.MySql.overrides.example.json index be4d25dd4a..3cd2937a11 100644 --- a/DataGateway.Service/hawaii-config.MySql.overrides.example.json +++ b/DataGateway.Service/hawaii-config.MySql.overrides.example.json @@ -128,8 +128,7 @@ "database": "@claims.id eq @item.id" }, "fields": { - "include": [ "*" ], - "exclude": [ "id" ] + "include": [ "*" ] } } ] diff --git a/DataGateway.Service/hawaii-config.PostgreSql.overrides.example.json b/DataGateway.Service/hawaii-config.PostgreSql.overrides.example.json index 03226d9433..b2ed903057 100644 --- a/DataGateway.Service/hawaii-config.PostgreSql.overrides.example.json +++ b/DataGateway.Service/hawaii-config.PostgreSql.overrides.example.json @@ -128,8 +128,7 @@ "database": "@claims.id eq @item.id" }, "fields": { - "include": [ "*" ], - "exclude": [ "id" ] + "include": [ "*" ] } } ]