Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions src/Core/Resolvers/CosmosMutationEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ private async Task<JObject> ExecuteAsync(IMiddlewareContext context, IDictionary
_ => throw new NotSupportedException($"unsupported operation type: {resolver.OperationType}")
};

string roleName = 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.
bool isReadPermissionConfigured = _authorizationResolver.AreRoleAndOperationDefinedForEntity(entityName, roleName, EntityActionOperation.Read)
|| _authorizationResolver.AreRoleAndOperationDefinedForEntity(entityName, AuthorizationResolver.ROLE_ANONYMOUS, EntityActionOperation.Read);

// Check read permission before returning the response to prevent unauthorized users from viewing the response.
if (!isReadPermissionConfigured)
{
throw new DataApiBuilderException(message: $"The mutation operation {context.Selection.Field.Name} was successful but the current user is unauthorized to view the response due to lack of read permissions",
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed);
}

return response.Resource;
}

Expand All @@ -84,19 +99,7 @@ public void AuthorizeMutationFields(
string entityName,
EntityActionOperation mutationOperation)
{
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);
}
string role = GetRoleOfGraphQLRequest(context);

List<string> inputArgumentKeys;
if (mutationOperation != EntityActionOperation.Delete)
Expand Down Expand Up @@ -258,6 +261,29 @@ private static async Task<ItemResponse<JObject>> HandleUpdateAsync(IDictionary<s
return item;
}

/// <summary>
/// Helper method to get the role with which the GraphQL API request was executed.
/// </summary>
/// <param name="context">HotChocolate context for the GraphQL request</param>
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;
}

/// <summary>
/// The method is for parsing the mutation input object with nested inner objects when input is passing inline.
/// </summary>
Expand Down
248 changes: 248 additions & 0 deletions src/Service.Tests/CosmosTests/MutationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.NamingPolicies;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Authorization;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Resolvers;
using Azure.DataApiBuilder.Service.Exceptions;
using Azure.DataApiBuilder.Service.Tests.Configuration;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
Expand Down Expand Up @@ -390,6 +399,245 @@ public async Task UpdateMutationWithOnlyTypenameInSelectionSet()
Assert.AreEqual(expected, actual);
}

/// <summary>
/// For mutation operations, both the respective operation(create/update/delete) + read permissions are needed to receive a valid response.
/// In this test, Anonymous role is configured with only create permission.
/// So, a create mutation executed in the context of Anonymous role is expected to result in
/// 1) Creation of a new item in the database
/// 2) An error response containing the error message : "The mutation operation {operation_name} was successful but the current user is unauthorized to view the response due to lack of read permissions"
///
/// A create mutation operation in the context of Anonymous role is executed and the expected error message is validated.
/// Authenticated role has read permission configured. A pk query is executed in the context of Authenticated role to validate that a new
/// record was created in the database.
/// </summary>
[TestMethod]
public async Task ValidateErrorMessageForMutationWithoutReadPermission()
{
const string SCHEMA = @"
type Planet @model(name:""Planet"") {
id : ID!,
name : String,
age : Int,
}";
GraphQLRuntimeOptions graphqlOptions = new(Enabled: true);
RestRuntimeOptions restRuntimeOptions = new(Enabled: false);
Dictionary<string, JsonElement> dbOptions = new();
HyphenatedNamingPolicy namingPolicy = new();

dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Database)), JsonSerializer.SerializeToElement("graphqldb"));
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Container)), JsonSerializer.SerializeToElement(_containerName));
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Schema)), JsonSerializer.SerializeToElement("custom-schema.gql"));
DataSource dataSource = new(DatabaseType.CosmosDB_NoSQL,
ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.COSMOSDBNOSQL), dbOptions);

EntityAction createAction = new(
Action: EntityActionOperation.Create,
Fields: null,
Policy: new());

EntityAction readAction = new(
Action: EntityActionOperation.Read,
Fields: null,
Policy: new());

EntityAction deleteAction = new(
Action: EntityActionOperation.Delete,
Fields: null,
Policy: new());

EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction }),
new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { readAction, createAction, deleteAction })};

Entity entity = new(Source: new($"graphqldb.{_containerName}", null, null, null),
Rest: null,
GraphQL: new(Singular: "Planet", Plural: "Planets"),
Permissions: permissions,
Relationships: null,
Mappings: null);

string entityName = "Planet";
RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName);

const string CUSTOM_CONFIG = "custom-config.json";
const string CUSTOM_SCHEMA = "custom-schema.gql";
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
File.WriteAllText(CUSTOM_SCHEMA, SCHEMA);

string[] args = new[]
{
$"--ConfigFileName={CUSTOM_CONFIG}",
};

