Skip to content

Commit 0217d3e

Browse files
Verifying the read access of anonymous users prior to transmitting the Mutation result. (#1893)
## Why make this change? Resolves #1464 for CosmosDB. ## What is this change? Verifying the read access of anonymous users prior to transmitting the Mutation result ## How was this tested? - [X] Integration Tests
1 parent 462eb00 commit 0217d3e

File tree

2 files changed

+287
-13
lines changed

2 files changed

+287
-13
lines changed

src/Core/Resolvers/CosmosMutationEngine.cs

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ private async Task<JObject> ExecuteAsync(IMiddlewareContext context, IDictionary
7474
_ => throw new NotSupportedException($"unsupported operation type: {resolver.OperationType}")
7575
};
7676

77+
string roleName = GetRoleOfGraphQLRequest(context);
78+
79+
// 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,
80+
// READ permission is inherited by other roles from Anonymous role when present.
81+
bool isReadPermissionConfigured = _authorizationResolver.AreRoleAndOperationDefinedForEntity(entityName, roleName, EntityActionOperation.Read)
82+
|| _authorizationResolver.AreRoleAndOperationDefinedForEntity(entityName, AuthorizationResolver.ROLE_ANONYMOUS, EntityActionOperation.Read);
83+
84+
// Check read permission before returning the response to prevent unauthorized users from viewing the response.
85+
if (!isReadPermissionConfigured)
86+
{
87+
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",
88+
statusCode: HttpStatusCode.Forbidden,
89+
subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed);
90+
}
91+
7792
return response.Resource;
7893
}
7994

@@ -84,19 +99,7 @@ public void AuthorizeMutationFields(
8499
string entityName,
85100
EntityActionOperation mutationOperation)
86101
{
87-
string role = string.Empty;
88-
if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals)
89-
{
90-
role = stringVals.ToString();
91-
}
92-
93-
if (string.IsNullOrEmpty(role))
94-
{
95-
throw new DataApiBuilderException(
96-
message: "No ClientRoleHeader available to perform authorization.",
97-
statusCode: HttpStatusCode.Unauthorized,
98-
subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed);
99-
}
102+
string role = GetRoleOfGraphQLRequest(context);
100103

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

264+
/// <summary>
265+
/// Helper method to get the role with which the GraphQL API request was executed.
266+
/// </summary>
267+
/// <param name="context">HotChocolate context for the GraphQL request</param>
268+
private static string GetRoleOfGraphQLRequest(IMiddlewareContext context)
269+
{
270+
string role = string.Empty;
271+
if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals)
272+
{
273+
role = stringVals.ToString();
274+
}
275+
276+
if (string.IsNullOrEmpty(role))
277+
{
278+
throw new DataApiBuilderException(
279+
message: "No ClientRoleHeader available to perform authorization.",
280+
statusCode: HttpStatusCode.Unauthorized,
281+
subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed);
282+
}
283+
284+
return role;
285+
}
286+
261287
/// <summary>
262288
/// The method is for parsing the mutation input object with nested inner objects when input is passing inline.
263289
/// </summary>

src/Service.Tests/CosmosTests/MutationTests.cs

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Net.Http;
58
using System.Text.Json;
69
using System.Threading.Tasks;
10+
using Azure.DataApiBuilder.Config.NamingPolicies;
11+
using Azure.DataApiBuilder.Config.ObjectModel;
12+
using Azure.DataApiBuilder.Core.Authorization;
13+
using Azure.DataApiBuilder.Core.Configurations;
714
using Azure.DataApiBuilder.Core.Resolvers;
815
using Azure.DataApiBuilder.Service.Exceptions;
16+
using Azure.DataApiBuilder.Service.Tests.Configuration;
17+
using Microsoft.AspNetCore.TestHost;
918
using Microsoft.Azure.Cosmos;
1019
using Microsoft.Extensions.DependencyInjection;
1120
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -390,6 +399,245 @@ public async Task UpdateMutationWithOnlyTypenameInSelectionSet()
390399
Assert.AreEqual(expected, actual);
391400
}
392401

