Skip to content

Commit f3eac1f

Browse files
authored
Add support for Cosmos field level auth (#1468)
## Why make this change? - Closes #1434, related discussion #1423 . - Changes to add support for Cosmos to check field level auth for query filter and query nested filter. - Changes to add support for Cosmos to check field level auth for mutation operation. - Note: current mutation field auth support only check one level down, will have a follow up PR to check nested field auth for mutations. ## What is this change? - Enable ```GraphQLFilterParser``` for Cosmos.  - Resolve ```WILDCARD``` and parse all the columns from schema.gql - Parse ```Included``` and ```Excluded``` columns from runtime config by injecting``` AuthorizationResolver``` into the ```CosmosMutationEngine``` to make a call to check the columns.  ## How was this tested? - [x] Integration Tests - [x] Unit Tests
1 parent 86b5917 commit f3eac1f

17 files changed

+629
-99
lines changed
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
init --config "dab-config.CosmosDb_NoSql.json" --database-type "cosmosdb_nosql" --connection-string "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" --cosmosdb_nosql-database "graphqldb" --cosmosdb_nosql-container "planet" --graphql-schema "schema.gql" --host-mode Development --cors-origin "http://localhost:5000"
22
add Planet --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Planet:Planets"
3+
update Planet --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "*"
34
update Planet --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete"
4-
add Character --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.character" --permissions "authenticated:create,read,update,delete" --graphql "Character:Characters"
5+
add Character --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.character" --permissions "anonymous:create,read,update,delete" --graphql "Character:Characters"
56
add StarAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.star" --permissions "anonymous:create,read,update,delete" --graphql "Star:Stars"
67
update StarAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.star" --permissions "authenticated:create,read,update,delete"
8+
add TagAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.tag" --permissions "anonymous:create,read,update,delete" --graphql "Tag:Tags"
79
add Moon --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.moon" --permissions "anonymous:create,read,update,delete" --graphql true
810
update Moon --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.moon" --permissions "authenticated:create,read,update,delete"
11+
add Earth --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.earth" --permissions "anonymous:delete" --graphql "Earth:Earths"
12+
update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:create" --fields.include "id" --fields.exclude "name"
13+
update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:read" --fields.include "id,type" --fields.exclude "name"
14+
update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "anonymous:update" --fields.exclude "*"
15+
update Earth --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete"

src/Config/DataApiBuilderException.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class DataApiBuilderException : Exception
1717
public const string GRAPHQL_FILTER_ENTITY_AUTHZ_FAILURE = "Access forbidden to the target entity described in the filter.";
1818
public const string GRAPHQL_FILTER_FIELD_AUTHZ_FAILURE = "Access forbidden to a field referenced in the filter.";
1919
public const string AUTHORIZATION_FAILURE = "Authorization Failure: Access Not Allowed.";
20+
public const string GRAPHQL_MUTATION_FIELD_AUTHZ_FAILURE = "Unauthorized due to one or more fields in this mutation.";
2021

2122
public enum SubStatusCodes
2223
{

src/Service.Tests/Configuration/ConfigurationTests.cs

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -300,23 +300,6 @@ public async Task TestInvalidConfigurationAtRuntime()
300300
Assert.AreEqual(HttpStatusCode.BadRequest, postResult.StatusCode);
301301
}
302302

303-
[TestMethod("Validates containing field permission in configuration returns a bad request."), TestCategory(TestCategory.COSMOSDBNOSQL)]
304-
public async Task TestInvalidConfigurationWithFieldPermission()
305-
{
306-
TestServer server = new(Program.CreateWebHostFromInMemoryUpdateableConfBuilder(Array.Empty<string>()));
307-
HttpClient httpClient = server.CreateClient();
308-
309-
ConfigurationPostParameters config = GetCosmosConfigurationParameters();
310-
config = config with
311-
{
312-
Configuration = "{\"$schema\":\"dab.draft.schema.json\",\"data-source\":{\"database-type\":\"cosmosdb_nosql\",\"options\":{\"database\":\"graphqldb\",\"schema\":\"schema.gql\"}},\"entities\":{\"Planet\":{\"source\":\"graphqldb.planet\",\"graphql\":{\"type\":{\"singular\":\"Planet\",\"plural\":\"Planets\"}},\"permissions\":[{\"role\":\"anonymous\",\"actions\":[{\"action\":\"read\",\"fields\":{\"include\":[\"*\"],\"exclude\":[]}}]}]}}}"
313-
};
314-
315-
HttpResponseMessage postResult =
316-
await httpClient.PostAsync("/configuration", JsonContent.Create(config));
317-
Assert.AreEqual(HttpStatusCode.BadRequest, postResult.StatusCode);
318-
}
319-
320303
[TestMethod("Validates a failure in one of the config updated handlers returns a bad request."), TestCategory(TestCategory.COSMOSDBNOSQL)]
321304
public async Task TestSettingFailureConfigurations()
322305
{

src/Service.Tests/CosmosTests/CosmosTestHelper.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ public static object GetItem(string id, string name = null, int numericVal = 4)
5858
name = "first moon",
5959
details = "12 Craters"
6060
},
61+
earth = new
62+
{
63+
id = id,
64+
name = "blue earth"
65+
},
6166
tags = new[] { "tag1", "tag2" }
6267
};
6368
}

