diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 5b64f1e015..9a700c95bb 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -89,52 +89,16 @@ 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 + // 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)) { - 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(); + currentUserRole = roleHeader; } } @@ -178,6 +142,15 @@ public Task ExecuteAsync( continue; } + // 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 currentUserRole is null, no entities are visible (empty result). + if (!HasAnyPermissionForEntity(entityName, entity, currentUserRole, authResolver)) + { + continue; + } + try { DatabaseObject? databaseObject = null; @@ -204,7 +177,7 @@ public Task ExecuteAsync( Dictionary entityInfo = nameOnly ? BuildBasicEntityInfo(entityName, entity) - : BuildFullEntityInfo(entityName, entity, currentUserRole, databaseObject); + : BuildFullEntityInfo(entityName, entity, currentUserRole, databaseObject, authResolver); entityList.Add(entityInfo); } @@ -401,6 +374,39 @@ 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, 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 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) + { + if (string.IsNullOrWhiteSpace(role)) + { + return false; + } + + HashSet validOperations = entity.Source.Type == EntitySourceType.StoredProcedure + ? EntityAction.ValidStoredProcedurePermissionOperations + : EntityAction.ValidPermissionOperations; + + foreach (EntityActionOperation operation in validOperations) + { + if (authResolver.AreRoleAndOperationDefinedForEntity(entityName, role, operation)) + { + return true; + } + } + + return false; + } + /// /// Creates a dictionary containing basic information about an entity. /// @@ -427,23 +433,32 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti /// /// 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? currentUserRole, 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) @@ -451,36 +466,102 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti info["parameters"] = BuildParameterMetadataInfo(databaseObject); } - info["permissions"] = BuildPermissionsInfo(entity, currentUserRole); + 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. @@ -539,51 +620,38 @@ private static List BuildParameterMetadataInfo(DatabaseObject? databaseO }; /// - /// Build a list of permission metadata info for the current user's role + /// 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 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 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 || string.IsNullOrWhiteSpace(currentUserRole)) + 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); - // Only include permissions for the current user's role - foreach (EntityPermission permission in entity.Permissions) + foreach (EntityActionOperation operation in validOperations) { - // Check if this permission applies to the current user's role - if (!string.Equals(permission.Role, currentUserRole, 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 7182150fc0..105dbfc907 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; @@ -242,6 +243,233 @@ 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). Also asserts the exact + /// permission set so 's BuildPermissionsInfo + /// cannot silently return an over-broad set. + /// + [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" }); + AssertEntityPermissions(result, "Book", new[] { "READ" }); + } + + /// + /// 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 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_AdminRole_SeesEveryAuthorizedEntityWithWildcardExpansion() + { + // Arrange - Config where: + // - "Book" entity: admin role has Action=All permission + // - "GetBook" entity: admin role has EXECUTE permission + RuntimeConfig config = CreateConfigWithMixedRoleAccess(); + IServiceProvider serviceProvider = CreateServiceProvider(config, role: "admin"); + DescribeEntitiesTool tool = new(); + + // Act + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + // 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" }); + } + + /// + /// 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 /// @@ -292,6 +520,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. /// @@ -450,11 +704,114 @@ 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 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 anonymous role 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) + private static IServiceProvider CreateServiceProvider(RuntimeConfig config, string? role = "anonymous") { ServiceCollection services = new(); @@ -462,19 +819,51 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config) RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); services.AddSingleton(sp => configProvider); + // 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) + { + 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 IAuthorizationResolver Mock mockAuthResolver = new(); - mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); - services.AddSingleton(mockAuthResolver.Object); - // Mock HttpContext with anonymous role - 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); + // 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 @@ -490,6 +879,89 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config) 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 @@ -544,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 } } diff --git a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersMsSqlIntegrationTests.cs index ef3fcf7816..e3b3d992fd 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; @@ -154,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 { @@ -169,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); } } @@ -291,8 +295,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); 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();