fix(mcp): gate describe_entities schema visibility by caller role and permissions - #3737
Conversation
…scribe_entities MCP tool
There was a problem hiding this comment.
Pull request overview
This pull request addresses an information disclosure vulnerability in the MCP describe_entities tool by introducing per-entity authorization filtering, so schema metadata is only returned for entities the caller is permitted to access (bringing MCP discovery behavior closer to other DAB surfaces).
Changes:
- Added authorization-based entity filtering to
describe_entitiesvia a newHasAnyPermissionForEntityhelper. - Added unit tests covering “no permissions”, “low privilege”, and “no role” scenarios for
describe_entitiesfiltering. - Updated MCP test harness to allow specifying (or omitting) the request role header in the mocked
HttpContext.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs | Adds per-entity authorization filtering to prevent disclosure of metadata for unauthorized entities. |
| src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs | Adds new tests for role-based filtering and updates helpers to simulate different role contexts. |
Comments suppressed due to low confidence (1)
src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs:305
- This test name says "ReturnsEmptyList", but the assertion expects an error result (NoEntitiesConfigured). Renaming to match the actual expectation will make the test intent clearer and avoid confusion for future readers.
/// <summary>
/// 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.
/// </summary>
[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");
}
… (MSRC 31000000666371)
…er for tampered config
|
Looks good, just one suggestion: Could we add an automated regression test for this field-level filtering path? The production implementation now uses Because field metadata disclosure is part of the MSRC issue, could we add a test with real |
aaronburtle
left a comment
There was a problem hiding this comment.
Looks good, just one comment about a regression test.
Souvik Ghosh (souvikghosh04)
left a comment
There was a problem hiding this comment.
Approving. Please address the comments and also update the PR title and description
Why make this change?
describe_entitiesreturned schema metadata — entity names, field names, parameters, and descriptions — for every configured entity without applying per-entity or per-column authorization. Callers with limited permissions could enumerate the full database surface, including entities and columns they had no right to access.This change aligns
describe_entitieswith REST, GraphQL, and OpenAPI: schema visibility is now gated by the caller's role, at both the entity level and the column level, using the sameIAuthorizationResolverDAB uses everywhere else.What changed?
Entity-level filter — an entity appears in the response only when the caller's role has at least one authorized operation on it.
HasAnyPermissionForEntitydelegates toauthResolver.AreRoleAndOperationDefinedForEntity, soanonymous → authenticated → named-roleinheritance and wildcardAllexpansion are handled by the resolver, matching REST/GraphQL semantics exactly.Column-level filter — for entities the caller can reach, only fields the caller is authorized to see (across all their permitted operations) are included.
ComputeAllowedFieldNamesunionsauthResolver.GetAllowedExposedColumnsacross every operation the caller's role is authorized for.BuildFieldMetadataInfofilters the emitted field list against that set.parametersare not filtered — SP access is gated byEXECUTEon the whole procedure; there is no column-level ACL on parameters.BuildPermissionsInfonow returns a sorted, uppercased list of operations the caller's role is authorized for on each entity.Single-role request model
X-MS-API-ROLEis treated as one atomic role at every MCP entry point. The value validated byIsValidRoleContext(viaHttpContext.User.IsInRole(header)) is the role used for the entire request — no comma-splitting. This matches DAB's single-role model used by REST, GraphQL, andClientRoleHeaderAuthorizationMiddleware. Multi-role headers likeX-MS-API-ROLE: reader,adminare rejected with HTTP 403 before reaching any tool.Applied in both
DescribeEntitiesTool.ExecuteAsyncandMcpAuthorizationHelper.TryResolveAuthorizedRole(used by every DML MCP tool andDynamicCustomTool).Testing
Unit tests — all pass ✅
Tests build a real
DefaultHttpContextwith theX-MS-API-ROLEheader and aClaimsPrincipalcarryingClaim(ClaimTypes.Role, role).IsValidRoleContextis mocked with the production semantic —!string.IsNullOrWhiteSpace(header) && ctx.User.IsInRole(header)— so multi-role headers fail exactly as in production.Notable coverage:
DescribeEntities_RoleWithNoPermissions_ReturnsNoEntitiesErrorDescribeEntities_LowPrivRole_SeesOnlyAuthorizedEntitiesDescribeEntities_NoRole_ReturnsNoEntitiesErrorDescribeEntities_AdminRole_SeesEveryAuthorizedEntityWithWildcardExpansionBookwith{CREATE,DELETE,READ,UPDATE}andGetBookwith{EXECUTE}DescribeEntities_HonorsRoleInheritance_AnonymousIntoAuthenticatedIntoNamedRoleDescribeEntities_ExcludesRestrictedColumnsFromFieldsArrayfields[]; permitted columns presentDescribeEntities_StoredProcedure_DoesNotFilterFieldsdotnet format --verify-no-changespasses.Manual end-to-end against SQL Server 2022 ✅
role=anonymousrole=authenticatedX-MS-API-ROLE: authenticated,anonymous(multi-role)entities=[Book]—titleexcludedperms=[READ], fields=[id]entities=[Book,Stock]anonymousghostauthenticated→ 58 entitiesBookperms=[CREATE,DELETE,READ,UPDATE], fields=[id,title]perms=[READ] fields=[id]; rest inherit from authenticatedBookperms=[READ,UPDATE], fields=[id,title]GetBookSP, anonymousperms=[EXECUTE], params=[id]Bookwith wildcard*perms=[CREATE,DELETE,READ,UPDATE]entities=[DoesNotExist]EntitiesNotFounderrornameOnly=trueand entity-filter arguments are unaffected.