diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 5b64f1e015..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,6 +146,15 @@ public Task ExecuteAsync( continue; } + // 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 currentUserRoles is null or empty, no entities are visible (empty result). + if (!HasAnyPermissionForEntity(entity, currentUserRoles)) + { + continue; + } + try { DatabaseObject? databaseObject = null; @@ -204,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); } @@ -401,6 +378,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 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 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 roles = no access to any entity (matches DML tool authorization model) + if (roles == null || roles.Length == 0) + { + return false; + } + + // No permissions defined = not accessible + if (entity.Permissions == null || !entity.Permissions.Any()) + { + return false; + } + + // Entity is accessible if ANY of the caller's roles has permissions (actions) defined for it + foreach (EntityPermission permission in entity.Permissions) + { + 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()) + { + return true; + } + } + } + + return false; + } + /// /// Creates a dictionary containing basic information about an entity. /// @@ -432,7 +448,7 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti /// /// 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) @@ -451,7 +467,7 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti info["parameters"] = BuildParameterMetadataInfo(databaseObject); } - info["permissions"] = BuildPermissionsInfo(entity, currentUserRole); + info["permissions"] = BuildPermissionsInfo(entity, currentUserRoles); return info; } @@ -539,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(); } @@ -558,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 7182150fc0..d8bb0e3819 100644 --- a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs +++ b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs @@ -242,6 +242,93 @@ public async Task DescribeEntities_ReturnsAllEntitiesFilteredDmlDisabled_WhenAll Assert.IsTrue(message.Contains("dml-tools: false"), "Error message should mention the config syntax"); } + /// + /// 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_ReturnsNoEntitiesError() + { + // 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 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_ReturnsNoEntitiesError() + { + // 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"); + } + + /// + /// 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 /// @@ -450,11 +537,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 +621,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(); 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);