src/Service.Tests/CosmosTests/MutationTests.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Text.Json;
66
using System.Threading.Tasks;
7+
using Azure.DataApiBuilder.Service.Exceptions;
78
using Azure.DataApiBuilder.Service.Resolvers;
89
using Microsoft.Azure.Cosmos;
910
using Microsoft.Extensions.DependencyInjection;
@@ -42,6 +43,7 @@ public static void TestFixtureSetup(TestContext context)
4243
cosmosClient.GetDatabase(DATABASE_NAME).CreateContainerIfNotExistsAsync(_containerName, "/id").Wait();
4344
CreateItems(DATABASE_NAME, _containerName, 10);
4445
OverrideEntityContainer("Planet", _containerName);
46+
OverrideEntityContainer("Earth", _containerName);
4547
}
4648

4749
[TestMethod]
@@ -242,6 +244,83 @@ public async Task MutationMissingRequiredPartitionKeyValueReturnError()
242244
Assert.AreEqual("The argument `_partitionKeyValue` is required.", response[0].GetProperty("message").ToString());
243245
}
244246

247+
/// <summary>
248+
/// Mutation can be performed on the authorized fields because the
249+
/// field `id` is an included field for the create operation on the anonymous role defined
250+
/// for entity 'earth'
251+
/// </summary>
252+
[TestMethod]
253+
public async Task CanCreateItemWithAuthorizedFields()
254+
{
255+
// Run mutation Add Earth;
256+
string id = Guid.NewGuid().ToString();
257+
string mutation = $@"
258+
mutation {{
259+
createEarth (item: {{ id: ""{id}"" }}) {{
260+
id
261+
}}
262+
}}";
263+
JsonElement response = await ExecuteGraphQLRequestAsync("createEarth", mutation, variables: new());
264+
265+
// Validate results
266+
Assert.AreEqual(id, response.GetProperty("id").GetString());
267+
}
268+
269+
/// <summary>
270+
/// Mutation performed on the unauthorized fields throws permission denied error because the
271+
/// field `name` is an excluded field for the create operation on the anonymous role defined
272+
/// for entity 'earth'
273+
/// </summary>
274+
[TestMethod]
275+
public async Task CreateItemWithUnauthorizedFieldsReturnsError()
276+
{
277+
// Run mutation Add Earth;
278+
string id = Guid.NewGuid().ToString();
279+
const string name = "test_name";
280+
string mutation = $@"
281+
mutation {{
282+
createEarth (item: {{ id: ""{id}"", name: ""{name}"" }}) {{
283+
id
284+
name
285+
}}
286+
}}";
287+
JsonElement response = await ExecuteGraphQLRequestAsync("createEarth", mutation, variables: new());
288+
289+
// Validate the result contains the GraphQL authorization error code.
290+
string errorMessage = response.ToString();
291+
Assert.IsTrue(errorMessage.Contains(DataApiBuilderException.GRAPHQL_MUTATION_FIELD_AUTHZ_FAILURE));
292+
}
293+
294+
/// <summary>
295+
/// Mutation performed on the unauthorized fields throws permission denied error because the
296+
/// wildcard is used in the excluded field for the update operation on the anonymous role defined
297+
/// for entity 'earth'
298+
/// </summary>
299+
[TestMethod]
300+
public async Task UpdateItemWithUnauthorizedWildCardReturnsError()
301+
{
302+
// Run mutation Update Earth;
303+
string id = Guid.NewGuid().ToString();
304+
string mutation = @"
305+
mutation ($id: ID!, $partitionKeyValue: String!, $item: UpdateEarthInput!) {
306+
updateEarth (id: $id, _partitionKeyValue: $partitionKeyValue, item: $item) {
307+
id
308+
name
309+
}
310+
}";
311+
var update = new
312+
{
313+
id = id,
314+
name = "new_name"
315+
};
316+
317+
JsonElement response = await ExecuteGraphQLRequestAsync("updateEarth", mutation, variables: new() { { "id", id }, { "partitionKeyValue", id }, { "item", update } });
318+
319+
// Validate the result contains the GraphQL authorization error code.
320+
string errorMessage = response.ToString();
321+
Assert.IsTrue(errorMessage.Contains(DataApiBuilderException.GRAPHQL_MUTATION_FIELD_AUTHZ_FAILURE));
322+
}
323+
245324
/// <summary>
246325
/// Runs once after all tests in this class are executed
247326
/// </summary>

