From 6f6e0974a1c2a013a2419d63c0be96524d590d26 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Mon, 27 Jul 2026 12:47:12 -0700 Subject: [PATCH 1/7] fix: MSRC Incident-31000000666371 - add authorization filtering to describe_entities MCP tool --- .../BuiltInTools/DescribeEntitiesTool.cs | 48 ++++++ .../Mcp/DescribeEntitiesFilteringTests.cs | 152 +++++++++++++++++- 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 5b64f1e015..84f9f6b80f 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -178,6 +178,15 @@ public Task ExecuteAsync( continue; } + // Authorization filtering: skip entities the current role has no permission on. + // This prevents information disclosure of schema metadata (entity/field/parameter names and descriptions) + // for entities the caller is not authorized to access, matching REST/GraphQL/OpenAPI behavior. + // If currentUserRole is null, no entities are visible (empty result). + if (!HasAnyPermissionForEntity(entity, currentUserRole)) + { + continue; + } + try { DatabaseObject? databaseObject = null; @@ -401,6 +410,45 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti return entityFilter == null || entityFilter.Count == 0 || entityFilter.Contains(entityName); } + /// + /// Determines whether the specified entity is accessible to the given role. + /// An entity is accessible if the role has at least one permission defined for it. + /// This prevents information disclosure of schema metadata (entity names, fields, parameters, descriptions) + /// for unauthorized entities, matching REST/GraphQL/OpenAPI authorization behavior. + /// + /// The entity to check. + /// The role to check permissions for. If null, the entity is not accessible. + /// if the role has permission on the entity; otherwise, . + private static bool HasAnyPermissionForEntity(Entity entity, string? role) + { + // No role = no access to any entity (matches DML tool authorization model) + if (string.IsNullOrWhiteSpace(role)) + { + return false; + } + + // No permissions defined = not accessible + if (entity.Permissions == null || !entity.Permissions.Any()) + { + return false; + } + + // Check if this role has any permissions (actions) defined for the entity + foreach (EntityPermission permission in entity.Permissions) + { + if (string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase)) + { + // Role found - check if it has any actions + if (permission.Actions != null && permission.Actions.Any()) + { + return true; + } + } + } + + return false; + } + /// /// Creates a dictionary containing basic information about an entity. /// diff --git a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs index 7182150fc0..08f63297ac 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs @@ -242,6 +242,68 @@ public async Task DescribeEntities_ReturnsAllEntitiesFilteredDmlDisabled_WhenAll Assert.IsTrue(message.Contains("dml-tools: false"), "Error message should mention the config syntax"); } + /// + /// Verifies that describe_entities filters entities + /// by role authorization. A role with no permissions on any entity receives an empty result. + /// This prevents information disclosure of schema metadata for unauthorized entities. + /// + [TestMethod] + public async Task DescribeEntities_RoleWithNoPermissions_ReturnsEmptyList() + { + // Arrange - Create config with entities that only the "admin" role can access + RuntimeConfig config = CreateConfigWithRestrictedRoleAccess(); + IServiceProvider serviceProvider = CreateServiceProvider(config, role: "guest"); + DescribeEntitiesTool tool = new(); + + // Act + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + // Assert - Guest role should see no entities because it has no permissions defined + AssertErrorResult(result, "NoEntitiesConfigured"); + } + + /// + /// Verifies that low-privilege roles see only entities + /// they have explicit permission on. A "reader" role that has READ permission on Book + /// should see Book but not GetBook (execute-only SP). + /// + [TestMethod] + public async Task DescribeEntities_LowPrivRole_SeesOnlyAuthorizedEntities() + { + // Arrange - Create config where: + // - "Book" entity: reader role has READ permission + // - "GetBook" entity: admin role has EXECUTE permission (reader has none) + RuntimeConfig config = CreateConfigWithMixedRoleAccess(); + IServiceProvider serviceProvider = CreateServiceProvider(config, role: "reader"); + DescribeEntitiesTool tool = new(); + + // Act + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + // Assert - Reader role should see only Book, not GetBook + AssertSuccessResultWithEntityNames(result, new[] { "Book" }, new[] { "GetBook" }); + } + + /// + /// Verifies that a null/empty role (unauthenticated caller) + /// receives no entities, even if some entities have "anonymous" permissions. + /// describe_entities requires a valid role to be included in the response. + /// + [TestMethod] + public async Task DescribeEntities_NoRole_ReturnsEmptyList() + { + // Arrange - Config with entities + RuntimeConfig config = CreateConfigWithMixedEntityTypes(); + IServiceProvider serviceProvider = CreateServiceProvider(config, role: null); + DescribeEntitiesTool tool = new(); + + // Act + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + // Assert - No role should result in empty entity list + AssertErrorResult(result, "NoEntitiesConfigured"); + } + #region Helper Methods /// @@ -450,11 +512,81 @@ private static RuntimeConfig CreateConfigWithAllEntitiesDmlDisabled() return CreateRuntimeConfig(entities); } + /// + /// Creates a runtime config with restricted role access. + /// Only "admin" role has READ permission on Book. + /// "guest" role has no permissions on any entity. + /// Used to test that roles without any entity permissions see no entities. + /// + private static RuntimeConfig CreateConfigWithRestrictedRoleAccess() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission(Role: "admin", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }) + }, + Mappings: null, + Relationships: null, + Mcp: null + ) + }; + + return CreateRuntimeConfig(entities); + } + + /// + /// Creates a runtime config with mixed role access. + /// "reader" role has READ permission on Book table. + /// "admin" role has EXECUTE permission on GetBook stored procedure. + /// Used to test that describe_entities shows only entities a role has permissions for. + /// + private static RuntimeConfig CreateConfigWithMixedRoleAccess() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission(Role: "reader", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }), + new EntityPermission(Role: "admin", Actions: new[] { new EntityAction(Action: EntityActionOperation.All, Fields: null, Policy: null) }) + }, + Mappings: null, + Relationships: null, + Mcp: null + ), + ["GetBook"] = new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission(Role: "admin", Actions: new[] { new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) }) + }, + Mappings: null, + Relationships: null, + Mcp: null + ) + }; + + return CreateRuntimeConfig(entities); + } + /// /// Creates a service provider with mocked dependencies for testing DescribeEntitiesTool. - /// Configures anonymous role and necessary DAB services. + /// Configures specified role (or anonymous) and necessary DAB services. /// - private static IServiceProvider CreateServiceProvider(RuntimeConfig config) + private static IServiceProvider CreateServiceProvider(RuntimeConfig config, string? role = "anonymous") { ServiceCollection services = new(); @@ -464,13 +596,23 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config) // Mock IAuthorizationResolver Mock mockAuthResolver = new(); - mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); + mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(role != null); services.AddSingleton(mockAuthResolver.Object); - // Mock HttpContext with anonymous role + // Mock HttpContext with specified role (or null for no role) Mock mockHttpContext = new(); Mock mockRequest = new(); - mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous"); + + if (role != null) + { + mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(role); + } + else + { + // When role is null, simulate empty role header + mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(""); + } + mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); Mock mockHttpContextAccessor = new(); From 6ff4677fd03c4d86501e797ac262d4303447e9f0 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Mon, 27 Jul 2026 15:04:50 -0700 Subject: [PATCH 2/7] Fix unit test failures --- .../Mcp/DescribeEntitiesFilteringTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs index 08f63297ac..6003a6e762 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs @@ -527,8 +527,8 @@ private static RuntimeConfig CreateConfigWithRestrictedRoleAccess() GraphQL: new("Book", "Books"), Fields: null, Rest: new(Enabled: true), - Permissions: new[] - { + Permissions: new[] + { new EntityPermission(Role: "admin", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }) }, Mappings: null, @@ -555,8 +555,8 @@ private static RuntimeConfig CreateConfigWithMixedRoleAccess() GraphQL: new("Book", "Books"), Fields: null, Rest: new(Enabled: true), - Permissions: new[] - { + Permissions: new[] + { new EntityPermission(Role: "reader", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }), new EntityPermission(Role: "admin", Actions: new[] { new EntityAction(Action: EntityActionOperation.All, Fields: null, Policy: null) }) }, @@ -602,7 +602,7 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config, stri // Mock HttpContext with specified role (or null for no role) Mock mockHttpContext = new(); Mock mockRequest = new(); - + if (role != null) { mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(role); @@ -612,7 +612,7 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config, stri // When role is null, simulate empty role header mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(""); } - + mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); Mock mockHttpContextAccessor = new(); From 07383db1cfe4d6aba3a56c8b451818daca6d535e Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Mon, 27 Jul 2026 16:05:50 -0700 Subject: [PATCH 3/7] Fix failing MSSQL Tests. --- ...sStoredProcedureParametersMsSqlIntegrationTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs index ef3fcf7816..42d6b3b256 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Claims; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -291,8 +292,18 @@ private static IServiceProvider BuildDescribeEntitiesServiceProvider(RuntimeConf services.AddSingleton(_authorizationResolver); // Real HttpContext carrying the anonymous role header that DescribeEntitiesTool reads. + // Must also set up the ClaimsPrincipal with the role claim for IsValidRoleContext to return true. DefaultHttpContext httpContext = new(); httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; + + // Set up the ClaimsPrincipal with the anonymous role claim so IsValidRoleContext passes + ClaimsIdentity identity = new( + authenticationType: "TestAuth", + nameType: null, + roleType: AuthenticationOptions.ROLE_CLAIM_TYPE); + identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS)); + httpContext.User = new ClaimsPrincipal(identity); + IHttpContextAccessor httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; services.AddSingleton(httpContextAccessor); From 4408964cbf75a2360f40aa12f4e1411e5f9b0888 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Tue, 28 Jul 2026 14:26:45 -0700 Subject: [PATCH 4/7] fix(mcp): support multi-role authorization in describe_entities with permission union --- .../BuiltInTools/DescribeEntitiesTool.cs | 98 +++++++------------ .../Mcp/DescribeEntitiesFilteringTests.cs | 39 ++++++-- 2 files changed, 65 insertions(+), 72 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 84f9f6b80f..64e52b0353 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -89,58 +89,26 @@ public Task ExecuteAsync( IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); HttpContext? httpContext = httpContextAccessor.HttpContext; - // Get current user's role for permission filtering - // For discovery tools like describe_entities, we use the first valid role from the header - // This differs from operation-specific tools that check permissions per entity per operation - string? currentUserRole = null; + // Get the caller's roles for authorization filtering. + // All roles from the header are collected and an entity is visible if ANY role grants access, + // matching the behavior of other MCP tools (McpAuthorizationHelper.TryResolveAuthorizedRole) + // and REST/GraphQL endpoints. + string[]? currentUserRoles = null; if (httpContext != null && authResolver.IsValidRoleContext(httpContext)) { string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); if (!string.IsNullOrWhiteSpace(roleHeader)) { - string[] roles = roleHeader - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - if (roles.Length > 1) - { - logger?.LogWarning("Multiple roles detected in request header: [{Roles}]. Using first role '{FirstRole}' for entity discovery. " + - "Consider using a single role for consistent permission reporting.", - string.Join(", ", roles), roles[0]); - } - - // For discovery operations, take the first role from comma-separated list - // This provides a consistent view of available entities for the primary role - currentUserRole = roles.FirstOrDefault(); - } - } - - // Get current user's role for permission filtering - // For discovery tools like describe_entities, we use the first valid role from the header - // This differs from operation-specific tools that check permissions per entity per operation - if (httpContext != null && authResolver.IsValidRoleContext(httpContext)) - { - string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); - if (!string.IsNullOrWhiteSpace(roleHeader)) - { - string[] roles = roleHeader - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - if (roles.Length > 1) - { - logger?.LogWarning("Multiple roles detected in request header: [{Roles}]. Using first role '{FirstRole}' for entity discovery. " + - "Consider using a single role for consistent permission reporting.", - string.Join(", ", roles), roles[0]); - } - - // For discovery operations, take the first role from comma-separated list - // This provides a consistent view of available entities for the primary role - currentUserRole = roles.FirstOrDefault(); + currentUserRoles = roleHeader + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); } } (bool nameOnly, HashSet? entityFilter) = ParseArguments(arguments, logger); - if (currentUserRole == null) + if (currentUserRoles == null) { logger?.LogWarning("Current user role could not be determined from HTTP context or role header. " + "Entity permissions will be empty (no permissions shown) rather than using anonymous permissions. " + @@ -178,11 +146,11 @@ public Task ExecuteAsync( continue; } - // Authorization filtering: skip entities the current role has no permission on. + // Authorization filtering: skip entities none of the caller's roles have permission on. // This prevents information disclosure of schema metadata (entity/field/parameter names and descriptions) // for entities the caller is not authorized to access, matching REST/GraphQL/OpenAPI behavior. - // If currentUserRole is null, no entities are visible (empty result). - if (!HasAnyPermissionForEntity(entity, currentUserRole)) + // If currentUserRoles is null or empty, no entities are visible (empty result). + if (!HasAnyPermissionForEntity(entity, currentUserRoles)) { continue; } @@ -213,7 +181,7 @@ public Task ExecuteAsync( Dictionary entityInfo = nameOnly ? BuildBasicEntityInfo(entityName, entity) - : BuildFullEntityInfo(entityName, entity, currentUserRole, databaseObject); + : BuildFullEntityInfo(entityName, entity, currentUserRoles, databaseObject); entityList.Add(entityInfo); } @@ -411,18 +379,18 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti } /// - /// Determines whether the specified entity is accessible to the given role. - /// An entity is accessible if the role has at least one permission defined for it. + /// Determines whether the specified entity is accessible to any of the given roles. + /// An entity is accessible if at least one role has at least one permission defined for it. /// This prevents information disclosure of schema metadata (entity names, fields, parameters, descriptions) /// for unauthorized entities, matching REST/GraphQL/OpenAPI authorization behavior. /// /// The entity to check. - /// The role to check permissions for. If null, the entity is not accessible. - /// if the role has permission on the entity; otherwise, . - private static bool HasAnyPermissionForEntity(Entity entity, string? role) + /// The roles to check permissions for. If null or empty, the entity is not accessible. + /// if any role has permission on the entity; otherwise, . + private static bool HasAnyPermissionForEntity(Entity entity, string[]? roles) { - // No role = no access to any entity (matches DML tool authorization model) - if (string.IsNullOrWhiteSpace(role)) + // No roles = no access to any entity (matches DML tool authorization model) + if (roles == null || roles.Length == 0) { return false; } @@ -433,10 +401,10 @@ private static bool HasAnyPermissionForEntity(Entity entity, string? role) return false; } - // Check if this role has any permissions (actions) defined for the entity + // Entity is accessible if ANY of the caller's roles has permissions (actions) defined for it foreach (EntityPermission permission in entity.Permissions) { - if (string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase)) + if (roles.Any(role => string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase))) { // Role found - check if it has any actions if (permission.Actions != null && permission.Actions.Any()) @@ -480,7 +448,7 @@ private static bool HasAnyPermissionForEntity(Entity entity, string? role) /// /// A dictionary containing the entity's name, description, fields, parameters (if applicable), and permissions. /// - private static Dictionary BuildFullEntityInfo(string entityName, Entity entity, string? currentUserRole, DatabaseObject? databaseObject) + private static Dictionary BuildFullEntityInfo(string entityName, Entity entity, string[]? currentUserRoles, DatabaseObject? databaseObject) { // Use GraphQL singular name as alias if available, otherwise use entity name string displayName = !string.IsNullOrWhiteSpace(entity.GraphQL?.Singular) @@ -499,7 +467,7 @@ private static bool HasAnyPermissionForEntity(Entity entity, string? role) info["parameters"] = BuildParameterMetadataInfo(databaseObject); } - info["permissions"] = BuildPermissionsInfo(entity, currentUserRole); + info["permissions"] = BuildPermissionsInfo(entity, currentUserRoles); return info; } @@ -587,14 +555,14 @@ private static List BuildParameterMetadataInfo(DatabaseObject? databaseO }; /// - /// Build a list of permission metadata info for the current user's role + /// Build a union of permission metadata for all of the current user's roles. /// /// The entity object - /// The current user's role - if null, returns empty permissions - /// A list of permissions available to the current user's role for this entity - private static string[] BuildPermissionsInfo(Entity entity, string? currentUserRole) + /// The current user's roles - if null or empty, returns empty permissions + /// A sorted list of permissions available to any of the current user's roles for this entity + private static string[] BuildPermissionsInfo(Entity entity, string[]? currentUserRoles) { - if (entity.Permissions == null || string.IsNullOrWhiteSpace(currentUserRole)) + if (entity.Permissions == null || currentUserRoles == null || currentUserRoles.Length == 0) { return Array.Empty(); } @@ -606,11 +574,11 @@ private static string[] BuildPermissionsInfo(Entity entity, string? currentUserR HashSet permissions = new(StringComparer.OrdinalIgnoreCase); - // Only include permissions for the current user's role + // Include permissions for any of the current user's roles (union) foreach (EntityPermission permission in entity.Permissions) { - // Check if this permission applies to the current user's role - if (!string.Equals(permission.Role, currentUserRole, StringComparison.OrdinalIgnoreCase)) + // Check if this permission applies to any of the current user's roles + if (!currentUserRoles.Any(role => string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase))) { continue; } diff --git a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs index 6003a6e762..d8bb0e3819 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs @@ -243,12 +243,12 @@ public async Task DescribeEntities_ReturnsAllEntitiesFilteredDmlDisabled_WhenAll } /// - /// Verifies that describe_entities filters entities - /// by role authorization. A role with no permissions on any entity receives an empty result. + /// Verifies that describe_entities returns a NoEntitiesConfigured error + /// when the caller's role has no permissions on any entity. /// This prevents information disclosure of schema metadata for unauthorized entities. /// [TestMethod] - public async Task DescribeEntities_RoleWithNoPermissions_ReturnsEmptyList() + public async Task DescribeEntities_RoleWithNoPermissions_ReturnsNoEntitiesError() { // Arrange - Create config with entities that only the "admin" role can access RuntimeConfig config = CreateConfigWithRestrictedRoleAccess(); @@ -285,12 +285,13 @@ public async Task DescribeEntities_LowPrivRole_SeesOnlyAuthorizedEntities() } /// - /// Verifies that a null/empty role (unauthenticated caller) - /// receives no entities, even if some entities have "anonymous" permissions. - /// describe_entities requires a valid role to be included in the response. + /// Verifies that describe_entities returns a NoEntitiesConfigured error + /// when no role header is provided (unauthenticated caller), + /// even if some entities have "anonymous" permissions. + /// describe_entities requires a valid role context to return results. /// [TestMethod] - public async Task DescribeEntities_NoRole_ReturnsEmptyList() + public async Task DescribeEntities_NoRole_ReturnsNoEntitiesError() { // Arrange - Config with entities RuntimeConfig config = CreateConfigWithMixedEntityTypes(); @@ -304,6 +305,30 @@ public async Task DescribeEntities_NoRole_ReturnsEmptyList() AssertErrorResult(result, "NoEntitiesConfigured"); } + /// + /// Verifies that when multiple roles are provided in the X-MS-API-ROLE header, + /// describe_entities returns the union of entities accessible to any of the roles. + /// A caller with "reader,admin" should see entities from both roles combined. + /// This matches the authorization behavior of other MCP tools (McpAuthorizationHelper.TryResolveAuthorizedRole). + /// + [TestMethod] + public async Task DescribeEntities_MultiRole_ReturnsUnionOfAuthorizedEntities() + { + // Arrange - Config where: + // - "Book" entity: reader role has READ permission + // - "GetBook" entity: admin role has EXECUTE permission (reader has none) + // Caller sends both roles → should see both entities + RuntimeConfig config = CreateConfigWithMixedRoleAccess(); + IServiceProvider serviceProvider = CreateServiceProvider(config, role: "reader,admin"); + DescribeEntitiesTool tool = new(); + + // Act + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + // Assert - Union of reader + admin → both Book and GetBook visible + AssertSuccessResultWithEntityNames(result, new[] { "Book", "GetBook" }, Array.Empty()); + } + #region Helper Methods /// From 62dac90ae29cb3e0b4f1337eb6ea9be2a11e9455 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 5 Aug 2026 15:52:42 -0700 Subject: [PATCH 5/7] MCP describe_entities: single-role model + resolver-driven visibility (MSRC 31000000666371) --- .../BuiltInTools/DescribeEntitiesTool.cs | 210 ++++++++------ .../Utils/McpAuthorizationHelper.cs | 29 +- .../Mcp/DescribeEntitiesFilteringTests.cs | 256 ++++++++++++++++-- ...eEntitiesStoredProcedureParametersTests.cs | 33 ++- 4 files changed, 392 insertions(+), 136 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 64e52b0353..9a700c95bb 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -89,26 +89,22 @@ public Task ExecuteAsync( IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); HttpContext? httpContext = httpContextAccessor.HttpContext; - // Get the caller's roles for authorization filtering. - // All roles from the header are collected and an entity is visible if ANY role grants access, - // matching the behavior of other MCP tools (McpAuthorizationHelper.TryResolveAuthorizedRole) - // and REST/GraphQL endpoints. - string[]? currentUserRoles = null; + // Get the caller's role for authorization filtering. DAB uses a single-role request + // model: the value validated by IsValidRoleContext (via User.IsInRole) is the role + // used to gate visibility here, matching REST, GraphQL, and the other MCP tools. + string? currentUserRole = null; if (httpContext != null && authResolver.IsValidRoleContext(httpContext)) { string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); if (!string.IsNullOrWhiteSpace(roleHeader)) { - currentUserRoles = roleHeader - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + currentUserRole = roleHeader; } } (bool nameOnly, HashSet? entityFilter) = ParseArguments(arguments, logger); - if (currentUserRoles == null) + if (currentUserRole == null) { logger?.LogWarning("Current user role could not be determined from HTTP context or role header. " + "Entity permissions will be empty (no permissions shown) rather than using anonymous permissions. " + @@ -146,11 +142,11 @@ public Task ExecuteAsync( continue; } - // Authorization filtering: skip entities none of the caller's roles have permission on. + // Authorization filtering: skip entities the caller's role has no permission on. // This prevents information disclosure of schema metadata (entity/field/parameter names and descriptions) // for entities the caller is not authorized to access, matching REST/GraphQL/OpenAPI behavior. - // If currentUserRoles is null or empty, no entities are visible (empty result). - if (!HasAnyPermissionForEntity(entity, currentUserRoles)) + // If currentUserRole is null, no entities are visible (empty result). + if (!HasAnyPermissionForEntity(entityName, entity, currentUserRole, authResolver)) { continue; } @@ -181,7 +177,7 @@ public Task ExecuteAsync( Dictionary entityInfo = nameOnly ? BuildBasicEntityInfo(entityName, entity) - : BuildFullEntityInfo(entityName, entity, currentUserRoles, databaseObject); + : BuildFullEntityInfo(entityName, entity, currentUserRole, databaseObject, authResolver); entityList.Add(entityInfo); } @@ -379,38 +375,32 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti } /// - /// Determines whether the specified entity is accessible to any of the given roles. - /// An entity is accessible if at least one role has at least one permission defined for it. - /// This prevents information disclosure of schema metadata (entity names, fields, parameters, descriptions) - /// for unauthorized entities, matching REST/GraphQL/OpenAPI authorization behavior. + /// Determines whether the specified entity is accessible to the given role, using the + /// authorization resolver as the source of truth. This respects role inheritance + /// (anonymous -> authenticated -> named role) and wildcard operation expansion, matching + /// REST/GraphQL/OpenAPI authorization behavior. /// - /// The entity to check. - /// The roles to check permissions for. If null or empty, the entity is not accessible. - /// if any role has permission on the entity; otherwise, . - private static bool HasAnyPermissionForEntity(Entity entity, string[]? roles) + /// The name of the entity being checked. + /// The entity object (used only to select the valid operation set for its source type). + /// The role to check permissions for. If null or whitespace, the entity is not accessible. + /// The authorization resolver. + /// if any valid operation is authorized on the entity for the role; otherwise, . + private static bool HasAnyPermissionForEntity(string entityName, Entity entity, string? role, IAuthorizationResolver authResolver) { - // No roles = no access to any entity (matches DML tool authorization model) - if (roles == null || roles.Length == 0) + if (string.IsNullOrWhiteSpace(role)) { return false; } - // No permissions defined = not accessible - if (entity.Permissions == null || !entity.Permissions.Any()) - { - return false; - } + HashSet validOperations = entity.Source.Type == EntitySourceType.StoredProcedure + ? EntityAction.ValidStoredProcedurePermissionOperations + : EntityAction.ValidPermissionOperations; - // Entity is accessible if ANY of the caller's roles has permissions (actions) defined for it - foreach (EntityPermission permission in entity.Permissions) + foreach (EntityActionOperation operation in validOperations) { - if (roles.Any(role => string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase))) + if (authResolver.AreRoleAndOperationDefinedForEntity(entityName, role, operation)) { - // Role found - check if it has any actions - if (permission.Actions != null && permission.Actions.Any()) - { - return true; - } + return true; } } @@ -443,23 +433,32 @@ private static bool HasAnyPermissionForEntity(Entity entity, string[]? roles) /// /// The name of the entity to include in the dictionary. /// The entity object from which to extract additional information. - /// The role of the current user, used to determine permissions. + /// The role of the current user, used to determine permissions and visible fields. /// The resolved database object metadata if available. + /// The authorization resolver used to compute allowed exposed columns. /// /// A dictionary containing the entity's name, description, fields, parameters (if applicable), and permissions. /// - private static Dictionary BuildFullEntityInfo(string entityName, Entity entity, string[]? currentUserRoles, DatabaseObject? databaseObject) + private static Dictionary BuildFullEntityInfo(string entityName, Entity entity, string? currentUserRole, DatabaseObject? databaseObject, IAuthorizationResolver authResolver) { // Use GraphQL singular name as alias if available, otherwise use entity name string displayName = !string.IsNullOrWhiteSpace(entity.GraphQL?.Singular) ? entity.GraphQL.Singular : entityName; + // Column-level authorization: filter fields by the columns the caller's role is allowed + // to see across every valid operation on this entity. Without this filter, describe_entities + // would leak the names and descriptions of columns restricted by fields.include / + // fields.exclude, extending the MSRC info-disclosure (CWE-285 -> CWE-200) from the entity + // level down to the column level. + HashSet? allowedFieldNames = ComputeAllowedFieldNames( + entityName, entity, currentUserRole, authResolver); + Dictionary info = new() { ["name"] = displayName, ["description"] = entity.Description ?? string.Empty, - ["fields"] = BuildFieldMetadataInfo(entity.Fields), + ["fields"] = BuildFieldMetadataInfo(entity.Fields, allowedFieldNames), }; if (entity.Source.Type == EntitySourceType.StoredProcedure) @@ -467,36 +466,102 @@ private static bool HasAnyPermissionForEntity(Entity entity, string[]? roles) info["parameters"] = BuildParameterMetadataInfo(databaseObject); } - info["permissions"] = BuildPermissionsInfo(entity, currentUserRoles); + info["permissions"] = BuildPermissionsInfo(entityName, entity, currentUserRole, authResolver); return info; } /// - /// Builds a list of metadata information objects from the provided collection of fields. + /// Builds a list of metadata information objects from the provided collection of fields, + /// filtered by the set of exposed column names the caller is allowed to see. /// /// A list of objects representing the fields to process. Can be null. + /// Exposed field names visible to the caller. When null the list is not filtered + /// (used for stored procedures, whose result-set columns are not governed by fields.include/exclude). + /// When empty, all fields are dropped. /// A list of objects, each containing the name and description of a field. If is /// null, an empty list is returned. - private static List BuildFieldMetadataInfo(List? fields) + private static List BuildFieldMetadataInfo(List? fields, HashSet? allowedFieldNames) { List result = new(); - if (fields != null) + if (fields == null) + { + return result; + } + + foreach (FieldMetadata field in fields) { - foreach (FieldMetadata field in fields) + string exposedName = field.Alias ?? field.Name; + + // A null allowedFieldNames set means "do not filter" (SP case). A non-null set + // that omits this name means the caller is not authorized to see it under any + // operation, so its name and description are withheld. + if (allowedFieldNames != null && !allowedFieldNames.Contains(exposedName)) { - result.Add(new - { - name = field.Alias ?? field.Name, - description = field.Description ?? string.Empty - }); + continue; } + + result.Add(new + { + name = exposedName, + description = field.Description ?? string.Empty + }); } return result; } + /// + /// Returns the set of exposed field names (aliased where applicable) the caller is + /// allowed to see on the given entity, computed across every valid operation the caller's + /// role is authorized for. + /// + /// + /// Uses , the same source + /// of truth REST uses when materializing a response's column projection. Stored procedures + /// return null because SP result-set columns are not governed by fields.include/exclude + /// (SP permissions are Execute-only); returning null signals "no filter" to the projection. + /// + /// + /// Null for stored procedures (do not filter). An empty set when the caller has no role, + /// which produces an empty fields[] projection while leaving the entity entry intact + /// (entity-level authorization has already passed by the time this runs). + /// + private static HashSet? ComputeAllowedFieldNames( + string entityName, + Entity entity, + string? currentUserRole, + IAuthorizationResolver authResolver) + { + if (entity.Source.Type == EntitySourceType.StoredProcedure) + { + return null; + } + + HashSet allowed = new(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(currentUserRole)) + { + return allowed; + } + + foreach (EntityActionOperation operation in EntityAction.ValidPermissionOperations) + { + if (!authResolver.AreRoleAndOperationDefinedForEntity(entityName, currentUserRole, operation)) + { + continue; + } + + foreach (string column in authResolver.GetAllowedExposedColumns(entityName, currentUserRole, operation)) + { + allowed.Add(column); + } + } + + return allowed; + } + /// /// Builds the parameter list for a stored procedure entity. /// Each entry has: name, required, default, description. @@ -555,51 +620,38 @@ private static List BuildParameterMetadataInfo(DatabaseObject? databaseO }; /// - /// Build a union of permission metadata for all of the current user's roles. + /// Builds the sorted list of operation permissions the caller has on the given entity, + /// using the authorization resolver as the source of truth so role inheritance + /// (anonymous -> authenticated -> named role) and wildcard operation expansion are applied + /// consistently with REST/GraphQL/OpenAPI. /// - /// The entity object - /// The current user's roles - if null or empty, returns empty permissions - /// A sorted list of permissions available to any of the current user's roles for this entity - private static string[] BuildPermissionsInfo(Entity entity, string[]? currentUserRoles) + /// The name of the entity being described. + /// The entity object (used only to select the valid operation set for its source type). + /// The current user's role - if null or whitespace, returns empty permissions. + /// The authorization resolver. + /// A sorted list of operation names (uppercased) authorized on the entity for the caller's role. + private static string[] BuildPermissionsInfo(string entityName, Entity entity, string? currentUserRole, IAuthorizationResolver authResolver) { - if (entity.Permissions == null || currentUserRoles == null || currentUserRoles.Length == 0) + if (string.IsNullOrWhiteSpace(currentUserRole)) { return Array.Empty(); } - bool isStoredProcedure = entity.Source.Type == EntitySourceType.StoredProcedure; - HashSet validOperations = isStoredProcedure + HashSet validOperations = entity.Source.Type == EntitySourceType.StoredProcedure ? EntityAction.ValidStoredProcedurePermissionOperations : EntityAction.ValidPermissionOperations; HashSet permissions = new(StringComparer.OrdinalIgnoreCase); - // Include permissions for any of the current user's roles (union) - foreach (EntityPermission permission in entity.Permissions) + foreach (EntityActionOperation operation in validOperations) { - // Check if this permission applies to any of the current user's roles - if (!currentUserRoles.Any(role => string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase))) - { - continue; - } - - foreach (EntityAction action in permission.Actions) + if (authResolver.AreRoleAndOperationDefinedForEntity(entityName, currentUserRole, operation)) { - if (action.Action == EntityActionOperation.All) - { - foreach (EntityActionOperation op in validOperations) - { - permissions.Add(op.ToString().ToUpperInvariant()); - } - } - else - { - permissions.Add(action.Action.ToString().ToUpperInvariant()); - } + permissions.Add(operation.ToString().ToUpperInvariant()); } } - return permissions.OrderBy(p => p).ToArray(); + return permissions.OrderBy(p => p, StringComparer.Ordinal).ToArray(); } } } diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs index 748b60f98b..cf23d1af0c 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpAuthorizationHelper.cs @@ -34,6 +34,10 @@ public static bool ValidateRoleContext( /// /// Tries to resolve an authorized role for the given entity and operation. + /// Uses DAB's single-role request model: the value of the + /// header is treated as one atomic role + /// (already validated by against + /// ), matching REST, GraphQL, and the other MCP tools. /// public static bool TryResolveAuthorizedRole( HttpContext httpContext, @@ -54,31 +58,14 @@ public static bool TryResolveAuthorizedRole( return false; } - string[] roles = roleHeader - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (roles.Length == 0) + if (!authorizationResolver.AreRoleAndOperationDefinedForEntity(entityName, roleHeader, operation)) { - error = "Client role header is missing or empty."; + error = $"You do not have permission to perform {operation} operation for this entity."; return false; } - foreach (string role in roles) - { - bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity( - entityName, role, operation); - - if (allowed) - { - effectiveRole = role; - return true; - } - } - - error = $"You do not have permission to perform {operation} operation for this entity."; - return false; + effectiveRole = roleHeader; + return true; } /// diff --git a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs index d8bb0e3819..4df62338fb 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Claims; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -265,7 +266,9 @@ public async Task DescribeEntities_RoleWithNoPermissions_ReturnsNoEntitiesError( /// /// Verifies that low-privilege roles see only entities /// they have explicit permission on. A "reader" role that has READ permission on Book - /// should see Book but not GetBook (execute-only SP). + /// should see Book but not GetBook (execute-only SP). Also asserts the exact + /// permission set so 's BuildPermissionsInfo + /// cannot silently return an over-broad set. /// [TestMethod] public async Task DescribeEntities_LowPrivRole_SeesOnlyAuthorizedEntities() @@ -282,6 +285,7 @@ public async Task DescribeEntities_LowPrivRole_SeesOnlyAuthorizedEntities() // Assert - Reader role should see only Book, not GetBook AssertSuccessResultWithEntityNames(result, new[] { "Book" }, new[] { "GetBook" }); + AssertEntityPermissions(result, "Book", new[] { "READ" }); } /// @@ -306,27 +310,57 @@ public async Task DescribeEntities_NoRole_ReturnsNoEntitiesError() } /// - /// Verifies that when multiple roles are provided in the X-MS-API-ROLE header, - /// describe_entities returns the union of entities accessible to any of the roles. - /// A caller with "reader,admin" should see entities from both roles combined. - /// This matches the authorization behavior of other MCP tools (McpAuthorizationHelper.TryResolveAuthorizedRole). + /// Verifies that a high-privilege single role sees every entity it has any permission on + /// and that the returned permission sets reflect resolver-driven wildcard expansion + /// (Action=All expands to CRUD on a table, Execute on a stored procedure). + /// DAB uses a single-role request model: the value validated by IsValidRoleContext + /// is the role used for the rest of the request, matching REST, GraphQL, and the DML MCP tools. /// [TestMethod] - public async Task DescribeEntities_MultiRole_ReturnsUnionOfAuthorizedEntities() + public async Task DescribeEntities_AdminRole_SeesEveryAuthorizedEntityWithWildcardExpansion() { // Arrange - Config where: - // - "Book" entity: reader role has READ permission - // - "GetBook" entity: admin role has EXECUTE permission (reader has none) - // Caller sends both roles → should see both entities + // - "Book" entity: admin role has Action=All permission + // - "GetBook" entity: admin role has EXECUTE permission RuntimeConfig config = CreateConfigWithMixedRoleAccess(); - IServiceProvider serviceProvider = CreateServiceProvider(config, role: "reader,admin"); + IServiceProvider serviceProvider = CreateServiceProvider(config, role: "admin"); DescribeEntitiesTool tool = new(); // Act CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); - // Assert - Union of reader + admin → both Book and GetBook visible + // Assert - Admin sees both entities and wildcard 'All' expands to the full CRUD set on Book; + // GetBook (SP) shows EXECUTE only. AssertSuccessResultWithEntityNames(result, new[] { "Book", "GetBook" }, Array.Empty()); + AssertEntityPermissions(result, "Book", new[] { "CREATE", "DELETE", "READ", "UPDATE" }); + AssertEntityPermissions(result, "GetBook", new[] { "EXECUTE" }); + } + + /// + /// Verifies that entity visibility and the returned permissions honor the same role-inheritance + /// chain the production applies: anonymous permissions are + /// inherited by authenticated, and a named role that is not explicitly configured on an entity + /// falls back to authenticated. Without this, describe_entities would under-report permissions + /// or hide entities the caller can actually reach via REST/GraphQL. + /// + [TestMethod] + public async Task DescribeEntities_HonorsRoleInheritance_AnonymousIntoAuthenticatedIntoNamedRole() + { + // Arrange - "Book" grants READ to anonymous only. authenticated and any named role + // should inherit that READ per resolver semantics. + RuntimeConfig config = CreateConfigWithAnonymousReadOnlyBook(); + + // authenticated inherits anonymous's READ. + IServiceProvider authedProvider = CreateServiceProvider(config, role: "authenticated"); + CallToolResult authedResult = await new DescribeEntitiesTool().ExecuteAsync(null, authedProvider, CancellationToken.None); + AssertSuccessResultWithEntityNames(authedResult, new[] { "Book" }, Array.Empty()); + AssertEntityPermissions(authedResult, "Book", new[] { "READ" }); + + // Unconfigured named role inherits authenticated → which itself inherited from anonymous. + IServiceProvider namedProvider = CreateServiceProvider(config, role: "some_unconfigured_role"); + CallToolResult namedResult = await new DescribeEntitiesTool().ExecuteAsync(null, namedProvider, CancellationToken.None); + AssertSuccessResultWithEntityNames(namedResult, new[] { "Book" }, Array.Empty()); + AssertEntityPermissions(namedResult, "Book", new[] { "READ" }); } #region Helper Methods @@ -379,6 +413,32 @@ private static void AssertErrorResult(CallToolResult result, string expectedErro Assert.AreEqual(expectedErrorType, errorType.GetString()); } + /// + /// Asserts that the entity's permissions array is exactly the given set (order-insensitive, + /// case-insensitive). Guards against BuildPermissionsInfo silently returning a partial or + /// over-broad union of the caller's roles' operations. + /// + private static void AssertEntityPermissions(CallToolResult result, string entityName, string[] expectedPermissions) + { + JsonElement content = GetContentFromResult(result); + Assert.IsTrue(content.TryGetProperty("entities", out JsonElement entities)); + + JsonElement entity = entities.EnumerateArray() + .FirstOrDefault(e => string.Equals(e.GetProperty("name").GetString(), entityName, StringComparison.Ordinal)); + Assert.AreNotEqual(default(JsonElement).ValueKind, entity.ValueKind, $"entity '{entityName}' not found in result"); + + Assert.IsTrue(entity.TryGetProperty("permissions", out JsonElement permissions), $"entity '{entityName}' missing 'permissions'"); + + HashSet actual = permissions.EnumerateArray() + .Select(p => p.GetString()!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + HashSet expected = new(expectedPermissions, StringComparer.OrdinalIgnoreCase); + + Assert.IsTrue( + actual.SetEquals(expected), + $"entity '{entityName}' permissions mismatch. expected=[{string.Join(",", expected.OrderBy(s => s))}] actual=[{string.Join(",", actual.OrderBy(s => s))}]"); + } + /// /// Creates a basic entity with standard permissions. /// @@ -607,9 +667,42 @@ private static RuntimeConfig CreateConfigWithMixedRoleAccess() return CreateRuntimeConfig(entities); } + /// + /// Creates a runtime config where "Book" grants READ to anonymous only, with no other roles + /// declared on the entity. Used to verify role inheritance semantics: authenticated should + /// inherit anonymous's READ, and any named role should fall through authenticated → anonymous. + /// + private static RuntimeConfig CreateConfigWithAnonymousReadOnlyBook() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission( + Role: AuthorizationResolver.ROLE_ANONYMOUS, + Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }) + }, + Mappings: null, + Relationships: null, + Mcp: null + ) + }; + + return CreateRuntimeConfig(entities); + } + + /// /// /// Creates a service provider with mocked dependencies for testing DescribeEntitiesTool. - /// Configures specified role (or anonymous) and necessary DAB services. + /// Wires a real populated with a + /// that carries a role claim, and mocks + /// with the exact production semantic (User.IsInRole(header)), so the auth boundary + /// is exercised through real claim/header logic rather than an unconditional test bypass. /// private static IServiceProvider CreateServiceProvider(RuntimeConfig config, string? role = "anonymous") { @@ -619,29 +712,51 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config, stri RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); services.AddSingleton(sp => configProvider); - // Mock IAuthorizationResolver - Mock mockAuthResolver = new(); - mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(role != null); - services.AddSingleton(mockAuthResolver.Object); - - // Mock HttpContext with specified role (or null for no role) - Mock mockHttpContext = new(); - Mock mockRequest = new(); - + // Build a real HttpContext with the X-MS-API-ROLE header set and, when a role is provided, + // a ClaimsPrincipal carrying that role claim. This matches production wiring: the header + // is what IsValidRoleContext reads, and IsInRole checks the principal's claims. + DefaultHttpContext httpContext = new(); if (role != null) { - mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(role); - } - else - { - // When role is null, simulate empty role header - mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(""); + httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = role; + ClaimsIdentity identity = new( + new[] { new Claim(ClaimTypes.Role, role) }, + authenticationType: "TestAuth"); + httpContext.User = new ClaimsPrincipal(identity); } - mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); + // Mock IAuthorizationResolver + Mock mockAuthResolver = new(); + + // Exact production semantic: exactly-one non-empty header value + User.IsInRole(header). + // Aaron flagged the previous "return role != null" mock as bypassing the auth boundary; + // this replicates the production check so a comma-in-header case (case #3 in the review) + // would not silently split into two roles. + mockAuthResolver + .Setup(x => x.IsValidRoleContext(It.IsAny())) + .Returns((HttpContext ctx) => + { + string headerValue = ctx.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); + return !string.IsNullOrWhiteSpace(headerValue) + && ctx.User is not null + && ctx.User.IsInRole(headerValue); + }); + + // Model production AreRoleAndOperationDefinedForEntity semantics over the test config: + // * wildcard EntityActionOperation.All expands to CRUD (table/view) or Execute (SP); + // * anonymous permissions are inherited by authenticated (setup-time copy); + // * a named role not configured on the entity falls back to authenticated (which itself + // may have inherited from anonymous). Mirrors AuthorizationResolver.GetEffectiveRoleName. + mockAuthResolver + .Setup(x => x.AreRoleAndOperationDefinedForEntity( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string entityName, string requestedRole, EntityActionOperation op) => + IsRoleAndOperationDefinedForEntity(config, entityName, requestedRole, op)); + + services.AddSingleton(mockAuthResolver.Object); Mock mockHttpContextAccessor = new(); - mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object); + mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); services.AddSingleton(mockHttpContextAccessor.Object); // Register a stub IMetadataProviderFactory that returns a populated DatabaseStoredProcedure @@ -657,6 +772,89 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config, stri return services.BuildServiceProvider(); } + /// + /// Resolves whether (, ) has a permission + /// defined for in , mirroring the + /// production AuthorizationResolver: wildcard actions are expanded, anonymous permissions + /// are inherited by authenticated, and a named role not configured on the entity falls back to + /// authenticated. + /// + private static bool IsRoleAndOperationDefinedForEntity( + RuntimeConfig config, + string entityName, + string requestedRole, + EntityActionOperation op) + { + if (!config.Entities.TryGetValue(entityName, out Entity? entity) || entity.Permissions == null) + { + return false; + } + + HashSet validOperations = entity.Source.Type == EntitySourceType.StoredProcedure + ? EntityAction.ValidStoredProcedurePermissionOperations + : EntityAction.ValidPermissionOperations; + + // Only ask about operations that are valid for this entity's source type; + // e.g. CRUD ops on a stored procedure entity never resolve to true. + if (!validOperations.Contains(op)) + { + return false; + } + + static bool RoleHasOp(Entity entity, string role, EntityActionOperation op, HashSet validOps) + { + foreach (EntityPermission permission in entity.Permissions) + { + if (!string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (permission.Actions == null) + { + continue; + } + + foreach (EntityAction action in permission.Actions) + { + if (action.Action == EntityActionOperation.All && validOps.Contains(op)) + { + return true; + } + + if (action.Action == op) + { + return true; + } + } + } + + return false; + } + + bool anonymousHasOp = RoleHasOp(entity, AuthorizationResolver.ROLE_ANONYMOUS, op, validOperations); + + if (string.Equals(requestedRole, AuthorizationResolver.ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)) + { + return anonymousHasOp; + } + + bool authenticatedHasOp = RoleHasOp(entity, AuthorizationResolver.ROLE_AUTHENTICATED, op, validOperations) || anonymousHasOp; + + if (string.Equals(requestedRole, AuthorizationResolver.ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) + { + return authenticatedHasOp; + } + + // Named role: use its own permissions if configured on the entity, else fall through to authenticated. + bool namedRoleConfigured = entity.Permissions.Any(p => + string.Equals(p.Role, requestedRole, StringComparison.OrdinalIgnoreCase)); + + return namedRoleConfigured + ? RoleHasOp(entity, requestedRole, op, validOperations) + : authenticatedHasOp; + } + /// /// Registers a stub that exposes a populated /// (with an empty diff --git a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs index 8e28e75e89..60128ddaa3 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Claims; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -240,17 +241,35 @@ private static void RegisterCommonServices(ServiceCollection services, RuntimeCo RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); services.AddSingleton(configProvider); + // Real HttpContext with the X-MS-API-ROLE header and a ClaimsPrincipal carrying the + // corresponding role claim. This lets IsValidRoleContext be checked with the exact + // production semantic (User.IsInRole(header)) rather than an unconditional test bypass. + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Role, AuthorizationResolver.ROLE_ANONYMOUS) }, + authenticationType: "TestAuth")); + Mock mockAuthResolver = new(); - mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); + mockAuthResolver + .Setup(x => x.IsValidRoleContext(It.IsAny())) + .Returns((HttpContext ctx) => + { + string headerValue = ctx.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); + return !string.IsNullOrWhiteSpace(headerValue) + && ctx.User is not null + && ctx.User.IsInRole(headerValue); + }); + mockAuthResolver + .Setup(x => x.AreRoleAndOperationDefinedForEntity(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string entityName, string roleName, EntityActionOperation operation) => + string.Equals(entityName, TEST_ENTITY_NAME, StringComparison.OrdinalIgnoreCase) + && string.Equals(roleName, AuthorizationResolver.ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) + && operation == EntityActionOperation.Execute); services.AddSingleton(mockAuthResolver.Object); - Mock mockHttpContext = new(); - Mock mockRequest = new(); - mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous"); - mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); - Mock mockHttpContextAccessor = new(); - mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object); + mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); services.AddSingleton(mockHttpContextAccessor.Object); services.AddLogging(); From b6e51f699c38887c132c67e5e143b93f8b00aa06 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 5 Aug 2026 17:13:14 -0700 Subject: [PATCH 6/7] Fix DescribeEntities_PartialConfigOverride test: rebuild authz resolver for tampered config --- ...tiesStoredProcedureParametersMsSqlIntegrationTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs index 42d6b3b256..e3b3d992fd 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs @@ -155,10 +155,12 @@ public async Task DescribeEntities_PartialConfigOverride_MergesOverriddenParamAn SetUpSQLMetadataProvider(tamperedProvider); await _sqlMetadataProvider.InitializeAsync(); - // Refresh the metadata-provider factory mock so DescribeEntitiesTool sees the - // tampered provider through the standard service-provider wiring. + // Refresh the metadata-provider factory mock and authorization resolver so + // DescribeEntitiesTool sees the tampered provider and HasAnyPermissionForEntity + // resolves permissions for UpdateBookTitlePartial from the tampered config. _metadataProviderFactory = new Mock(); _metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(_sqlMetadataProvider); + _authorizationResolver = new AuthorizationResolver(tamperedProvider, _metadataProviderFactory.Object); try { @@ -170,12 +172,13 @@ public async Task DescribeEntities_PartialConfigOverride_MergesOverriddenParamAn } finally { - // Restore the shared fixture's provider/factory so subsequent tests are unaffected. + // Restore the shared fixture's provider/factory/resolver so subsequent tests are unaffected. RuntimeConfigProvider sharedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(baseConfig); SetUpSQLMetadataProvider(sharedProvider); await _sqlMetadataProvider.InitializeAsync(); _metadataProviderFactory = new Mock(); _metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(_sqlMetadataProvider); + _authorizationResolver = new AuthorizationResolver(sharedProvider, _metadataProviderFactory.Object); } } From 35042c0631279c7755a1704e49604431e1ec6b7d Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Thu, 6 Aug 2026 11:59:39 -0700 Subject: [PATCH 7/7] Add column-level field filtering regression tests for describe_entities --- .../Mcp/DescribeEntitiesFilteringTests.cs | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs index 4df62338fb..105dbfc907 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs @@ -363,6 +363,113 @@ public async Task DescribeEntities_HonorsRoleInheritance_AnonymousIntoAuthentica AssertEntityPermissions(namedResult, "Book", new[] { "READ" }); } + /// + /// Column-level authorization regression: when a role's READ permission has fields.exclude + /// on a sensitive column, that column must be absent from fields[] while permitted columns + /// remain. Verifies 's use of + /// for column projection. + /// + [TestMethod] + public async Task DescribeEntities_ExcludesRestrictedColumnsFromFieldsArray() + { + const string EntityName = "Book"; + List fields = new() + { + new() { Name = "title", Description = "Book title" }, + new() { Name = "publisher_id", Description = "Publisher FK" }, + new() { Name = "salary", Description = "Internal cost" } // sensitive – excluded + }; + + Entity bookEntity = new( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new(EntityName, "Books"), + Fields: fields, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission(Role: "anonymous", Actions: new[] + { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) + }, + Mappings: null, + Relationships: null, + Mcp: null); + + RuntimeConfig config = CreateRuntimeConfig(new Dictionary { [EntityName] = bookEntity }); + + // anonymous READ exposes title and publisher_id but not salary. + HashSet allowedColumns = new(StringComparer.OrdinalIgnoreCase) { "title", "publisher_id" }; + IServiceProvider serviceProvider = CreateServiceProviderWithColumnAccess(config, role: "anonymous", allowedColumns); + DescribeEntitiesTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + Assert.IsTrue(result.IsError == false || result.IsError == null); + JsonElement content = GetContentFromResult(result); + JsonElement entity = content.GetProperty("entities").EnumerateArray().Single(); + JsonElement returnedFields = entity.GetProperty("fields"); + + List fieldNames = returnedFields.EnumerateArray() + .Select(f => f.GetProperty("name").GetString()!) + .ToList(); + + CollectionAssert.Contains(fieldNames, "title", "title should be visible"); + CollectionAssert.Contains(fieldNames, "publisher_id", "publisher_id should be visible"); + Assert.IsFalse(fieldNames.Contains("salary"), "salary must be excluded by column-level authz"); + } + + /// + /// SP entities must not apply column-level filtering: ComputeAllowedFieldNames + /// returns null for stored procedures, so every entry in Fields passes through untouched. + /// + [TestMethod] + public async Task DescribeEntities_StoredProcedure_DoesNotFilterFields() + { + const string EntityName = "GetBook"; + List resultFields = new() + { + new() { Name = "id", Description = "Book id" }, + new() { Name = "title", Description = "Book title" } + }; + + Entity spEntity = new( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new(EntityName, EntityName), + Fields: resultFields, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission(Role: "anonymous", Actions: new[] + { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) + }, + Mappings: null, + Relationships: null, + Mcp: null); + + RuntimeConfig config = CreateRuntimeConfig(new Dictionary { [EntityName] = spEntity }); + + // Even with an empty allowed-column set the SP fields must not be filtered. + IServiceProvider serviceProvider = CreateServiceProviderWithColumnAccess(config, role: "anonymous", allowedColumns: new HashSet()); + DescribeEntitiesTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + Assert.IsTrue(result.IsError == false || result.IsError == null); + JsonElement content = GetContentFromResult(result); + JsonElement entity = content.GetProperty("entities").EnumerateArray().Single(); + JsonElement returnedFields = entity.GetProperty("fields"); + + List fieldNames = returnedFields.EnumerateArray() + .Select(f => f.GetProperty("name").GetString()!) + .ToList(); + + CollectionAssert.Contains(fieldNames, "id", "SP result field 'id' must not be filtered"); + CollectionAssert.Contains(fieldNames, "title", "SP result field 'title' must not be filtered"); + } + #region Helper Methods /// @@ -909,6 +1016,65 @@ private static JsonElement GetContentFromResult(CallToolResult result) return JsonDocument.Parse(firstContent.Text).RootElement; } + /// + /// Variant of that also mocks + /// so column-level + /// filtering tests can control which field names are projected for a given role. + /// + private static IServiceProvider CreateServiceProviderWithColumnAccess( + RuntimeConfig config, + string? role, + HashSet allowedColumns) + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); + services.AddSingleton(sp => configProvider); + + DefaultHttpContext httpContext = new(); + if (role != null) + { + httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = role; + ClaimsIdentity identity = new( + new[] { new Claim(ClaimTypes.Role, role) }, + authenticationType: "TestAuth"); + httpContext.User = new ClaimsPrincipal(identity); + } + + Mock mockAuthResolver = new(); + mockAuthResolver + .Setup(x => x.IsValidRoleContext(It.IsAny())) + .Returns((HttpContext ctx) => + { + string headerValue = ctx.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); + return !string.IsNullOrWhiteSpace(headerValue) + && ctx.User is not null + && ctx.User.IsInRole(headerValue); + }); + mockAuthResolver + .Setup(x => x.AreRoleAndOperationDefinedForEntity( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string entityName, string requestedRole, EntityActionOperation op) => + IsRoleAndOperationDefinedForEntity(config, entityName, requestedRole, op)); + // Return the caller-supplied set for every entity/role/operation combination so the + // test fully controls which column names pass through ComputeAllowedFieldNames. + mockAuthResolver + .Setup(x => x.GetAllowedExposedColumns( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(allowedColumns); + + services.AddSingleton(mockAuthResolver.Object); + + Mock mockHttpContextAccessor = new(); + mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + services.AddSingleton(mockHttpContextAccessor.Object); + + RegisterStubMetadataProvider(services, config); + services.AddLogging(); + + return services.BuildServiceProvider(); + } + #endregion } }