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": [ "*" ]
}
}
]