diff --git a/DataGateway.Auth/AuthorizationMetadataHelpers.cs b/DataGateway.Auth/AuthorizationMetadataHelpers.cs index b7873bd70d..ef44cd77d0 100644 --- a/DataGateway.Auth/AuthorizationMetadataHelpers.cs +++ b/DataGateway.Auth/AuthorizationMetadataHelpers.cs @@ -14,10 +14,13 @@ public class EntityMetadata public Dictionary RoleToActionMap { get; set; } = new(); /// - /// Given the key (actionName) returns a key/value collection of fieldName to Roles - /// i.e. READ action - /// Key(field): id -> Value(collection): permitted in {Role1, Role2, ..., RoleN} - /// Key(field): title -> Value(collection): permitted in {Role1} + /// Field to action to role mapping. + /// Given the key (Field aka. column name) returns a key/value collection of action to Roles + /// i.e. ID column + /// Key(field): id -> Dictionary(actions) + /// each entry in the dictionary contains action to role map. + /// create: permitted in {Role1, Role2, ..., RoleN} + /// delete: permitted in {Role1, RoleN} /// public Dictionary>> FieldToRolesMap { get; set; } = new(); diff --git a/DataGateway.Auth/IAuthorizationResolver.cs b/DataGateway.Auth/IAuthorizationResolver.cs index 934a84de7e..f418a2ea10 100644 --- a/DataGateway.Auth/IAuthorizationResolver.cs +++ b/DataGateway.Auth/IAuthorizationResolver.cs @@ -91,14 +91,19 @@ public interface IAuthorizationResolver /// Entity to lookup permissions /// Action to lookup applicable roles /// Collection of roles. Empty list if entityPermissionsMap is null. - public static IEnumerable GetRolesForAction(string entityName, string actionName, Dictionary? entityPermissionsMap) + public static IEnumerable GetRolesForAction( + string entityName, + string actionName, + Dictionary? entityPermissionsMap) { if (entityName is null) { throw new ArgumentNullException(paramName: "entityName"); } - if (entityPermissionsMap is not null && entityPermissionsMap[entityName].ActionToRolesMap.TryGetValue(actionName, out List? roleList) && roleList is not null) + if (entityPermissionsMap is not null && + entityPermissionsMap[entityName].ActionToRolesMap.TryGetValue(actionName, out List? roleList) && + roleList is not null) { return roleList; } diff --git a/DataGateway.Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/DataGateway.Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index ba5408d039..f68b5d1994 100644 --- a/DataGateway.Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/DataGateway.Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -1,10 +1,12 @@ #nullable enable using System.Collections.Generic; +using System.Linq; using System.Net; using System.Security.Claims; using System.Text.Json; using Azure.DataGateway.Config; using Azure.DataGateway.Service.Authorization; +using Azure.DataGateway.Service.Configurations; using Azure.DataGateway.Service.Exceptions; using Azure.DataGateway.Service.Models; using Microsoft.AspNetCore.Http; @@ -87,7 +89,9 @@ public void NoRoleHeader_RoleContextTest() Assert.AreEqual(authZResolver.IsValidRoleContext(context.Object), expected); } #endregion + #region Role and Action on Entity Tests + /// /// Tests the AreRoleAndActionDefinedForEntity stage of authorization. /// Request Action is defined for role -> VALID @@ -110,241 +114,351 @@ public void AreRoleAndActionDefinedForEntityTest( AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); // Mock Request Values - Assert.AreEqual(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, roleName, actionName), expected); + Assert.AreEqual(expected, authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, roleName, actionName)); } - #endregion /// - /// Tests the authorization stage: Columns defined for Action - /// Columns are allowed for role - /// Columns are not allowed for role - /// Wildcard included and/or excluded columns handling - /// and assumes request validation has already occurred + /// Test that wildcard actions are expanded to explicit actions. + /// Verifies that internal data structure are created correctly. /// - #region Positive Column Tests - [TestMethod("Column allowed for action on role")] - public void ColsDefinedForAction_ColsForActionTest() + [TestMethod("Wildcard actions are expand to all valid actions")] + public void TestWildcardAction() { + List expectedRoles = new() { AuthorizationHelpers.TEST_ROLE }; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, - ActionType.CREATE, - includedCols: new HashSet { "col1", "col2", "col3" } - ); + AuthorizationResolver.WILDCARD); + + // Override the action to be a list of string for wildcard instead of a list of object created by InitRuntimeConfig() + // + runtimeConfig.Entities[AuthorizationHelpers.TEST_ENTITY].Permissions[0].Actions = new object[] { JsonSerializer.SerializeToElement(AuthorizationResolver.WILDCARD) }; AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with column allowed. - List columns = new(new string[] { "col1" }); - bool expected = true; + // There should not be a wildcard action in AuthorizationResolver.EntityPermissionsMap + // + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, AuthorizationResolver.WILDCARD)); - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); - } + // All the wildcard action should be expand to explicit actions. + // + foreach (string actionName in RuntimeConfigValidator.ValidActions) + { + Assert.IsTrue(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, actionName)); - [TestMethod("All Columns allowed for action on role")] - public void ColDefinedForAction_ColsForActionTest() - { - RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( - AuthorizationHelpers.TEST_ENTITY, - AuthorizationHelpers.TEST_ROLE, - ActionType.CREATE, - includedCols: new HashSet { "col1", "col2", "col3" } - ); - AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + IEnumerable actualRolesForCol1 = authZResolver.GetRolesForField(AuthorizationHelpers.TEST_ENTITY, "col1", actionName); - // Mock Request Values - Query a configured entity/role/action with columns allowed. - List columns = new(new string[] { "col1", "col2", "col3" }); - bool expected = true; + CollectionAssert.AreEquivalent(expectedRoles, actualRolesForCol1.ToList()); + + IEnumerable actualRolesForAction = authZResolver.GetRolesForAction(AuthorizationHelpers.TEST_ENTITY, actionName); + CollectionAssert.AreEquivalent(expectedRoles, actualRolesForAction.ToList()); + } - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + // Validate that the authorization check fails because the actions are invalid. + // + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, TEST_ROLE, "patch")); + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, TEST_ROLE, "fetch")); } - [TestMethod("Wildcard included columns allowed for action on role")] - public void WildcardIncludeColDefinedForAction_ColsForActionTest() + /// + /// Verify that the internal data structure is created correctly when we have + /// Two roles for the same entity with different permission. + /// readOnlyRole - Read permission only for col1 and no policy. + /// readAndUpdateRole - read and update permission for col1 and no policy. + /// + [TestMethod] + public void TestRoleAndActionCombination() { - RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( - AuthorizationHelpers.TEST_ENTITY, - AuthorizationHelpers.TEST_ROLE, - ActionType.CREATE, - includedCols: new HashSet { "*" } - ); - AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + const string READ_ONLY_ROLE = "readOnlyRole"; + const string READ_AND_UPDATE_ROLE = "readAndUpdateRole"; - // Mock Request Values - Query a configured entity/role/action with columns allowed. - List columns = new(new string[] { "col1", "col2", "col3" }); - bool expected = true; + Field fieldsForRole = new( + include: new HashSet { "col1" }, + exclude: null); - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); - } + Action readAction = new( + Name: ActionType.READ, + Fields: fieldsForRole, + Policy: null); - [TestMethod("Wildcard excluded columns with some included for action on role success")] - public void WildcardIncludeColsSomeExcludeDefinedForActionSuccess_ColsForActionTest() - { - RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( - AuthorizationHelpers.TEST_ENTITY, - AuthorizationHelpers.TEST_ROLE, - ActionType.CREATE, - includedCols: new HashSet { "*" }, - excludedCols: new HashSet { "col1", "col2" } + Action updateAction = new( + Name: ActionType.UPDATE, + Fields: fieldsForRole, + Policy: null); + + PermissionSetting readOnlyPermission = new( + role: READ_ONLY_ROLE, + actions: new object[] { JsonSerializer.SerializeToElement(readAction) }); + + PermissionSetting readAndUpdatePermission = new( + role: READ_AND_UPDATE_ROLE, + actions: new object[] { JsonSerializer.SerializeToElement(readAction), JsonSerializer.SerializeToElement(updateAction) }); + + Entity sampleEntity = new( + Source: TEST_ENTITY, + Rest: null, + GraphQL: null, + Permissions: new PermissionSetting[] { readOnlyPermission, readAndUpdatePermission }, + Relationships: null, + Mappings: null + ); + + Dictionary entityMap = new(); + entityMap.Add(AuthorizationHelpers.TEST_ENTITY, sampleEntity); + + RuntimeConfig runtimeConfig = new( + Schema: "UnitTestSchema", + MsSql: null, + CosmosDb: null, + PostgreSql: null, + MySql: null, + DataSource: new DataSource(DatabaseType: DatabaseType.mssql), + RuntimeSettings: new Dictionary(), + Entities: entityMap ); - AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with column allowed. - List columns = new(new string[] { "col3", "col4" }); - bool expected = true; + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + // Verify that read only role has permission for read and nothing else. + // + Assert.IsTrue(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, READ_ONLY_ROLE, ActionType.READ)); + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, READ_ONLY_ROLE, ActionType.UPDATE)); + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, READ_ONLY_ROLE, ActionType.CREATE)); + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, READ_ONLY_ROLE, ActionType.DELETE)); + + // Verify that read only role has permission for read and nothing else. + // + Assert.IsTrue(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, READ_AND_UPDATE_ROLE, ActionType.READ)); + Assert.IsTrue(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, READ_AND_UPDATE_ROLE, ActionType.UPDATE)); + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, READ_AND_UPDATE_ROLE, ActionType.CREATE)); + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, READ_AND_UPDATE_ROLE, ActionType.DELETE)); + + List expectedRolesForRead = new() { READ_ONLY_ROLE, READ_AND_UPDATE_ROLE }; + List expectedRolesForUpdate = new() { READ_AND_UPDATE_ROLE }; + + IEnumerable actualReadRolesForCol1 = authZResolver.GetRolesForField(AuthorizationHelpers.TEST_ENTITY, "col1", ActionType.READ); + CollectionAssert.AreEquivalent(expectedRolesForRead, actualReadRolesForCol1.ToList()); + IEnumerable actualUpdateRolesForCol1 = authZResolver.GetRolesForField(AuthorizationHelpers.TEST_ENTITY, "col1", ActionType.UPDATE); + CollectionAssert.AreEquivalent(expectedRolesForUpdate, actualUpdateRolesForCol1.ToList()); + + IEnumerable actualRolesForRead = authZResolver.GetRolesForAction(AuthorizationHelpers.TEST_ENTITY, ActionType.READ); + CollectionAssert.AreEquivalent(expectedRolesForRead, actualRolesForRead.ToList()); + IEnumerable actualRolesForUpdate = authZResolver.GetRolesForAction(AuthorizationHelpers.TEST_ENTITY, ActionType.UPDATE); + CollectionAssert.AreEquivalent(expectedRolesForUpdate, actualRolesForUpdate.ToList()); } + #endregion - #region Negative Column Tests - [TestMethod("Columns NOT allowed for action on role")] - public void ColsNotDefinedForAction_ColsForActionTest() + + #region Column Tests + + /// + /// Tests the authorization stage: Columns defined for Action + /// Columns are allowed for role + /// Columns are not allowed for role + /// Wildcard included and/or excluded columns handling + /// and assumes request validation has already occurred + /// + [TestMethod("Explicit include columns with no exclusion")] + public void ExplicitIncludeColumn() { + HashSet includedColumns = new() { "col1", "col2", "col3" }; RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "col1", "col2", "col3" } + includedCols: includedColumns ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with column NOT allowed. - List columns = new(new string[] { "col4" }); - bool expected = false; + Assert.IsTrue(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, includedColumns)); - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + // Not allow column. + // + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, new List { "col4" })); + + // Mix of allow and not allow. Should result in not allow. + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, new List { "col3", "col4" })); + + // Column does not exist + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, new List { "col5", "col6" })); } - [TestMethod("Columns NOT allowed for action on role - with some valid cols")] - public void ColsNotDefinedForAction2_ColsForActionTest() + /// + /// Test to validate that for wildcard action, the authorization stage for column check + /// would pass if the action is one among create, read, update, delete and the columns are accessible. + /// Similarly if the column is in accessible, then we should not have access. + /// + [TestMethod("Explicit include and exclude columns")] + public void ExplicitIncludeAndExcludeColumns() { + HashSet includeColumns = new() { "col1", "col2" }; + HashSet excludeColumns = new() { "col3" }; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "col1", "col2", "col3" } + includedCols: includeColumns, + excludedCols: excludeColumns ); + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action - // to match all allowed columns, with one NOT allowed column. - List columns = new(new string[] { "col1", "col2", "col3", "col4" }); - bool expected = false; + Assert.IsTrue(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, includeColumns)); + + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, excludeColumns)); - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + // Not exist column in the inclusion or exclusion list + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, new List { "col4" })); + + // Mix of allow and not allow. Should result in not allow. + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, new List { "col1", "col3" })); } - [TestMethod("Columns NOT allowed for action on role - definition has inc/excl - req has only excluded cols")] - public void ColsNotDefinedForAction3_ColsForActionTest() + /// + /// Exclusion has precedence over inclusion. So for this test case, + /// col1 will be excluded even if it is in the inclusion list. + /// + [TestMethod("Same column in exclusion and inclusion list")] + public void ColumnExclusionWithSameColumnInclusion() { + HashSet includedColumns = new() { "col1", "col2" }; + HashSet excludedColumns = new() { "col1", "col4" }; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "col1", "col2", "col3" }, - excludedCols: new HashSet { "col4", "col5", "col6" } + includedCols: includedColumns, + excludedCols: excludedColumns ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with multiple columns NOT allowed. - List columns = new(new string[] { "col4", "col5" }); - bool expected = false; + // Col2 should be included. + // + Assert.IsTrue(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, new List { "col2" })); + + // Col1 should NOT to included since it is in exclusion list. + // + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, new List { "col1" })); - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, excludedColumns)); } - [TestMethod("Columns NOT allowed for action on role - Mixed allowed/disallowed in req.")] - public void ColsNotDefinedForAction4Mixed_ColsForActionTest() + /// + /// Test that wildcard inclusion will include all the columns in the table. + /// + [TestMethod("Wildcard included columns")] + public void WildcardColumnInclusion() { RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "col1", "col2", "col3" }, - excludedCols: new HashSet { "col4", "col5", "col6" } + includedCols: new HashSet { AuthorizationResolver.WILDCARD } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with 1 allowed/ 1 disallwed column(s). - List columns = new(new string[] { "col2", "col5" }); - bool expected = false; + List includedColumns = new() { "col1", "col2", "col3", "col4" }; - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + Assert.IsTrue(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, includedColumns)); } - [TestMethod("Wildcard excluded for action on role")] - public void WildcardExcludeColsDefinedForAction_ColsForActionTest() + /// + /// Test that wildcard inclusion will include all column except column specify in exclusion. + /// Exclusion has priority over inclusion. + /// + [TestMethod("Wildcard include columns with some column exclusion")] + public void WildcardColumnInclusionWithExplictExclusion() { + List includedColumns = new() { "col1", "col2" }; + HashSet excludedColumns = new() { "col3", "col4" }; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - excludedCols: new HashSet { "*" } + includedCols: new HashSet { AuthorizationResolver.WILDCARD }, + excludedCols: excludedColumns ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with columns not allowed. - List columns = new(new string[] { "col1", "col2", "col3" }); - bool expected = false; - - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + Assert.IsTrue(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, includedColumns)); + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, excludedColumns)); } - [TestMethod("Wildcard include all except some columns for action on role")] - public void WildcardIncludeColsSomeExcludeDefinedForAction_ColsForActionTest() + /// + /// Test that all columns should be excluded if the exclusion contains wildcard character. + /// + [TestMethod("Wildcard column exclusion")] + public void WildcardColumnExclusion() { + HashSet excludedColumns = new() { "col1", "col2", "col3", "col4" }; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "*" }, - excludedCols: new HashSet { "col1", "col2" } + excludedCols: new HashSet { AuthorizationResolver.WILDCARD } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with column allowed and column not allowed. - List columns = new(new string[] { "col3", "col1" }); - bool expected = false; - - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, excludedColumns)); } - [TestMethod("Wildcard exclude all except for some columns for action on role - Request with excluded column")] - public void WildcardExcludeColsSomeIncludeDefinedForAction_ColsForActionTest() + /// + /// For this test, exclusion has precedence over inclusion. So all columns will be excluded + /// because wildcard is specified in the exclusion list. + /// + [TestMethod("Wildcard column exclusion with some explicit columns inclusion")] + public void WildcardColumnExclusionWithExplicitColumnInclusion() { + HashSet includedColumns = new() { "col1", "col2" }; + HashSet excludedColumns = new() { "col3", "col4" }; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "col1", "col2" }, - excludedCols: new HashSet { "*" } + includedCols: includedColumns, + excludedCols: new HashSet { AuthorizationResolver.WILDCARD } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with two columns allowed, one not. - List columns = new(new string[] { "col1", "col2", "col3" }); - bool expected = false; - - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, includedColumns)); + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, excludedColumns)); } - [TestMethod("Wildcard exclude all except some columns for action on role - Request with all included columns")] - public void WildcardExcludeColsSomeIncludeDefinedForActionSuccess_ColsForActionTest() + /// + /// Test to validate that for wildcard action, the authorization stage for column check + /// would pass if the action is one among create, read, update, delete and the columns are accessible. + /// Similarly if the column is in accessible, then we should not have access. + /// + [TestMethod("Explicit include and exclude columns with wildcard actions")] + public void CheckIncludeAndExcludeColumnForWildcardAction() { + HashSet includeColumns = new() { "col1", "col2" }; + HashSet excludeColumns = new() { "col3" }; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, - ActionType.CREATE, - includedCols: new HashSet { "col1", "col2" }, - excludedCols: new HashSet { "*" } + AuthorizationResolver.WILDCARD, + includedCols: includeColumns, + excludedCols: excludeColumns ); - AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - // Mock Request Values - Query a configured entity/role/action with columns allowed. - List columns = new(new string[] { "col1", "col2" }); - bool expected = false; + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - Assert.AreEqual(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, columns), expected); + foreach (string actionName in RuntimeConfigValidator.ValidActions) + { + // Validate that the authorization check passes for valid CRUD actions + // because columns are accessbile or inaccessible. + Assert.IsTrue(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, actionName, includeColumns)); + Assert.IsFalse(authZResolver.AreColumnsAllowedForAction(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, actionName, excludeColumns)); + } } + #endregion #region Tests to validate Database policy parsing @@ -490,6 +604,7 @@ public void ParsePolicyWithDuplicateUserClaims(bool exceptionExpected, params st } } #endregion + #region Helpers public static RuntimeConfig InitRuntimeConfig( string entityName = "SampleEntity", diff --git a/DataGateway.Service.Tests/Authorization/REST/RestAuthorizationHandlerUnitTests.cs b/DataGateway.Service.Tests/Authorization/REST/RestAuthorizationHandlerUnitTests.cs index 501e2855e2..d4943d9da1 100644 --- a/DataGateway.Service.Tests/Authorization/REST/RestAuthorizationHandlerUnitTests.cs +++ b/DataGateway.Service.Tests/Authorization/REST/RestAuthorizationHandlerUnitTests.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Collections.Generic; using System.Security.Claims; +using System.Text.Json; using System.Threading.Tasks; using Azure.DataGateway.Auth; using Azure.DataGateway.Config; @@ -346,15 +347,20 @@ IEnumerable columnsRequested } /// - /// Sets up an authorization resolver with a config that specifies the wildcard ("*") as the test entity's actions + /// Sets up an authorization resolver with a config that specifies the wildcard ("*") as the test entity's actions. + /// Explicitly use this instead of AuthorizationHelpers.InitRuntimeConfig() because we want to create actions as + /// array of string instead of array of object. /// private static AuthorizationResolver SetupAuthResolverWithWildcardActions() { RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( entityName: AuthorizationHelpers.TEST_ENTITY, roleName: "admin", - actionName: "*" - ); + actionName: "*"); + + // Override the action to be a list of string for wildcard instead of a list of object created by InitRuntimeConfig() + // + runtimeConfig.Entities[AuthorizationHelpers.TEST_ENTITY].Permissions[0].Actions = new object[] { JsonSerializer.SerializeToElement(AuthorizationResolver.WILDCARD) }; return AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); } diff --git a/DataGateway.Service/Authorization/AuthorizationResolver.cs b/DataGateway.Service/Authorization/AuthorizationResolver.cs index 67699d383b..a1c77bc3e7 100644 --- a/DataGateway.Service/Authorization/AuthorizationResolver.cs +++ b/DataGateway.Service/Authorization/AuthorizationResolver.cs @@ -25,7 +25,7 @@ namespace Azure.DataGateway.Service.Authorization public class AuthorizationResolver : IAuthorizationResolver { private ISqlMetadataProvider _metadataProvider; - private const string WILDCARD = "*"; + public const string WILDCARD = "*"; public const string CLAIM_PREFIX = "@claims."; public const string FIELD_PREFIX = "@item."; public const string CLIENT_ROLE_HEADER = "X-MS-API-ROLE"; @@ -102,8 +102,7 @@ public bool AreRoleAndActionDefinedForEntity(string entityName, string roleName, { if (valueOfEntityToRole.RoleToActionMap.TryGetValue(roleName, out RoleMetadata? valueOfRoleToAction)) { - if (valueOfRoleToAction!.ActionToColumnMap.ContainsKey(WILDCARD) || - valueOfRoleToAction!.ActionToColumnMap.ContainsKey(action)) + if (valueOfRoleToAction!.ActionToColumnMap.ContainsKey(action)) { return true; } @@ -119,17 +118,7 @@ public bool AreColumnsAllowedForAction(string entityName, string roleName, strin // Columns.Count() will never be zero because this method is called after a check ensures Count() > 0 Assert.IsFalse(columns.Count() == 0, message: "columns.Count() should be greater than 0."); - ActionMetadata actionToColumnMap; - RoleMetadata roleInEntity = EntityPermissionsMap[entityName].RoleToActionMap[roleName]; - - try - { - actionToColumnMap = roleInEntity.ActionToColumnMap[actionName]; - } - catch (KeyNotFoundException) - { - actionToColumnMap = roleInEntity.ActionToColumnMap[WILDCARD]; - } + ActionMetadata actionToColumnMap = EntityPermissionsMap[entityName].RoleToActionMap[roleName].ActionToColumnMap[actionName]; // Each column present in the request is an "exposedColumn". // Authorization permissions reference "backingColumns" @@ -140,8 +129,8 @@ public bool AreColumnsAllowedForAction(string entityName, string roleName, strin if (_metadataProvider.TryGetBackingColumn(entityName, field: exposedColumn, out string? backingColumn)) { // backingColumn will not be null when TryGetBackingColumn() is true. - if (actionToColumnMap.Excluded.Contains(backingColumn!) || actionToColumnMap.Excluded.Contains(WILDCARD) || - !(actionToColumnMap.Included.Contains(WILDCARD) || actionToColumnMap.Included.Contains(backingColumn!))) + if (actionToColumnMap.Excluded.Contains(backingColumn!) || + !actionToColumnMap.Included.Contains(backingColumn!)) { // If column is present in excluded OR excluded='*' // If column is absent from included and included!=* @@ -189,20 +178,8 @@ private string GetDBPolicyForRequest(string entityName, string roleName, string RoleMetadata roleMetadata = EntityPermissionsMap[entityName].RoleToActionMap[roleName]; roleMetadata.ActionToColumnMap.TryGetValue(action, out ActionMetadata? actionMetadata); - // If action exists in map (explicitly specified in config), use its policy - // action should only be absent in roleMetadata if WILDCARD is in the map instead of specific actions, - // as authorization happens before policy parsing (would have already returned forbidden) - string? dbPolicy; - if (actionMetadata is not null) - { - dbPolicy = actionMetadata.DatabasePolicy; - - } // else check if wildcard exists in action map, if so use its policy, else null - else - { - roleMetadata.ActionToColumnMap.TryGetValue(WILDCARD, out ActionMetadata? wildcardMetadata); - dbPolicy = wildcardMetadata is not null ? wildcardMetadata.DatabasePolicy : null; - } + // Get the database policy for the specified action. + string? dbPolicy = actionMetadata!.DatabasePolicy; return dbPolicy is not null ? dbPolicy : string.Empty; } @@ -227,7 +204,7 @@ public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) object[] Actions = permission.Actions; foreach (JsonElement actionElement in Actions) { - string actionName = string.Empty; + string action = string.Empty; ActionMetadata actionToColumn = new(); IEnumerable allTableColumns = ResolveTableDefinitionColumns(entityName); @@ -235,7 +212,7 @@ public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) // Since no granular field permissions exist for this action within the current role. if (actionElement.ValueKind is JsonValueKind.String) { - actionName = actionElement.ToString(); + action = actionElement.ToString(); actionToColumn.Included.UnionWith(allTableColumns); actionToColumn.Allowed.UnionWith(allTableColumns); } @@ -246,7 +223,7 @@ public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) if (RuntimeConfig.TryGetDeserializedConfig(actionElement.ToString(), out Action? actionObj) && actionObj is not null) { - actionName = actionObj.Name; + action = actionObj.Name; if (actionObj.Fields!.Include is not null) { // When a wildcard (*) is defined for Included columns, all of the table's @@ -287,20 +264,25 @@ public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) } } - // Try to add the actionName to the map if not present. - // Builds up mapping: i.e. ActionType.CREATE permitted in {Role1, Role2, ..., RoleN} - if (!string.IsNullOrWhiteSpace(actionName) && !entityToRoleMap.ActionToRolesMap.TryAdd(actionName, new List(new string[] { role }))) + IEnumerable actionNames = GetAllActions(action); + foreach (string actionName in actionNames) { - entityToRoleMap.ActionToRolesMap[actionName].Add(role); - } + // Try to add the actionName to the map if not present. + // Builds up mapping: i.e. ActionType.CREATE permitted in {Role1, Role2, ..., RoleN} + if (!string.IsNullOrWhiteSpace(actionName) && + !entityToRoleMap.ActionToRolesMap.TryAdd(actionName, new List(new string[] { role }))) + { + entityToRoleMap.ActionToRolesMap[actionName].Add(role); + } - foreach (string allowedColumn in actionToColumn.Allowed) - { - entityToRoleMap.FieldToRolesMap.TryAdd(key: allowedColumn, CreateActionToRoleMap()); - entityToRoleMap.FieldToRolesMap[allowedColumn][actionName].Add(role); - } + foreach (string allowedColumn in actionToColumn.Allowed) + { + entityToRoleMap.FieldToRolesMap.TryAdd(key: allowedColumn, CreateActionToRoleMap()); + entityToRoleMap.FieldToRolesMap[allowedColumn][actionName].Add(role); + } - roleToAction.ActionToColumnMap[actionName] = actionToColumn; + roleToAction.ActionToColumnMap[actionName] = actionToColumn; + } } entityToRoleMap.RoleToActionMap[role] = roleToAction; @@ -310,6 +292,17 @@ public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) } } + /// + /// Helper method to create a list consisting of the given action name. + /// In case the action is a wildcard(*), it gets resolved to a set of CRUD operations. + /// + /// Action name. + /// IEnumerable of all available action name + private static IEnumerable GetAllActions(string action) + { + return WILDCARD.Equals(action) ? RuntimeConfigValidator.ValidActions : new List { action }; + } + /// public IEnumerable GetAllowedColumns(string entityName, string roleName, string action) { diff --git a/DataGateway.Service/Configurations/RuntimeConfigValidator.cs b/DataGateway.Service/Configurations/RuntimeConfigValidator.cs index 97a6c791a9..37abfa37a7 100644 --- a/DataGateway.Service/Configurations/RuntimeConfigValidator.cs +++ b/DataGateway.Service/Configurations/RuntimeConfigValidator.cs @@ -35,7 +35,7 @@ public class RuntimeConfigValidator : IConfigValidator private static readonly string _claimChars = @"@claims\.[^\s\)]*"; // Set of allowed actions for a request. - private static readonly HashSet _validActions = new() { ActionType.CREATE, ActionType.READ, ActionType.UPDATE, ActionType.DELETE }; + public static readonly HashSet ValidActions = new() { ActionType.CREATE, ActionType.READ, ActionType.UPDATE, ActionType.DELETE }; public RuntimeConfigValidator( RuntimeConfigProvider runtimeConfigProvider, @@ -162,10 +162,10 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) // Check if the IncludeSet/ExcludeSet contain wildcard. If they contain wildcard, we make sure that they // don't contain any other field. If they do, we throw an appropriate exception. - if (configAction.Fields!.Include.Contains("*") && configAction.Fields.Include.Count > 1 || - configAction.Fields.Exclude.Contains("*") && configAction.Fields.Exclude.Count > 1) + if (configAction.Fields!.Include.Contains(AuthorizationResolver.WILDCARD) && configAction.Fields.Include.Count > 1 || + configAction.Fields.Exclude.Contains(AuthorizationResolver.WILDCARD) && configAction.Fields.Exclude.Count > 1) { - string incExc = configAction.Fields.Include.Contains("*") && configAction.Fields.Include.Count > 1 ? "included" : "excluded"; + string incExc = configAction.Fields.Include.Contains(AuthorizationResolver.WILDCARD) && configAction.Fields.Include.Count > 1 ? "included" : "excluded"; throw new DataGatewayException( message: $"No other field can be present with wildcard in the {incExc} set for: entity:{entityName}," + $" role:{permissionSetting.Role}, action:{actionName}", @@ -389,8 +389,8 @@ private static void AreFieldsAccessible(string policy, HashSet includedF private static bool IsFieldAccessible(Match columnNameMatch, HashSet includedFields, HashSet excludedFields) { string columnName = columnNameMatch.Value.Substring(AuthorizationResolver.FIELD_PREFIX.Length); - if (excludedFields.Contains(columnName!) || excludedFields.Contains("*") || - !(includedFields.Contains("*") || includedFields.Contains(columnName))) + if (excludedFields.Contains(columnName!) || excludedFields.Contains(AuthorizationResolver.WILDCARD) || + !(includedFields.Contains(AuthorizationResolver.WILDCARD) || includedFields.Contains(columnName))) { // If column is present in excluded OR excluded='*' // If column is absent from included and included!=* @@ -472,7 +472,7 @@ private static void ValidateActionName(string actionName, string entityName, str /// Boolean value indicating whether the actionName is valid or not. public static bool IsValidActionName(string actionName) { - return actionName.Equals("*") || _validActions.Contains(actionName); + return actionName.Equals(AuthorizationResolver.WILDCARD) || ValidActions.Contains(actionName); } } } diff --git a/DataGateway.Service/Services/RestService.cs b/DataGateway.Service/Services/RestService.cs index 9707716011..814b0c624c 100644 --- a/DataGateway.Service/Services/RestService.cs +++ b/DataGateway.Service/Services/RestService.cs @@ -263,16 +263,16 @@ public static string HttpVerbToActions(string httpVerbName) switch (httpVerbName) { case "POST": - return "create"; + return ActionType.CREATE; case "PUT": case "PATCH": // Please refer to the use of this method, which is to look out for policy based on crud operation type. // Since create doesn't have filter predicates, PUT/PATCH would resolve to update operation. - return "update"; + return ActionType.UPDATE; case "DELETE": - return "delete"; + return ActionType.DELETE; case "GET": - return "read"; + return ActionType.READ; default: throw new DataGatewayException( message: "Unsupported operation type.",