src/Service.Tests/CosmosTests/QueryFilterTests.cs

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.Text.Json;
67
using System.Threading.Tasks;
8+
using Azure.DataApiBuilder.Config;
9+
using Azure.DataApiBuilder.Service.Exceptions;
710
using Azure.DataApiBuilder.Service.Resolvers;
811
using Microsoft.Azure.Cosmos;
912
using Microsoft.Extensions.DependencyInjection;
@@ -20,6 +23,7 @@ public class QueryFilterTests : TestBase
2023
private static readonly string _containerName = Guid.NewGuid().ToString();
2124
private static int _pageSize = 10;
2225
private static readonly string _graphQLQueryName = "planets";
26+
private static List<string> _idList;
2327

2428
[ClassInitialize]
2529
public static void TestFixtureSetup(TestContext context)
@@ -28,8 +32,11 @@ public static void TestFixtureSetup(TestContext context)
2832
CosmosClient cosmosClient = _application.Services.GetService<CosmosClientProvider>().Client;
2933
cosmosClient.CreateDatabaseIfNotExistsAsync(DATABASE_NAME).Wait();
3034
cosmosClient.GetDatabase(DATABASE_NAME).CreateContainerIfNotExistsAsync(_containerName, "/id").Wait();
31-
CreateItems(DATABASE_NAME, _containerName, 10);
35+
_idList = CreateItems(DATABASE_NAME, _containerName, 10);
3236
OverrideEntityContainer("Planet", _containerName);
37+
OverrideEntityContainer("Earth", _containerName);
38+
OverrideEntityContainer("StarAlias", _containerName);
39+
OverrideEntityContainer("TagAlias", _containerName);
3340
}
3441

3542
/// <summary>
@@ -642,6 +649,193 @@ public async Task TestFilterOnInnerNestedFields()
642649
await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery);
643650
}
644651

