Skip to content

Commit 6f6e097

Browse files
committed
fix: MSRC Incident-31000000666371 - add authorization filtering to describe_entities MCP tool
1 parent 2210289 commit 6f6e097

2 files changed

Lines changed: 195 additions & 5 deletions

File tree

src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ public Task<CallToolResult> ExecuteAsync(
178178
continue;
179179
}
180180

181+
// Authorization filtering: skip entities the current role has no permission on.
182+
// This prevents information disclosure of schema metadata (entity/field/parameter names and descriptions)
183+
// for entities the caller is not authorized to access, matching REST/GraphQL/OpenAPI behavior.
184+
// If currentUserRole is null, no entities are visible (empty result).
185+
if (!HasAnyPermissionForEntity(entity, currentUserRole))
186+
{
187+
continue;
188+
}
189+
181190
try
182191
{
183192
DatabaseObject? databaseObject = null;
@@ -401,6 +410,45 @@ private static bool ShouldIncludeEntity(string entityName, HashSet<string>? enti
401410
return entityFilter == null || entityFilter.Count == 0 || entityFilter.Contains(entityName);
402411
}
403412

413+
/// <summary>
414+
/// Determines whether the specified entity is accessible to the given role.
415+
/// An entity is accessible if the role has at least one permission defined for it.
416+
/// This prevents information disclosure of schema metadata (entity names, fields, parameters, descriptions)
417+
/// for unauthorized entities, matching REST/GraphQL/OpenAPI authorization behavior.
418+
/// </summary>
419+
/// <param name="entity">The entity to check.</param>
420+
/// <param name="role">The role to check permissions for. If null, the entity is not accessible.</param>
421+
/// <returns><see langword="true"/> if the role has permission on the entity; otherwise, <see langword="false"/>.</returns>
422+
private static bool HasAnyPermissionForEntity(Entity entity, string? role)
423+
{
424+
// No role = no access to any entity (matches DML tool authorization model)
425+
if (string.IsNullOrWhiteSpace(role))
426+
{
427+
return false;
428+
}
429+
430+
// No permissions defined = not accessible
431+
if (entity.Permissions == null || !entity.Permissions.Any())
432+
{
433+
return false;
434+
}
435+
436+
// Check if this role has any permissions (actions) defined for the entity
437+
foreach (EntityPermission permission in entity.Permissions)
438+
{
439+
if (string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase))
440+
{
441+
// Role found - check if it has any actions
442+
if (permission.Actions != null && permission.Actions.Any())
443+
{
444+
return true;
445+
}
446+
}
447+
}
448+
449+
return false;
450+
}
451+
404452
/// <summary>
405453
/// Creates a dictionary containing basic information about an entity.
406454
/// </summary>

src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs

Lines changed: 147 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,68 @@ public async Task DescribeEntities_ReturnsAllEntitiesFilteredDmlDisabled_WhenAll
242242
Assert.IsTrue(message.Contains("dml-tools: false"), "Error message should mention the config syntax");
243243
}
244244

245+
/// <summary>
246+
/// Verifies that describe_entities filters entities
247+
/// by role authorization. A role with no permissions on any entity receives an empty result.
248+
/// This prevents information disclosure of schema metadata for unauthorized entities.
249+
/// </summary>
250+
[TestMethod]
251+
public async Task DescribeEntities_RoleWithNoPermissions_ReturnsEmptyList()
252+
{
253+
// Arrange - Create config with entities that only the "admin" role can access
254+
RuntimeConfig config = CreateConfigWithRestrictedRoleAccess();
255+
IServiceProvider serviceProvider = CreateServiceProvider(config, role: "guest");
256+
DescribeEntitiesTool tool = new();
257+
258+
// Act
259+
CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None);
260+
261+
// Assert - Guest role should see no entities because it has no permissions defined
262+
AssertErrorResult(result, "NoEntitiesConfigured");
263+
}
264+
265+
/// <summary>
266+
/// Verifies that low-privilege roles see only entities
267+
/// they have explicit permission on. A "reader" role that has READ permission on Book
268+
/// should see Book but not GetBook (execute-only SP).
269+
/// </summary>
270+
[TestMethod]
271+
public async Task DescribeEntities_LowPrivRole_SeesOnlyAuthorizedEntities()
272+
{
273+
// Arrange - Create config where:
274+
// - "Book" entity: reader role has READ permission
275+
// - "GetBook" entity: admin role has EXECUTE permission (reader has none)
276+
RuntimeConfig config = CreateConfigWithMixedRoleAccess();
277+
IServiceProvider serviceProvider = CreateServiceProvider(config, role: "reader");
278+
DescribeEntitiesTool tool = new();
279+
280+
// Act
281+
CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None);
282+
283+
// Assert - Reader role should see only Book, not GetBook
284+
AssertSuccessResultWithEntityNames(result, new[] { "Book" }, new[] { "GetBook" });
285+
}
286+
287+
/// <summary>
288+
/// Verifies that a null/empty role (unauthenticated caller)
289+
/// receives no entities, even if some entities have "anonymous" permissions.
290+
/// describe_entities requires a valid role to be included in the response.
291+
/// </summary>
292+
[TestMethod]
293+
public async Task DescribeEntities_NoRole_ReturnsEmptyList()
294+
{
295+
// Arrange - Config with entities
296+
RuntimeConfig config = CreateConfigWithMixedEntityTypes();
297+
IServiceProvider serviceProvider = CreateServiceProvider(config, role: null);
298+
DescribeEntitiesTool tool = new();
299+
300+
// Act
301+
CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None);
302+
303+
// Assert - No role should result in empty entity list
304+
AssertErrorResult(result, "NoEntitiesConfigured");
305+
}
306+
245307
#region Helper Methods
246308

