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..514cab13a2 100644 --- a/src/Auth/IAuthorizationResolver.cs +++ b/src/Auth/IAuthorizationResolver.cs @@ -137,4 +137,20 @@ 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 not 'anonymous' and 'authenticated' is listed, return true (inheritance). + /// - Otherwise, return false. + /// + /// 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 b368227a75..8dfe55a70e 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1052,31 +1052,385 @@ 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 + /// + /// 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("This MCP provides access to the Products database and should be used to answer product-related or inventory-related questions from the user.", DisplayName = "Set MCP description.")] - [DataRow("Use this server for customer data queries.", DisplayName = "Set MCP description with short text.")] - public void TestConfigureDescriptionForMcpSettings(string descriptionValue) + [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 -> all the setup which includes creating options. - SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + // 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); - // Act: Attempts to update mcp.description value + // Capture logger output via a StringWriter on Console + StringWriter writer = new(); + Console.SetOut(writer); + + // Act ConfigureOptions options = new( - runtimeMcpDescription: descriptionValue, + showEffectivePermissions: true, config: TEST_RUNTIME_CONFIG_FILE ); - bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + 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."); + } + } - // Assert: Validate the Description is updated + /// + /// 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); - string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? runtimeConfig)); - Assert.IsNotNull(runtimeConfig.Runtime?.Mcp?.Description); - Assert.AreEqual(descriptionValue, runtimeConfig.Runtime.Mcp.Description); + 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 does not modify the config file. + /// + [TestMethod] + public void TestShowEffectivePermissions_DoesNotModifyConfigFile() + { + // Arrange + 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)); + string originalContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + string afterContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.AreEqual(originalContent, afterContent, "Config file should not be modified by --show-effective-permissions."); + } + + /// + /// 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); } /// @@ -1094,5 +1448,33 @@ private void SetupFileSystemWithInitialConfig(string jsonConfig) Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? config)); 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)); + } + } } } diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 14234d24d7..d0b25b1957 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -73,6 +73,7 @@ public ConfigureOptions( RollingInterval? fileSinkRollingInterval = null, int? fileSinkRetainedFileCountLimit = null, long? fileSinkFileSizeLimitBytes = null, + bool showEffectivePermissions = false, string? config = null) : base(config) { @@ -137,6 +138,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.")] @@ -292,11 +294,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 9a3401a55a..fdaf44992c 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -585,6 +585,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..689617f711 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -119,6 +119,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 +136,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 +146,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 +213,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; @@ -322,6 +326,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))) { @@ -391,6 +397,8 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig) /// /// 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 +407,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 +435,70 @@ 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; + } + + // Role inheritance: any non-anonymous role inherits from 'authenticated'. + if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && + directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return false; + } + + /// + /// 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 +547,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 +820,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/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index 0dff3ac016..0795efc8da 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,152 @@ 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."); + } + /// /// 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 +1065,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 +1444,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/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs index 4da3266271..6cc6b6b7ad 100644 --- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#nullable enable + using Azure.DataApiBuilder.Core.Parsers; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -52,7 +54,7 @@ public void ExtractRawQueryParameter_PreservesEncoding(string queryString, strin public void ExtractRawQueryParameter_ReturnsNull_WhenParameterNotFound(string? queryString, string parameterName) { // Call the internal method directly (no reflection needed) - string? result = RequestParser.ExtractRawQueryParameter(queryString, parameterName); + string? result = RequestParser.ExtractRawQueryParameter(queryString!, parameterName); Assert.IsNull(result, $"Expected null but got '{result}' for parameter '{parameterName}' in query '{queryString}'");