652+
/// <summary>
653+
/// Test filters when entity names are using alias.
654+
/// This exercises the scenario when top level entity name is using an alias,
655+
/// as well as the nested level entity name is using an alias,
656+
/// in both layers, the entity name to GraphQL type lookup is successfully performed.
657+
/// </summary>
658+
[TestMethod]
659+
public async Task TestFilterWithEntityNameAlias()
660+
{
661+
string gqlQuery = @"{
662+
stars(first: 1, " + QueryBuilder.FILTER_FIELD_NAME + @" : {tag : {name : {eq : ""test name""}}})
663+
{
664+
items {
665+
tag {
666+
id
667+
name
668+
}
669+
}
670+
}
671+
}";
672+
673+
string dbQuery = "SELECT top 1 c.tag FROM c where c.tag.name = \"test name\"";
674+
await ExecuteAndValidateResult("stars", gqlQuery, dbQuery);
675+
}
676+
677+
#region Field Level Auth
678+
/// <summary>
679+
/// Tests that the field level query filter succeeds requests when filter fields are authorized
680+
/// </summary>
681+
[TestMethod]
682+
public async Task TestQueryFilterFieldAuth_AuthorizedField()
683+
{
684+
string gqlQuery = @"{
685+
earths(first: 1, " + QueryBuilder.FILTER_FIELD_NAME + @" : {id : {eq : """ + _idList[0] + @"""}})
686+
{
687+
items {
688+
id
689+
}
690+
}
691+
}";
692+
693+
string dbQuery = $"SELECT top 1 c.id FROM c where c.id = \"{_idList[0]}\"";
694+
await ExecuteAndValidateResult("earths", gqlQuery, dbQuery);
695+
}
696+
697+
/// <summary>
698+
/// Tests that the field level query filter fails authorization when filter fields are
699+
/// unauthorized because the field 'name' on object type 'earth' is an excluded field of the read
700+
/// operation permissions defined for the anonymous role.
701+
/// </summary>
702+
[TestMethod]
703+
public async Task TestQueryFilterFieldAuth_UnauthorizedField()
704+
{
705+
// Run query
706+
string gqlQuery = @"{
707+
earths(first: 1, " + QueryBuilder.FILTER_FIELD_NAME + @" : {name : {eq : ""test name""}})
708+
{
709+
items {
710+
name
711+
}
712+
}
713+
}";
714+
string clientRoleHeader = AuthorizationType.Anonymous.ToString();
715+
JsonElement response = await ExecuteGraphQLRequestAsync(
716+
queryName: "earths",
717+
query: gqlQuery,
718+
variables: new() { { "name", "test name" } },
719+
authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader),
720+
clientRoleHeader: clientRoleHeader);
721+
722+
// Validate the result contains the GraphQL authorization error code.
723+
string errorMessage = response.ToString();
724+
Assert.IsTrue(errorMessage.Contains(DataApiBuilderException.GRAPHQL_FILTER_FIELD_AUTHZ_FAILURE));
725+
}
726+
727+
/// <summary>
728+
/// Tests that the field level query filter succeeds requests when filter fields are authorized
729+
/// </summary>
730+
[TestMethod]
731+
public async Task TestQueryFilterFieldAuth_AuthorizedWildCard()
732+
{
733+
// Run query
734+
string gqlQuery = @"{
735+
planets(first: 1, " + QueryBuilder.FILTER_FIELD_NAME + @" : {name : {eq : ""Earth""}})
736+
{
737+
items {
738+
name
739+
}
740+
}
741+
}";
742+
string clientRoleHeader = AuthorizationType.Anonymous.ToString();
743+
JsonElement response = await ExecuteGraphQLRequestAsync(
744+
queryName: "planets",
745+
query: gqlQuery,
746+
variables: new() { },
747+
authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader),
748+
clientRoleHeader: clientRoleHeader);
749+
750+
Assert.AreEqual(response.GetProperty("items")[0].GetProperty("name").ToString(), "Earth");
751+
}
752+
753+
/// <summary>
754+
/// Tests that the nested field level query filter passes authorization when nested filter fields are authorized
755+
/// because the field 'id' on object type 'earth' is an included field of the read operation
756+
/// permissions defined for the anonymous role.
757+
/// </summary>
758+
[TestMethod]
759+
public async Task TestQueryFilterNestedFieldAuth_AuthorizedNestedField()
760+
{
761+
string gqlQuery = @"{
762+
planets(first: 1, " + QueryBuilder.FILTER_FIELD_NAME + @" : {earth : {id : {eq : """ + _idList[0] + @"""}}})
763+
{
764+
items {
765+
earth {
766+
id
767+
}
768+
}
769+
}
770+
}";
771+
772+
JsonElement actual = await ExecuteGraphQLRequestAsync(_graphQLQueryName, query: gqlQuery);
773+
Assert.AreEqual(actual.GetProperty("items")[0].GetProperty("earth").GetProperty("id").ToString(), _idList[0]);
774+
}
775+
776+
/// <summary>
777+
/// Tests that the nested field level query filter fails authorization when nested filter fields are
778+
/// unauthorized because the field 'name' on object type 'earth' is an excluded field of the read
779+
/// operation permissions defined for the anonymous role.
780+
/// </summary>
781+
[TestMethod]
782+
public async Task TestQueryFilterNestedFieldAuth_UnauthorizedNestedField()
783+
{
784+
// Run query
785+
string gqlQuery = @"{
786+
planets(first: 1, " + QueryBuilder.FILTER_FIELD_NAME + @" : {earth : {name : {eq : ""test name""}}})
787+
{
788+
items {
789+
id
790+
name
791+
earth {
792+
name
793+
}
794+
}
795+
}
796+
}";
797+
798+
string clientRoleHeader = AuthorizationType.Anonymous.ToString();
799+
JsonElement response = await ExecuteGraphQLRequestAsync(
800+
queryName: _graphQLQueryName,
801+
query: gqlQuery,
802+
variables: new() { { "name", "test name" } },
803+
authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader),
804+
clientRoleHeader: clientRoleHeader);
805+
806+
// Validate the result contains the GraphQL authorization error code.
807+
string errorMessage = response.ToString();
808+
Assert.IsTrue(errorMessage.Contains(DataApiBuilderException.GRAPHQL_FILTER_FIELD_AUTHZ_FAILURE));
809+
}
810+
811+
/// <summary>
812+
/// This is for testing the scenario when the filter field is authorized, but the query field is unauthorized.
813+
/// For "type" field in "Earth" GraphQL type, it has @authorize(policy: "authenticated") directive in the test schema,
814+
/// but in the runtime config, this field is marked as included field for read operation with anonymous role,
815+
/// this should return unauthorized.
816+
/// </summary>
817+
[TestMethod]
818+
public async Task TestQueryFieldAuthConflictingWithFilterFieldAuth_Unauthorized()
819+
{
820+
// Run query
821+
string gqlQuery = @"{
822+
earths(first: 1, " + QueryBuilder.FILTER_FIELD_NAME + @" : {id : {eq : """ + _idList[0] + @"""}})
823+
{
824+
items {
825+
id
826+
type
827+
}
828+
}
829+
}";
830+
831+
JsonElement response = await ExecuteGraphQLRequestAsync(_graphQLQueryName, query: gqlQuery);
832+
833+
// Validate the result contains the GraphQL authorization error code.
834+
string errorMessage = response.ToString();
835+
Assert.IsTrue(errorMessage.Contains("The current user is not authorized to access this resource."));
836+
}
837+
#endregion
838+
645839
[ClassCleanup]
646840
public static void TestFixtureTearDown()
647841
{

0 commit comments

Comments
 (0)