@@ -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