string id = Guid.NewGuid().ToString();
string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken();
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
using (HttpClient client = server.CreateClient())
{
try
{
var input = new
{
id,
name = "test_name",
};

// A create mutation operation is executed in the context of Anonymous role. The Anonymous role has create action configured but lacks
// read action. As a result, a new record should be created in the database but the mutation operation should return an error message.
JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
client,
server.Services.GetRequiredService<RuntimeConfigProvider>(),
query: _createPlanetMutation,
queryName: "createPlanet",
variables: new() { { "item", input } },
clientRoleHeader: null
);

Assert.IsTrue(mutationResponse.ToString().Contains("The mutation operation createPlanet was successful but the current user is unauthorized to view the response due to lack of read permissions"));

// pk_query is executed in the context of Authenticated role to validate that the create mutation executed in the context of Anonymous role
// resulted in the creation of a new record in the database.
string graphQLQuery = @$"
query {{
planet_by_pk (id: ""{id}"") {{
id
name
}}
}}";
string queryName = "planet_by_pk";

JsonElement queryResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
client,
server.Services.GetRequiredService<RuntimeConfigProvider>(),
query: graphQLQuery,
queryName: queryName,
variables: null,
authToken: authToken,
clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED);

Assert.IsFalse(!queryResponse.ToString().Contains(id), "The query response was not expected to have errors. The document did not return successfully.");
}
finally
{
// Clean-up steps. The record created by the create mutation operation is deleted to reset the database
// back to its original state.
_ = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
client,
server.Services.GetRequiredService<RuntimeConfigProvider>(),
query: _deletePlanetMutation,
queryName: "deletePlanet",
variables: new() { { "id", id }, { "partitionKeyValue", id } },
authToken: authToken,
clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED);
}
}
}

/// <summary>
/// For mutation operations, the respective mutation operation type(create/update/delete) + read permissions are needed to receive a valid response.
/// For graphQL requests, if read permission is configured for Anonymous role, then it is inherited by other roles.
/// In this test, Anonymous role has read permission configured. Authenticated role has only create permission configured.
/// A create mutation operation is executed in the context of Authenticated role and the response is expected to have no errors because
/// the read permission is inherited from Anonymous role.
/// </summary>
[TestMethod]
public async Task ValidateInheritanceOfReadPermissionFromAnonymous()
{
const string SCHEMA = @"
type Planet @model(name:""Planet"") {
id : ID!,
name : String,
age : Int,
}";
GraphQLRuntimeOptions graphqlOptions = new(Enabled: true);
RestRuntimeOptions restRuntimeOptions = new(Enabled: false);
Dictionary<string, JsonElement> dbOptions = new();
HyphenatedNamingPolicy namingPolicy = new();

dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Database)), JsonSerializer.SerializeToElement("graphqldb"));
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Container)), JsonSerializer.SerializeToElement(_containerName));
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Schema)), JsonSerializer.SerializeToElement("custom-schema.gql"));
DataSource dataSource = new(DatabaseType.CosmosDB_NoSQL,
ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.COSMOSDBNOSQL), dbOptions);

EntityAction createAction = new(
Action: EntityActionOperation.Create,
Fields: null,
Policy: new());

EntityAction readAction = new(
Action: EntityActionOperation.Read,
Fields: null,
Policy: new());

EntityAction deleteAction = new(
Action: EntityActionOperation.Delete,
Fields: null,
Policy: new());

EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction, readAction, deleteAction }),
new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { createAction })};

Entity entity = new(Source: new($"graphqldb.{_containerName}", null, null, null),
Rest: null,
GraphQL: new(Singular: "Planet", Plural: "Planets"),
Permissions: permissions,
Relationships: null,
Mappings: null);

string entityName = "Planet";
RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName);

const string CUSTOM_CONFIG = "custom-config.json";
const string CUSTOM_SCHEMA = "custom-schema.gql";
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
File.WriteAllText(CUSTOM_SCHEMA, SCHEMA);

string id = Guid.NewGuid().ToString();
string[] args = new[]
{
$"--ConfigFileName={CUSTOM_CONFIG}"
};

using (TestServer server = new(Program.CreateWebHostBuilder(args)))
using (HttpClient client = server.CreateClient())
{
try
{
var input = new
{
id,
name = "test_name",
};

// A create mutation operation is executed in the context of Authenticated role and the response is expected to be a valid
// response without any errors.
JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
client,
server.Services.GetRequiredService<RuntimeConfigProvider>(),
query: _createPlanetMutation,
queryName: "createPlanet",
variables: new() { { "item", input } },
authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(),
clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED
);

Assert.IsFalse(!mutationResponse.ToString().Contains(id), "The mutation response was not expected to have errors. The document did not create successfully.");
}
finally
{
// Clean-up steps. The record created by the create mutation operation is deleted to reset the database
// back to its original state.
_ = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
client,
server.Services.GetRequiredService<RuntimeConfigProvider>(),
query: _deletePlanetMutation,
queryName: "deletePlanet",
variables: new() { { "id", id }, { "partitionKeyValue", id } },
clientRoleHeader: null);
}
}
}

/// <summary>
/// Runs once after all tests in this class are executed
/// </summary>
Expand Down