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..933e2eab2e 100644 --- a/DataGateway.Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/DataGateway.Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -1,5 +1,6 @@ #nullable enable using System.Collections.Generic; +using System.Linq; using System.Net; using System.Security.Claims; using System.Text.Json; @@ -87,7 +88,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,8 +113,46 @@ 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)); + } + + /// + /// Test that wildcard actions are expanded to explicit actions. + /// Verifies that internal data structure are created correctly. + /// + [TestMethod] + public void TestWildcardAction() + { + string roleName = "myRole"; + List expectedRoles = new() { roleName }; + + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig(AuthorizationHelpers.TEST_ENTITY, roleName, 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); + + // There should not be a wildcard action in AuthorizationResolver.EntityPermissionsMap + // + Assert.IsFalse(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, roleName, AuthorizationResolver.WILDCARD)); + + // All the wildcard action should be expand to explicit actions. + // + string[] allAvailableActions = { ActionType.READ, ActionType.CREATE, ActionType.DELETE, ActionType.UPDATE }; + foreach (string action in allAvailableActions) + { + Assert.IsTrue(authZResolver.AreRoleAndActionDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, roleName, action)); + + IEnumerable actualRolesForCol1 = authZResolver.GetRolesForField(AuthorizationHelpers.TEST_ENTITY, "col1", action); + + CollectionAssert.AreEquivalent(expectedRoles, actualRolesForCol1.ToList()); + } + + IEnumerable actualRolesForAction = authZResolver.GetRolesForAction(AuthorizationHelpers.TEST_ENTITY, ActionType.CREATE); + CollectionAssert.AreEquivalent(expectedRoles, actualRolesForAction.ToList()); } + #endregion /// @@ -165,7 +206,7 @@ public void WildcardIncludeColDefinedForAction_ColsForActionTest() AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "*" } + includedCols: new HashSet { AuthorizationResolver.WILDCARD } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); @@ -183,7 +224,7 @@ public void WildcardIncludeColsSomeExcludeDefinedForActionSuccess_ColsForActionT AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "*" }, + includedCols: new HashSet { AuthorizationResolver.WILDCARD }, excludedCols: new HashSet { "col1", "col2" } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); @@ -278,7 +319,7 @@ public void WildcardExcludeColsDefinedForAction_ColsForActionTest() AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - excludedCols: new HashSet { "*" } + excludedCols: new HashSet { AuthorizationResolver.WILDCARD } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); @@ -296,7 +337,7 @@ public void WildcardIncludeColsSomeExcludeDefinedForAction_ColsForActionTest() AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, - includedCols: new HashSet { "*" }, + includedCols: new HashSet { AuthorizationResolver.WILDCARD }, excludedCols: new HashSet { "col1", "col2" } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); @@ -316,7 +357,7 @@ public void WildcardExcludeColsSomeIncludeDefinedForAction_ColsForActionTest() AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, includedCols: new HashSet { "col1", "col2" }, - excludedCols: new HashSet { "*" } + excludedCols: new HashSet { AuthorizationResolver.WILDCARD } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); @@ -335,7 +376,7 @@ public void WildcardExcludeColsSomeIncludeDefinedForActionSuccess_ColsForActionT AuthorizationHelpers.TEST_ROLE, ActionType.CREATE, includedCols: new HashSet { "col1", "col2" }, - excludedCols: new HashSet { "*" } + excludedCols: new HashSet { AuthorizationResolver.WILDCARD } ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); 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..29e17f576f 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; } @@ -122,14 +121,7 @@ public bool AreColumnsAllowedForAction(string entityName, string roleName, strin ActionMetadata actionToColumnMap; RoleMetadata roleInEntity = EntityPermissionsMap[entityName].RoleToActionMap[roleName]; - try - { - actionToColumnMap = roleInEntity.ActionToColumnMap[actionName]; - } - catch (KeyNotFoundException) - { - actionToColumnMap = roleInEntity.ActionToColumnMap[WILDCARD]; - } + actionToColumnMap = roleInEntity.ActionToColumnMap[actionName]; // Each column present in the request is an "exposedColumn". // Authorization permissions reference "backingColumns" @@ -140,8 +132,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 +181,7 @@ 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; - } + string? dbPolicy = actionMetadata!.DatabasePolicy; return dbPolicy is not null ? dbPolicy : string.Empty; } @@ -289,18 +268,31 @@ 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 }))) + // Expand wildcard action to explicit action type. + // + if (actionName.Equals(WILDCARD)) { - entityToRoleMap.ActionToRolesMap[actionName].Add(role); - } + string[] allAvailableActions = { ActionType.READ, ActionType.CREATE, ActionType.DELETE, ActionType.UPDATE }; - foreach (string allowedColumn in actionToColumn.Allowed) - { - entityToRoleMap.FieldToRolesMap.TryAdd(key: allowedColumn, CreateActionToRoleMap()); - entityToRoleMap.FieldToRolesMap[allowedColumn][actionName].Add(role); + foreach (string action in allAvailableActions) + { + PopulateActionToRoleMap(entityToRoleMap.ActionToRolesMap, action, role); + + PopulateFieldToRoleMap(entityToRoleMap.FieldToRolesMap, role, action, actionToColumn); + + roleToAction.ActionToColumnMap[action] = actionToColumn; + } } + // Otherwise, we know explicit action name. Just add that. + // + else if (!string.IsNullOrWhiteSpace(actionName)) + { + PopulateActionToRoleMap(entityToRoleMap.ActionToRolesMap, actionName, role); + + PopulateFieldToRoleMap(entityToRoleMap.FieldToRolesMap, role, actionName, actionToColumn); - roleToAction.ActionToColumnMap[actionName] = actionToColumn; + roleToAction.ActionToColumnMap[actionName] = actionToColumn; + } } entityToRoleMap.RoleToActionMap[role] = roleToAction; @@ -310,6 +302,28 @@ public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) } } + private static void PopulateFieldToRoleMap( + Dictionary>> fieldToRolesMap, + string role, + string actionName, + ActionMetadata actionToColumn) + { + foreach (string allowedColumn in actionToColumn.Allowed) + { + fieldToRolesMap.TryAdd(key: allowedColumn, CreateActionToRoleMap()); + + fieldToRolesMap[allowedColumn][actionName].Add(role); + } + } + + private static void PopulateActionToRoleMap(Dictionary> actionToRolesMap, string actionName, string role) + { + if (!actionToRolesMap.TryAdd(actionName, new List(new string[] { role }))) + { + actionToRolesMap[actionName].Add(role); + } + } + /// 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..220dd3c6dd 100644 --- a/DataGateway.Service/Configurations/RuntimeConfigValidator.cs +++ b/DataGateway.Service/Configurations/RuntimeConfigValidator.cs @@ -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 d9490039e8..3757356843 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.",