diff --git a/src/Auth/AuthorizationMetadataHelpers.cs b/src/Auth/AuthorizationMetadataHelpers.cs index d26e6af447..1ef13e663e 100644 --- a/src/Auth/AuthorizationMetadataHelpers.cs +++ b/src/Auth/AuthorizationMetadataHelpers.cs @@ -55,6 +55,23 @@ public class RoleMetadata /// Given the key (operation) returns the associated OperationMetadata object. /// public Dictionary OperationToColumnMap { get; set; } = new(); + + /// + /// Creates a deep clone of this RoleMetadata instance so that mutations + /// to the clone do not affect the original (and vice versa). + /// This is critical when copying permissions from one role to another + /// (e.g., anonymous → authenticated) to prevent shared mutable state. + /// + public RoleMetadata DeepClone() + { + RoleMetadata clone = new(); + foreach ((EntityActionOperation operation, OperationMetadata metadata) in OperationToColumnMap) + { + clone.OperationToColumnMap[operation] = metadata.DeepClone(); + } + + return clone; + } } /// @@ -68,4 +85,19 @@ public class OperationMetadata public HashSet Included { get; set; } = new(); public HashSet Excluded { get; set; } = new(); public HashSet AllowedExposedColumns { get; set; } = new(); + + /// + /// Creates a deep clone of this OperationMetadata instance so that + /// mutations to the clone do not affect the original (and vice versa). + /// + public OperationMetadata DeepClone() + { + return new OperationMetadata + { + DatabasePolicy = DatabasePolicy, + Included = new HashSet(Included), + Excluded = new HashSet(Excluded), + AllowedExposedColumns = new HashSet(AllowedExposedColumns) + }; + } } diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs index a17f61ade5..3a961ece4d 100644 --- a/src/Auth/IAuthorizationResolver.cs +++ b/src/Auth/IAuthorizationResolver.cs @@ -137,4 +137,23 @@ public static IEnumerable GetRolesForOperation( return new List(); } + + /// + /// Determines whether a given client role should be allowed through the GraphQL + /// schema-level authorization gate for a specific set of directive roles. + /// Centralizes the role inheritance logic so that callers (e.g. GraphQLAuthorizationHandler) + /// do not need to duplicate inheritance rules. + /// + /// Inheritance chain: named-role → authenticated → anonymous → none. + /// - If the role is explicitly listed in the directive roles, return true. + /// - If the role is 'authenticated' and 'anonymous' is listed, return true (inheritance). + /// - If the role is an unconfigured named role (not in any entity's explicit permissions) + /// and either 'authenticated' or 'anonymous' is listed, return true (inheritance). + /// - Explicitly configured named roles use strict matching only, to prevent unintended + /// access to operations outside their explicitly scoped permissions. + /// + /// The role from the X-MS-API-ROLE header. + /// The roles listed on the @authorize directive. + /// True if the client role should be allowed through the gate. + public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles); } diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index a1355679fb..65849d789d 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1052,6 +1052,7 @@ public void TestUpdateDataSourceHealthName(string healthName) Assert.AreEqual(2000, config.DataSource.Health.ThresholdMs); } + /// /// Tests that running "dab configure --runtime.mcp.description {value}" on a config with various values results /// in runtime config update. Takes in updated value for mcp.description and /// validates whether the runtime config reflects those updated values @@ -1079,6 +1080,347 @@ public void TestConfigureDescriptionForMcpSettings(string descriptionValue) Assert.AreEqual(descriptionValue, runtimeConfig.Runtime.Mcp.Description); } + /// + /// Validates that `dab configure --show-effective-permissions` correctly displays + /// effective permissions without modifying the config file. + /// Covers: + /// 1. Entities are listed alphabetically. + /// 2. Explicitly configured roles show their actions. + /// 3. When only anonymous is configured, authenticated inherits from anonymous. + /// 4. An inheritance note is emitted for unconfigured named roles. + /// 5. The config file is not modified. + /// + [DataTestMethod] + [DataRow( + true, false, + "authenticated", "Read (inherited from: anonymous)", + "Any unconfigured named role inherits from: anonymous", + DisplayName = "Only anonymous defined: authenticated inherits from anonymous.")] + [DataRow( + true, true, + null, null, + "Any unconfigured named role inherits from: authenticated", + DisplayName = "Both anonymous and authenticated defined: named roles inherit from authenticated.")] + public void TestShowEffectivePermissions( + bool hasAnonymous, + bool hasAuthenticated, + string? expectedInheritedRole, + string? expectedInheritedActionsSubstring, + string expectedInheritanceNote) + { + // Arrange: build a config with two entities (Zebra before Alpha to verify sorting) + // and the specified role combinations. + string permissionsJson = ""; + List perms = new(); + if (hasAnonymous) + { + perms.Add(@"{ ""role"": ""anonymous"", ""actions"": [""read""] }"); + } + + if (hasAuthenticated) + { + perms.Add(@"{ ""role"": ""authenticated"", ""actions"": [""create"", ""read"" ] }"); + } + + permissionsJson = string.Join(",", perms); + + string configJson = @" + { + ""$schema"": ""test"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""testconnectionstring"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"", ""allow-introspection"": true }, + ""host"": { + ""mode"": ""development"", + ""cors"": { ""origins"": [], ""allow-credentials"": false }, + ""authentication"": { ""provider"": ""StaticWebApps"" } + } + }, + ""entities"": { + ""Zebra"": { + ""source"": ""ZebraTable"", + ""permissions"": [" + permissionsJson + @"] + }, + ""Alpha"": { + ""source"": ""AlphaTable"", + ""permissions"": [" + permissionsJson + @"] + } + } + }"; + + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(configJson)); + string configBefore = _fileSystem.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + + // Capture logger output via a StringWriter on Console + StringWriter writer = new(); + Console.SetOut(writer); + + // Act + ConfigureOptions options = new( + showEffectivePermissions: true, + config: TEST_RUNTIME_CONFIG_FILE + ); + bool isSuccess = ConfigGenerator.TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert: operation succeeded + Assert.IsTrue(isSuccess, "TryShowEffectivePermissions should return true."); + + // Assert: config file is unchanged (read-only operation) + string configAfter = _fileSystem.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.AreEqual(configBefore, configAfter, "Config file should not be modified by --show-effective-permissions."); + + // Note: TryShowEffectivePermissions uses ILogger (not Console), so we verify + // behavior indirectly by re-checking the logic via the RuntimeConfig. + // Parse config and verify the expected inheritance rules hold. + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config)); + + // Verify alphabetical entity ordering + string[] entityNames = config!.Entities.Select(e => e.Key).ToArray(); + string[] sortedNames = entityNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray(); + CollectionAssert.AreEqual(sortedNames, new[] { "Alpha", "Zebra" }, + "Entities should be listed alphabetically."); + + // Verify entity permission structure matches expectations + Entity firstEntity = config.Entities[sortedNames[0]]; + bool configHasAnonymous = firstEntity.Permissions.Any(p => p.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase)); + bool configHasAuthenticated = firstEntity.Permissions.Any(p => p.Role.Equals("authenticated", StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual(hasAnonymous, configHasAnonymous); + Assert.AreEqual(hasAuthenticated, configHasAuthenticated); + + // When only anonymous is defined, verify inherited role line would be generated + if (hasAnonymous && !hasAuthenticated) + { + Assert.IsNotNull(expectedInheritedRole, "Expected inherited role should be 'authenticated'."); + Assert.AreEqual("authenticated", expectedInheritedRole); + + // Verify the anonymous actions would be inherited + EntityPermission anonPerm = firstEntity.Permissions.First(p => p.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase)); + string inheritedActions = string.Join(", ", anonPerm.Actions.Select(a => a.Action.ToString())); + Assert.AreEqual("Read", inheritedActions, "Inherited actions should match anonymous role's actions."); + } + + // When authenticated is explicitly defined, no inheritance line for authenticated + if (hasAuthenticated) + { + Assert.IsNull(expectedInheritedRole, "No inherited role line when authenticated is explicitly configured."); + } + } + + /// + /// Validates that --show-effective-permissions returns true and outputs entities sorted a-z by name. + /// + [TestMethod] + public void TestShowEffectivePermissions_EntitiesSortedAlphabetically() + { + // Arrange: Config with "Zebra" entity before "Apple" entity (insertion order reversed). + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Zebra"": {{ + ""source"": ""dbo.Zebra"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }} + ] + }}, + ""Apple"": {{ + ""source"": ""dbo.Apple"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + int appleIndex = logMessages.FindIndex(m => m.Contains("Apple")); + int zebraIndex = logMessages.FindIndex(m => m.Contains("Zebra")); + Assert.IsTrue(appleIndex >= 0, "Expected 'Apple' entity in output."); + Assert.IsTrue(zebraIndex >= 0, "Expected 'Zebra' entity in output."); + Assert.IsTrue(appleIndex < zebraIndex, "Expected 'Apple' to appear before 'Zebra' in output."); + } + + /// + /// Validates that --show-effective-permissions outputs roles sorted a-z within each entity. + /// + [TestMethod] + public void TestShowEffectivePermissions_RolesSortedAlphabeticallyWithinEntity() + { + // Arrange: Config with roles "zebra-role" before "admin" (insertion order reversed). + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Book"": {{ + ""source"": ""dbo.Book"", + ""permissions"": [ + {{ ""role"": ""zebra-role"", ""actions"": [""read""] }}, + {{ ""role"": ""admin"", ""actions"": [""create"", ""read"", ""update"", ""delete""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + + // Within the entity, "admin" should appear before "zebra-role". + int adminIndex = logMessages.FindIndex(m => m.Contains("admin")); + int zebraRoleIndex = logMessages.FindIndex(m => m.Contains("zebra-role")); + Assert.IsTrue(adminIndex >= 0, "Expected 'admin' role in output."); + Assert.IsTrue(zebraRoleIndex >= 0, "Expected 'zebra-role' role in output."); + Assert.IsTrue(adminIndex < zebraRoleIndex, "Expected 'admin' to appear before 'zebra-role' in output."); + } + + /// + /// Validates that --show-effective-permissions shows the authenticated-inherits-anonymous line + /// when anonymous is configured but authenticated is not. + /// + [TestMethod] + public void TestShowEffectivePermissions_AuthenticatedInheritsAnonymousNote() + { + // Arrange: anonymous defined, authenticated not defined. + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Book"": {{ + ""source"": ""dbo.Book"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + + // Should show "authenticated" inheriting from "anonymous". + bool hasAuthenticatedInheritedLine = logMessages.Any(m => + m.Contains("authenticated") && m.Contains("inherited from") && m.Contains("anonymous")); + Assert.IsTrue(hasAuthenticatedInheritedLine, "Expected a line showing authenticated inherits from anonymous."); + + // Should show inheritance note for unconfigured named roles. + // When only anonymous is defined, the note points to "anonymous" (since authenticated + // is itself shown as inheriting from anonymous via the line above). + bool hasInheritanceNote = logMessages.Any(m => + m.Contains("unconfigured named role") && m.Contains("anonymous")); + Assert.IsTrue(hasInheritanceNote, "Expected an inheritance note pointing to 'anonymous'."); + } + + /// + /// Validates that --show-effective-permissions does not show an authenticated-inherits-anonymous + /// line when authenticated is explicitly configured for the entity. + /// + [TestMethod] + public void TestShowEffectivePermissions_NoInheritanceNoteWhenAuthenticatedExplicitlyConfigured() + { + // Arrange: Both anonymous and authenticated explicitly defined. + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Book"": {{ + ""source"": ""dbo.Book"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }}, + {{ ""role"": ""authenticated"", ""actions"": [""read"", ""create""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + + // Should NOT show an "authenticated inherits from anonymous" line. + bool hasUnexpectedInheritanceLine = logMessages.Any(m => + m.Contains("authenticated") && m.Contains("inherited from") && m.Contains("anonymous")); + Assert.IsFalse(hasUnexpectedInheritanceLine, + "Should not show authenticated-inherits-anonymous when authenticated is explicitly configured."); + } + + /// + /// Validates that --show-effective-permissions returns false when the config file does not exist. + /// + [TestMethod] + public void TestShowEffectivePermissions_ReturnsFalseWhenConfigMissing() + { + // Arrange: no config file added to the file system. + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + + ConfigureOptions options = new( + config: "nonexistent-config.json", + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsFalse(isSuccess); + } + /// /// Sets up the mock file system with an initial configuration file. /// This method adds a config file to the mock file system and verifies its existence. @@ -1095,6 +1437,34 @@ private void SetupFileSystemWithInitialConfig(string jsonConfig) Assert.IsNotNull(config.Runtime); } + /// + /// A simple ILogger implementation that records all log messages to a list, + /// enabling tests to assert on log output without redirecting console streams. + /// + private sealed class ListLogger : ILogger + { + private readonly List _messages; + + public ListLogger(List messages) + { + _messages = messages; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + _messages.Add(formatter(state, exception)); + } + } + /// /// Tests adding user-delegated-auth configuration options individually or together. /// Verifies that enabled and database-audience properties can be set independently or combined. diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 262cbc9145..b59eb45585 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -75,6 +75,7 @@ public ConfigureOptions( RollingInterval? fileSinkRollingInterval = null, int? fileSinkRetainedFileCountLimit = null, long? fileSinkFileSizeLimitBytes = null, + bool showEffectivePermissions = false, string? config = null) : base(config) { @@ -141,6 +142,7 @@ public ConfigureOptions( FileSinkRollingInterval = fileSinkRollingInterval; FileSinkRetainedFileCountLimit = fileSinkRetainedFileCountLimit; FileSinkFileSizeLimitBytes = fileSinkFileSizeLimitBytes; + ShowEffectivePermissions = showEffectivePermissions; } [Option("data-source.database-type", Required = false, HelpText = "Database type. Allowed values: MSSQL, PostgreSQL, CosmosDB_NoSQL, MySQL.")] @@ -302,11 +304,27 @@ public ConfigureOptions( [Option("runtime.telemetry.file.file-size-limit-bytes", Required = false, HelpText = "Configure maximum file size limit in bytes. Default: 1048576")] public long? FileSinkFileSizeLimitBytes { get; } + [Option("show-effective-permissions", Required = false, HelpText = "Display effective permissions for all entities, including inherited permissions. Entities are listed in alphabetical order.")] + public bool ShowEffectivePermissions { get; } + public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) { logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion()); - bool isSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem); - if (isSuccess) + + if (ShowEffectivePermissions) + { + bool isSuccess = ConfigGenerator.TryShowEffectivePermissions(this, loader, fileSystem); + if (!isSuccess) + { + logger.LogError("Failed to display effective permissions."); + return CliReturnCode.GENERAL_ERROR; + } + + return CliReturnCode.SUCCESS; + } + + bool configSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem); + if (configSuccess) { logger.LogInformation("Successfully updated runtime settings in the config file."); return CliReturnCode.SUCCESS; diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 40bb6c7262..993d320e06 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -589,6 +589,63 @@ public static bool TryCreateSourceObjectForNewEntity( return true; } + + /// + /// Displays the effective permissions for all entities defined in the config, listed alphabetically by entity name. + /// Effective permissions include explicitly configured roles as well as inherited permissions: + /// - anonymous → authenticated (when authenticated is not explicitly configured) + /// - authenticated → any named role not explicitly configured for the entity + /// + /// True if the effective permissions were successfully displayed; otherwise, false. + public static bool TryShowEffectivePermissions(ConfigureOptions options, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) + { + if (!TryGetConfigFileBasedOnCliPrecedence(loader, options.Config, out string runtimeConfigFile)) + { + return false; + } + + if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig)) + { + _logger.LogError("Failed to read the config file: {runtimeConfigFile}.", runtimeConfigFile); + return false; + } + + const string ROLE_ANONYMOUS = "anonymous"; + const string ROLE_AUTHENTICATED = "authenticated"; + + // Iterate entities sorted a-z by name. + foreach ((string entityName, Entity entity) in runtimeConfig.Entities.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)) + { + _logger.LogInformation("Entity: {entityName}", entityName); + + bool hasAnonymous = entity.Permissions.Any(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)); + bool hasAuthenticated = entity.Permissions.Any(p => p.Role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)); + + foreach (EntityPermission permission in entity.Permissions.OrderBy(p => p.Role, StringComparer.OrdinalIgnoreCase)) + { + string actions = string.Join(", ", permission.Actions.Select(a => a.Action.ToString())); + _logger.LogInformation(" Role: {role} | Actions: {actions}", permission.Role, actions); + } + + // Show inherited authenticated permissions when authenticated is not explicitly configured. + if (hasAnonymous && !hasAuthenticated) + { + EntityPermission anonPermission = entity.Permissions.First(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)); + string inheritedActions = string.Join(", ", anonPermission.Actions.Select(a => a.Action.ToString())); + _logger.LogInformation(" Role: {role} | Actions: {actions} (inherited from: {source})", ROLE_AUTHENTICATED, inheritedActions, ROLE_ANONYMOUS); + } + + // Show inheritance note for named roles. + string inheritSource = hasAuthenticated ? ROLE_AUTHENTICATED : (hasAnonymous ? ROLE_ANONYMOUS : string.Empty); + if (!string.IsNullOrEmpty(inheritSource)) + { + _logger.LogInformation(" Any unconfigured named role inherits from: {inheritSource}", inheritSource); + } + } + + return true; + } + /// /// Tries to update the runtime settings based on the provided runtime options. /// diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 0f22b9cd28..7c3c2fedb2 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -38,6 +38,13 @@ public class AuthorizationResolver : IAuthorizationResolver public Dictionary EntityPermissionsMap { get; private set; } = new(); + /// + /// Cached set of named roles that are explicitly configured in at least one entity's permissions. + /// Used by to determine whether a named role + /// should use strict directive matching vs. inheritance at the GraphQL @authorize gate. + /// + private HashSet _explicitlyConfiguredNamedRoles = new(StringComparer.OrdinalIgnoreCase); + public AuthorizationResolver( RuntimeConfigProvider runtimeConfigProvider, IMetadataProviderFactory metadataProviderFactory, @@ -119,6 +126,7 @@ public bool IsValidRoleContext(HttpContext httpContext) /// public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string roleName, EntityActionOperation operation) { + roleName = GetEffectiveRoleName(entityIdentifier, roleName); if (EntityPermissionsMap.TryGetValue(entityIdentifier, out EntityMetadata? valueOfEntityToRole)) { if (valueOfEntityToRole.RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? valueOfRoleToOperation)) @@ -135,6 +143,7 @@ public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string public bool IsStoredProcedureExecutionPermitted(string entityName, string roleName, SupportedHttpVerb httpVerb) { + roleName = GetEffectiveRoleName(entityName, roleName); bool executionPermitted = EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata) && entityMetadata is not null && entityMetadata.RoleToOperationMap.TryGetValue(roleName, out _); @@ -144,6 +153,7 @@ public bool IsStoredProcedureExecutionPermitted(string entityName, string roleNa /// public bool AreColumnsAllowedForOperation(string entityName, string roleName, EntityActionOperation operation, IEnumerable columns) { + roleName = GetEffectiveRoleName(entityName, roleName); string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName); ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); @@ -210,6 +220,7 @@ public string ProcessDBPolicy(string entityName, string roleName, EntityActionOp /// public string GetDBPolicyForRequest(string entityName, string roleName, EntityActionOperation operation) { + roleName = GetEffectiveRoleName(entityName, roleName); if (!EntityPermissionsMap[entityName].RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? roleMetadata)) { return string.Empty; @@ -259,6 +270,9 @@ public static string GetRoleOfGraphQLRequest(IMiddlewareContext context) /// private void SetEntityPermissionMap(RuntimeConfig runtimeConfig) { + Dictionary newEntityPermissionsMap = new(); + HashSet newExplicitlyConfiguredNamedRoles = new(StringComparer.OrdinalIgnoreCase); + foreach ((string entityName, Entity entity) in runtimeConfig.Entities) { EntityMetadata entityToRoleMap = new(); @@ -322,6 +336,8 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig) // When a wildcard (*) is defined for Excluded columns, all of the table's // columns must be resolved and placed in the operationToColumn Key/Value store. + // This is especially relevant for delete requests, where the operation may not include + // any columns, but the policy still needs to be evaluated. if (entityAction.Fields.Exclude is null || (entityAction.Fields.Exclude.Count == 1 && entityAction.Fields.Exclude.Contains(WILDCARD))) { @@ -384,13 +400,28 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig) CopyOverPermissionsFromAnonymousToAuthenticatedRole(entityToRoleMap, allowedColumnsForAnonymousRole); } - EntityPermissionsMap[entityName] = entityToRoleMap; + newEntityPermissionsMap[entityName] = entityToRoleMap; + + // Collect all named roles (non-system) that are explicitly configured for this entity. + foreach (string roleName in entityToRoleMap.RoleToOperationMap.Keys) + { + if (!roleName.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && + !roleName.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) + { + newExplicitlyConfiguredNamedRoles.Add(roleName); + } + } } + + EntityPermissionsMap = newEntityPermissionsMap; + _explicitlyConfiguredNamedRoles = newExplicitlyConfiguredNamedRoles; } /// /// Helper method to copy over permissions from anonymous role to authenticated role in the case /// when anonymous role is defined for an entity in the config but authenticated role is not. + /// Uses deep cloning to ensure the authenticated role's RoleMetadata is a separate instance + /// from anonymous, preventing shared mutable state between the two roles. /// /// The EntityMetadata for the entity for which we want to copy permissions /// from anonymous to authenticated role. @@ -399,9 +430,10 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole( EntityMetadata entityToRoleMap, HashSet allowedColumnsForAnonymousRole) { - // Using assignment operator overrides the existing value for the key / - // adds a new entry for (key,value) pair if absent, to the map. - entityToRoleMap.RoleToOperationMap[ROLE_AUTHENTICATED] = entityToRoleMap.RoleToOperationMap[ROLE_ANONYMOUS]; + // Deep clone the RoleMetadata so that anonymous and authenticated roles + // do not share mutable OperationMetadata instances. Without deep cloning, + // any future mutation of one role's permissions would silently affect the other. + entityToRoleMap.RoleToOperationMap[ROLE_AUTHENTICATED] = entityToRoleMap.RoleToOperationMap[ROLE_ANONYMOUS].DeepClone(); // Copy over OperationToRolesMap for authenticated role from anonymous role. Dictionary allowedOperationMap = @@ -426,6 +458,93 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole( } } + /// + public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles) + { + if (directiveRoles is null || directiveRoles.Count == 0) + { + return false; + } + + // Explicit match — role is directly listed. + if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + // 'authenticated' inherits from 'anonymous': allow authenticated when anonymous is in the directive. + if (clientRole.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase) && + directiveRoles.Any(role => role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + // For named roles (non-system), only apply inheritance if the role is not explicitly + // configured in any entity. Explicitly configured roles have their own permission scopes + // and should only pass directives that list them (or a system role they'd inherit from) + // explicitly, preventing unintended access to operations outside their configured scope. + if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && + !clientRole.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase) && + !IsNamedRoleExplicitlyConfigured(clientRole) && + (directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) || + directiveRoles.Any(role => role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)))) + { + return true; + } + + return false; + } + + /// + /// Returns true if the given named role appears in the explicit permissions configuration of + /// any entity. Roles that are explicitly configured have their own permission scopes and + /// should not inherit permissions from system roles at the GraphQL directive level. + /// + private bool IsNamedRoleExplicitlyConfigured(string roleName) + { + return _explicitlyConfiguredNamedRoles.Contains(roleName); + } + + /// + /// Returns the effective role name for permission lookups, implementing role inheritance. + /// System roles (anonymous, authenticated) always resolve to themselves. + /// For any other named role not explicitly configured for the entity, this method falls back + /// to the 'authenticated' role if it is present (which itself may already inherit from 'anonymous'). + /// Inheritance chain: named-role → authenticated → anonymous → none. + /// + /// Name of the entity being accessed. + /// Role name from the request. + /// The role name whose permissions should apply for this request. + private string GetEffectiveRoleName(string entityName, string roleName) + { + // System roles always resolve to themselves; they do not inherit from other roles. + if (roleName.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) || + roleName.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) + { + return roleName; + } + + if (!EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata)) + { + return roleName; + } + + // Named role explicitly configured: use its own permissions. + if (entityMetadata.RoleToOperationMap.ContainsKey(roleName)) + { + return roleName; + } + + // Named role not configured: inherit from 'authenticated' if present. + // Note: 'authenticated' itself may already inherit from 'anonymous' via setup-time copy. + if (entityMetadata.RoleToOperationMap.ContainsKey(ROLE_AUTHENTICATED)) + { + return ROLE_AUTHENTICATED; + } + + return roleName; + } + /// /// Returns a list of all possible operations depending on the provided EntitySourceType. /// Stored procedures only support Operation.Execute. @@ -474,6 +593,7 @@ private static void PopulateAllowedExposedColumns( /// public IEnumerable GetAllowedExposedColumns(string entityName, string roleName, EntityActionOperation operation) { + roleName = GetEffectiveRoleName(entityName, roleName); return EntityPermissionsMap[entityName].RoleToOperationMap[roleName].OperationToColumnMap[operation].AllowedExposedColumns; } @@ -746,12 +866,7 @@ private static string GetClaimValue(Claim claim) } } - /// - /// Get list of roles defined for entity within runtime configuration.. This is applicable for GraphQL when creating authorization - /// directive on Object type. - /// - /// Name of entity. - /// Collection of role names. + /// public IEnumerable GetRolesForEntity(string entityName) { return EntityPermissionsMap[entityName].RoleToOperationMap.Keys; diff --git a/src/Core/Authorization/GraphQLAuthorizationHandler.cs b/src/Core/Authorization/GraphQLAuthorizationHandler.cs index 2760777e94..54a6362c68 100644 --- a/src/Core/Authorization/GraphQLAuthorizationHandler.cs +++ b/src/Core/Authorization/GraphQLAuthorizationHandler.cs @@ -17,6 +17,13 @@ namespace Azure.DataApiBuilder.Core.Authorization; /// public class GraphQLAuthorizationHandler : IAuthorizationHandler { + private readonly Azure.DataApiBuilder.Auth.IAuthorizationResolver _authorizationResolver; + + public GraphQLAuthorizationHandler(Azure.DataApiBuilder.Auth.IAuthorizationResolver authorizationResolver) + { + _authorizationResolver = authorizationResolver; + } + /// /// Authorize access to field based on contents of @authorize directive. /// Validates that the requestor is authenticated, and that the @@ -44,7 +51,7 @@ public ValueTask AuthorizeAsync( // Schemas defining authorization policies are not supported, even when roles are defined appropriately. // Requests will be short circuited and rejected (authorization forbidden). - if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && IsInHeaderDesignatedRole(clientRole, directive.Roles)) + if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles)) { if (!string.IsNullOrEmpty(directive.Policy)) { @@ -83,7 +90,7 @@ public ValueTask AuthorizeAsync( { // Schemas defining authorization policies are not supported, even when roles are defined appropriately. // Requests will be short circuited and rejected (authorization forbidden). - if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && IsInHeaderDesignatedRole(clientRole, directive.Roles)) + if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles)) { if (!string.IsNullOrEmpty(directive.Policy)) { @@ -129,30 +136,6 @@ private static bool TryGetApiRoleHeader(IDictionary contextData return false; } - /// - /// Checks the pre-validated clientRoleHeader value against the roles listed in @authorize directive's roles. - /// The runtime's GraphQLSchemaBuilder will not add an @authorize directive without any roles defined, - /// however, since the Roles property of HotChocolate's AuthorizeDirective object is nullable, - /// handle the possible null gracefully. - /// - /// Role defined in request HTTP Header, X-MS-API-ROLE - /// Roles defined on the @authorize directive. Case insensitive. - /// True when the authenticated user's explicitly defined role is present in the authorize directive role list. Otherwise, false. - private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyList? roles) - { - if (roles is null || roles.Count == 0) - { - return false; - } - - if (roles.Any(role => role.Equals(clientRoleHeader, StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - return false; - } - /// /// Returns whether the ClaimsPrincipal in the HotChocolate IMiddlewareContext.ContextData is authenticated. /// To be authenticated, at least one ClaimsIdentity in ClaimsPrincipal.Identities must be authenticated. diff --git a/src/Product/ProductInfo.cs b/src/Product/ProductInfo.cs index 781317b157..f42d7802c0 100644 --- a/src/Product/ProductInfo.cs +++ b/src/Product/ProductInfo.cs @@ -9,6 +9,7 @@ namespace Azure.DataApiBuilder.Product; public static class ProductInfo { public const string DAB_APP_NAME_ENV = "DAB_APP_NAME_ENV"; + public const string COSMOSDB_DATABASE_NAME = "COSMOSDB_DATABASE_NAME"; public static readonly string DAB_USER_AGENT = $"dab_oss_{GetProductVersion()}"; public static readonly string CLOUD_ROLE_NAME = "DataApiBuilder"; diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index 0dff3ac016..c16d362268 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -326,9 +326,9 @@ public void TestAuthenticatedRoleWhenAnonymousRoleIsDefined() } } - // Anonymous role's permissions are copied over for authenticated role only. - // Assert by checking for an arbitrary role. - Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, + // With role inheritance, named roles inherit from authenticated (which inherited from anonymous). + // Assert that an arbitrary named role now effectively has the Create operation via inheritance. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, EntityActionOperation.Create)); // Assert that the create operation has both anonymous, authenticated roles. @@ -479,6 +479,211 @@ public void TestAuthenticatedRoleWhenBothAnonymousAndAuthenticatedAreDefined() CollectionAssert.AreEquivalent(expectedRolesForUpdateCol1, actualRolesForUpdateCol1.ToList()); } + /// + /// Validates role inheritance for named roles: when a named role is not configured for an entity + /// but 'authenticated' is configured (or inherited from 'anonymous'), the named role inherits + /// the permissions of 'authenticated'. + /// Inheritance chain: named-role → authenticated → anonymous → none. + /// + [TestMethod] + public void TestNamedRoleInheritsFromAuthenticatedRole() + { + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: AuthorizationResolver.ROLE_AUTHENTICATED, + operation: EntityActionOperation.Read); + + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // Named role (TEST_ROLE = "Writer") is not configured but should inherit from 'authenticated'. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Read)); + + // Named role should NOT have operations that 'authenticated' does not have. + Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Create)); + } + + /// + /// Validates that when neither 'anonymous' nor 'authenticated' is configured for an entity, + /// a named role that is also not configured inherits nothing (rule 5). + /// + [TestMethod] + public void TestNamedRoleInheritsNothingWhenNoSystemRolesDefined() + { + const string CONFIGURED_NAMED_ROLE = "admin"; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: CONFIGURED_NAMED_ROLE, + operation: EntityActionOperation.Create); + + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // The configured 'admin' role has Create permission. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + CONFIGURED_NAMED_ROLE, + EntityActionOperation.Create)); + + // TEST_ROLE ("Writer") is not configured and neither anonymous nor authenticated is configured, + // so it inherits nothing (rule 5). + Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Create)); + } + + /// + /// Validates that a named role inherits from 'authenticated', which in turn has already + /// inherited from 'anonymous' at setup time (when anonymous is configured but authenticated is not). + /// Inheritance chain: named-role → authenticated (inherited from anonymous). + /// + [TestMethod] + public void TestNamedRoleInheritsFromAnonymousViaAuthenticated() + { + // Only 'anonymous' is configured; 'authenticated' will inherit from it at setup time. + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: AuthorizationResolver.ROLE_ANONYMOUS, + operation: EntityActionOperation.Read); + + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // Named role ("Writer") should inherit Read via: Writer → authenticated → anonymous. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Read)); + + // Named role should NOT have operations that anonymous does not have. + Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Create)); + } + + /// + /// SECURITY: Validates that a named role that IS explicitly configured for an entity + /// does NOT inherit broader permissions from 'authenticated'. This prevents privilege + /// escalation when a config author intentionally restricts a named role's permissions. + /// Example: authenticated has CRUD, but 'restricted' is configured with only Read. + /// A request from 'restricted' for Create must be denied. + /// + [TestMethod] + public void TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions() + { + // 'authenticated' gets Read + Create; 'restricted' gets only Read. + EntityActionFields fieldsForRole = new( + Include: new HashSet { "col1" }, + Exclude: new()); + + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: fieldsForRole, + Policy: new(null, null)); + + EntityAction createAction = new( + Action: EntityActionOperation.Create, + Fields: fieldsForRole, + Policy: new(null, null)); + + EntityPermission authenticatedPermission = new( + Role: AuthorizationResolver.ROLE_AUTHENTICATED, + Actions: new[] { readAction, createAction }); + + EntityPermission restrictedPermission = new( + Role: "restricted", + Actions: new[] { readAction }); + + EntityPermission[] permissions = new[] { authenticatedPermission, restrictedPermission }; + RuntimeConfig runtimeConfig = BuildTestRuntimeConfig(permissions, AuthorizationHelpers.TEST_ENTITY); + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // 'restricted' is explicitly configured, so it should use its OWN permissions only. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + "restricted", + EntityActionOperation.Read), + "Explicitly configured 'restricted' role should have Read permission."); + + // CRITICAL: 'restricted' must NOT inherit Create from 'authenticated'. + Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + "restricted", + EntityActionOperation.Create), + "Explicitly configured 'restricted' role must NOT inherit Create from 'authenticated'."); + + // Verify 'authenticated' still has Create (sanity check). + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationResolver.ROLE_AUTHENTICATED, + EntityActionOperation.Create), + "'authenticated' should retain its own Create permission."); + } + + /// + /// Tests for IsRoleAllowedByDirective covering the full role inheritance chain at the + /// GraphQL @authorize directive gate. + /// Unconfigured named roles inherit: named-role inherits from 'authenticated'; 'authenticated' + /// inherits from 'anonymous'. Any unconfigured non-anonymous role is allowed when 'authenticated' + /// OR 'anonymous' is listed in the directive roles. + /// Explicitly configured named roles use strict matching only to prevent privilege escalation. + /// + [DataTestMethod] + [DataRow(null, "admin", false, DisplayName = "Null directive roles — deny all")] + [DataRow(new string[0], "admin", false, DisplayName = "Empty directive roles — deny all")] + [DataRow(new[] { "admin" }, "admin", true, DisplayName = "Explicit match — allowed")] + [DataRow(new[] { "admin" }, "other", false, DisplayName = "No match, no system roles — denied")] + [DataRow(new[] { "authenticated" }, "Writer", true, DisplayName = "Unconfigured named role inherits from authenticated")] + [DataRow(new[] { "authenticated" }, "anonymous", false, DisplayName = "anonymous does NOT inherit from authenticated")] + [DataRow(new[] { "anonymous" }, "authenticated", true, DisplayName = "authenticated inherits from anonymous")] + [DataRow(new[] { "anonymous" }, "Writer", true, DisplayName = "Unconfigured named role inherits from anonymous via authenticated")] + [DataRow(new[] { "anonymous" }, "anonymous", true, DisplayName = "anonymous explicit match when anonymous listed")] + [DataRow(new[] { "ANONYMOUS" }, "authenticated", true, DisplayName = "Case-insensitive: ANONYMOUS directive allows authenticated")] + [DataRow(new[] { "AUTHENTICATED" }, "Writer", true, DisplayName = "Case-insensitive: AUTHENTICATED directive allows unconfigured named role")] + public void TestIsRoleAllowedByDirective(string[]? directiveRoles, string clientRole, bool expected) + { + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver( + AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: AuthorizationResolver.ROLE_ANONYMOUS, + operation: EntityActionOperation.Read)); + + bool actual = authZResolver.IsRoleAllowedByDirective(clientRole, directiveRoles); + Assert.AreEqual(expected, actual); + } + + /// + /// Tests that explicitly configured named roles use strict directive matching. + /// A role that is explicitly configured for any entity (even with restricted permissions) + /// will NOT inherit from system roles at the @authorize directive level, preventing + /// unintended access to operations outside its configured permission scope. + /// + [DataTestMethod] + [DataRow(new[] { "authenticated" }, "Writer", false, DisplayName = "Configured role does NOT inherit from authenticated when not in directive")] + [DataRow(new[] { "anonymous" }, "Writer", false, DisplayName = "Configured role does NOT inherit from anonymous when not in directive")] + [DataRow(new[] { "Writer" }, "Writer", true, DisplayName = "Configured role passes when explicitly listed in directive")] + [DataRow(new[] { "anonymous", "authenticated" }, "Writer", false, DisplayName = "Configured role denied even when both system roles in directive")] + public void TestIsRoleAllowedByDirective_ExplicitlyConfiguredRoleUsesStrictMatching( + string[] directiveRoles, string clientRole, bool expected) + { + // Configure 'Writer' as an explicitly restricted role (read-only) on the test entity. + // Even though 'authenticated' or 'anonymous' may be in the directive, 'Writer' should + // not inherit because it is an explicitly configured role with its own permission scope. + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver( + AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: "Writer", + operation: EntityActionOperation.Read)); + + bool actual = authZResolver.IsRoleAllowedByDirective(clientRole, directiveRoles); + Assert.AreEqual(expected, actual); + } + /// /// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName. /// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc. @@ -919,7 +1124,7 @@ public void AreColumnsAllowedForOperationWithRoleWithDifferentCasing( DisplayName = "Valid policy parsing test for string and int64 claimvaluetypes.")] [DataRow("(@claims.isemployee eq @item.col1 and @item.col2 ne @claims.user_email) or" + "('David' ne @item.col3 and @claims.contact_no ne @item.col3)", "(true eq col1 and col2 ne 'xyz@microsoft.com') or" + - "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetype.")] + "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetypes.")] [DataRow("(@item.rating gt @claims.emprating) and (@claims.isemployee eq true)", "(rating gt 4.2) and (true eq true)", DisplayName = "Valid policy parsing test for double and boolean claimvaluetypes.")] [DataRow("@item.rating eq @claims.emprating)", "rating eq 4.2)", DisplayName = "Valid policy parsing test for double claimvaluetype.")] @@ -1298,11 +1503,11 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage() }; //Add identity object to the Mock context object. - ClaimsIdentity identityWithClientRoleHeaderClaim = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE); - identityWithClientRoleHeaderClaim.AddClaims(claims); + ClaimsIdentity identity = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE); + identity.AddClaims(claims); ClaimsPrincipal principal = new(); - principal.AddIdentity(identityWithClientRoleHeaderClaim); + principal.AddIdentity(identity); context.Setup(x => x.User).Returns(principal); context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE); diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs index bd1cd28b88..778144c6d2 100644 --- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs @@ -707,7 +707,8 @@ private static (MsSqlQueryExecutor QueryExecutor, RuntimeConfigProvider Provider Mcp: new(), Host: new(null, null) ), - Entities: new(new Dictionary())); + Entities: new(new Dictionary()) + ); MockFileSystem fileSystem = new(); fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson()));