247309
/// <summary>
@@ -450,11 +512,81 @@ private static RuntimeConfig CreateConfigWithAllEntitiesDmlDisabled()
450512
return CreateRuntimeConfig(entities);
451513
}
452514

515+
/// <summary>
516+
/// Creates a runtime config with restricted role access.
517+
/// Only "admin" role has READ permission on Book.
518+
/// "guest" role has no permissions on any entity.
519+
/// Used to test that roles without any entity permissions see no entities.
520+
/// </summary>
521+
private static RuntimeConfig CreateConfigWithRestrictedRoleAccess()
522+
{
523+
Dictionary<string, Entity> entities = new()
524+
{
525+
["Book"] = new Entity(
526+
Source: new("books", EntitySourceType.Table, null, null),
527+
GraphQL: new("Book", "Books"),
528+
Fields: null,
529+
Rest: new(Enabled: true),
530+
Permissions: new[]
531+
{
532+
new EntityPermission(Role: "admin", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) })
533+
},
534+
Mappings: null,
535+
Relationships: null,
536+
Mcp: null
537+
)
538+
};
539+
540+
return CreateRuntimeConfig(entities);
541+
}
542+
543+
/// <summary>
544+
/// Creates a runtime config with mixed role access.
545+
/// "reader" role has READ permission on Book table.
546+
/// "admin" role has EXECUTE permission on GetBook stored procedure.
547+
/// Used to test that describe_entities shows only entities a role has permissions for.
548+
/// </summary>
549+
private static RuntimeConfig CreateConfigWithMixedRoleAccess()
550+
{
551+
Dictionary<string, Entity> entities = new()
552+
{
553+
["Book"] = new Entity(
554+
Source: new("books", EntitySourceType.Table, null, null),
555+
GraphQL: new("Book", "Books"),
556+
Fields: null,
557+
Rest: new(Enabled: true),
558+
Permissions: new[]
559+
{
560+
new EntityPermission(Role: "reader", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }),
561+
new EntityPermission(Role: "admin", Actions: new[] { new EntityAction(Action: EntityActionOperation.All, Fields: null, Policy: null) })
562+
},
563+
Mappings: null,
564+
Relationships: null,
565+
Mcp: null
566+
),
567+
["GetBook"] = new Entity(
568+
Source: new("get_book", EntitySourceType.StoredProcedure, null, null),
569+
GraphQL: new("GetBook", "GetBook"),
570+
Fields: null,
571+
Rest: new(Enabled: true),
572+
Permissions: new[]
573+
{
574+
new EntityPermission(Role: "admin", Actions: new[] { new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) })
575+
},
576+
Mappings: null,
577+
Relationships: null,
578+
Mcp: null
579+
)
580+
};
581+
582+
return CreateRuntimeConfig(entities);
583+
}
584+
453585
/// <summary>
454586
/// Creates a service provider with mocked dependencies for testing DescribeEntitiesTool.
455-
/// Configures anonymous role and necessary DAB services.
587+
/// Configures specified role (or anonymous) and necessary DAB services.
456588
/// </summary>
457-
private static IServiceProvider CreateServiceProvider(RuntimeConfig config)
589+
private static IServiceProvider CreateServiceProvider(RuntimeConfig config, string? role = "anonymous")
458590
{
459591
ServiceCollection services = new();
460592

@@ -464,13 +596,23 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config)
464596

465597
// Mock IAuthorizationResolver
466598
Mock<IAuthorizationResolver> mockAuthResolver = new();
467-
mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny<HttpContext>())).Returns(true);
599+
mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny<HttpContext>())).Returns(role != null);
468600
services.AddSingleton(mockAuthResolver.Object);
469601

470-
// Mock HttpContext with anonymous role
602+
// Mock HttpContext with specified role (or null for no role)
471603
Mock<HttpContext> mockHttpContext = new();
472604
Mock<HttpRequest> mockRequest = new();
473-
mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous");
605+
606+
if (role != null)
607+
{
608+
mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(role);
609+
}
610+
else
611+
{
612+
// When role is null, simulate empty role header
613+
mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("");
614+
}
615+
474616
mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object);
475617

476618
Mock<IHttpContextAccessor> mockHttpContextAccessor = new();

0 commit comments

Comments
 (0)