402+
/// <summary>
403+
/// For mutation operations, both the respective operation(create/update/delete) + read permissions are needed to receive a valid response.
404+
/// In this test, Anonymous role is configured with only create permission.
405+
/// So, a create mutation executed in the context of Anonymous role is expected to result in
406+
/// 1) Creation of a new item in the database
407+
/// 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"
408+
///
409+
/// A create mutation operation in the context of Anonymous role is executed and the expected error message is validated.
410+
/// Authenticated role has read permission configured. A pk query is executed in the context of Authenticated role to validate that a new
411+
/// record was created in the database.
412+
/// </summary>
413+
[TestMethod]
414+
public async Task ValidateErrorMessageForMutationWithoutReadPermission()
415+
{
416+
const string SCHEMA = @"
417+
type Planet @model(name:""Planet"") {
418+
id : ID!,
419+
name : String,
420+
age : Int,
421+
}";
422+
GraphQLRuntimeOptions graphqlOptions = new(Enabled: true);
423+
RestRuntimeOptions restRuntimeOptions = new(Enabled: false);
424+
Dictionary<string, JsonElement> dbOptions = new();
425+
HyphenatedNamingPolicy namingPolicy = new();
426+
427+
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Database)), JsonSerializer.SerializeToElement("graphqldb"));
428+
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Container)), JsonSerializer.SerializeToElement(_containerName));
429+
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Schema)), JsonSerializer.SerializeToElement("custom-schema.gql"));
430+
DataSource dataSource = new(DatabaseType.CosmosDB_NoSQL,
431+
ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.COSMOSDBNOSQL), dbOptions);
432+
433+
EntityAction createAction = new(
434+
Action: EntityActionOperation.Create,
435+
Fields: null,
436+
Policy: new());
437+
438+
EntityAction readAction = new(
439+
Action: EntityActionOperation.Read,
440+
Fields: null,
441+
Policy: new());
442+
443+
EntityAction deleteAction = new(
444+
Action: EntityActionOperation.Delete,
445+
Fields: null,
446+
Policy: new());
447+
448+
EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction }),
449+
new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { readAction, createAction, deleteAction })};
450+
451+
Entity entity = new(Source: new($"graphqldb.{_containerName}", null, null, null),
452+
Rest: null,
453+
GraphQL: new(Singular: "Planet", Plural: "Planets"),
454+
Permissions: permissions,
455+
Relationships: null,
456+
Mappings: null);
457+
458+
string entityName = "Planet";
459+
RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName);
460+
461+
const string CUSTOM_CONFIG = "custom-config.json";
462+
const string CUSTOM_SCHEMA = "custom-schema.gql";
463+
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
464+
File.WriteAllText(CUSTOM_SCHEMA, SCHEMA);
465+
466+
string[] args = new[]
467+
{
468+
$"--ConfigFileName={CUSTOM_CONFIG}",
469+
};
470+
471+
string id = Guid.NewGuid().ToString();
472+
string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken();
473+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
474+
using (HttpClient client = server.CreateClient())
475+
{
476+
try
477+
{
478+
var input = new
479+
{
480+
id,
481+
name = "test_name",
482+
};
483+
484+
// A create mutation operation is executed in the context of Anonymous role. The Anonymous role has create action configured but lacks
485+
// read action. As a result, a new record should be created in the database but the mutation operation should return an error message.
486+
JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
487+
client,
488+
server.Services.GetRequiredService<RuntimeConfigProvider>(),
489+
query: _createPlanetMutation,
490+
queryName: "createPlanet",
491+
variables: new() { { "item", input } },
492+
clientRoleHeader: null
493+
);
494+
495+
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"));
496+
497+
// pk_query is executed in the context of Authenticated role to validate that the create mutation executed in the context of Anonymous role
498+
// resulted in the creation of a new record in the database.
499+
string graphQLQuery = @$"
500+
query {{
501+
planet_by_pk (id: ""{id}"") {{
502+
id
503+
name
504+
}}
505+
}}";
506+
string queryName = "planet_by_pk";
507+
508+
JsonElement queryResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
509+
client,
510+
server.Services.GetRequiredService<RuntimeConfigProvider>(),
511+
query: graphQLQuery,
512+
queryName: queryName,
513+
variables: null,
514+
authToken: authToken,
515+
clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED);
516+
517+
Assert.IsFalse(!queryResponse.ToString().Contains(id), "The query response was not expected to have errors. The document did not return successfully.");
518+
}
519+
finally
520+
{
521+
// Clean-up steps. The record created by the create mutation operation is deleted to reset the database
522+
// back to its original state.
523+
_ = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
524+
client,
525+
server.Services.GetRequiredService<RuntimeConfigProvider>(),
526+
query: _deletePlanetMutation,
527+
queryName: "deletePlanet",
528+
variables: new() { { "id", id }, { "partitionKeyValue", id } },
529+
authToken: authToken,
530+
clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED);
531+
}
532+
}
533+
}
534+
535+
/// <summary>
536+
/// For mutation operations, the respective mutation operation type(create/update/delete) + read permissions are needed to receive a valid response.
537+
/// For graphQL requests, if read permission is configured for Anonymous role, then it is inherited by other roles.
538+
/// In this test, Anonymous role has read permission configured. Authenticated role has only create permission configured.
539+
/// A create mutation operation is executed in the context of Authenticated role and the response is expected to have no errors because
540+
/// the read permission is inherited from Anonymous role.
541+
/// </summary>
542+
[TestMethod]
543+
public async Task ValidateInheritanceOfReadPermissionFromAnonymous()
544+
{
545+
const string SCHEMA = @"
546+
type Planet @model(name:""Planet"") {
547+
id : ID!,
548+
name : String,
549+
age : Int,
550+
}";
551+
GraphQLRuntimeOptions graphqlOptions = new(Enabled: true);
552+
RestRuntimeOptions restRuntimeOptions = new(Enabled: false);
553+
Dictionary<string, JsonElement> dbOptions = new();
554+
HyphenatedNamingPolicy namingPolicy = new();
555+
556+
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Database)), JsonSerializer.SerializeToElement("graphqldb"));
557+
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Container)), JsonSerializer.SerializeToElement(_containerName));
558+
dbOptions.Add(namingPolicy.ConvertName(nameof(CosmosDbNoSQLDataSourceOptions.Schema)), JsonSerializer.SerializeToElement("custom-schema.gql"));
559+
DataSource dataSource = new(DatabaseType.CosmosDB_NoSQL,
560+
ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.COSMOSDBNOSQL), dbOptions);
561+
562+
EntityAction createAction = new(
563+
Action: EntityActionOperation.Create,
564+
Fields: null,
565+
Policy: new());
566+
567+
EntityAction readAction = new(
568+
Action: EntityActionOperation.Read,
569+
Fields: null,
570+
Policy: new());
571+
572+
EntityAction deleteAction = new(
573+
Action: EntityActionOperation.Delete,
574+
Fields: null,
575+
Policy: new());
576+
577+
EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction, readAction, deleteAction }),
578+
new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { createAction })};
579+
580+
Entity entity = new(Source: new($"graphqldb.{_containerName}", null, null, null),
581+
Rest: null,
582+
GraphQL: new(Singular: "Planet", Plural: "Planets"),
583+
Permissions: permissions,
584+
Relationships: null,
585+
Mappings: null);
586+
587+
string entityName = "Planet";
588+
RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName);
589+
590+
const string CUSTOM_CONFIG = "custom-config.json";
591+
const string CUSTOM_SCHEMA = "custom-schema.gql";
592+
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
593+
File.WriteAllText(CUSTOM_SCHEMA, SCHEMA);
594+
595+
string id = Guid.NewGuid().ToString();
596+
string[] args = new[]
597+
{
598+
$"--ConfigFileName={CUSTOM_CONFIG}"
599+
};
600+
601+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
602+
using (HttpClient client = server.CreateClient())
603+
{
604+
try
605+
{
606+
var input = new
607+
{
608+
id,
609+
name = "test_name",
610+
};
611+
612+
// A create mutation operation is executed in the context of Authenticated role and the response is expected to be a valid
613+
// response without any errors.
614+
JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
615+
client,
616+
server.Services.GetRequiredService<RuntimeConfigProvider>(),
617+
query: _createPlanetMutation,
618+
queryName: "createPlanet",
619+
variables: new() { { "item", input } },
620+
authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(),
621+
clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED
622+
);
623+
624+
Assert.IsFalse(!mutationResponse.ToString().Contains(id), "The mutation response was not expected to have errors. The document did not create successfully.");
625+
}
626+
finally
627+
{
628+
// Clean-up steps. The record created by the create mutation operation is deleted to reset the database
629+
// back to its original state.
630+
_ = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
631+
client,
632+
server.Services.GetRequiredService<RuntimeConfigProvider>(),
633+
query: _deletePlanetMutation,
634+
queryName: "deletePlanet",
635+
variables: new() { { "id", id }, { "partitionKeyValue", id } },
636+
clientRoleHeader: null);
637+
}
638+
}
639+
}
640+
393641
/// <summary>
394642
/// Runs once after all tests in this class are executed
395643
/// </summary>

0 commit comments

Comments
 (0)