From 66e2112715f7f1636834b4471f5a4545fb66dc77 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 20:19:52 +0000
Subject: [PATCH 1/4] Initial plan
From 22f67146d623767fd33d7ba9f02a3263d2c915ba Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 20:44:44 +0000
Subject: [PATCH 2/4] Implement role inheritance and show-effective-permissions
CLI command with a-z entity ordering
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
---
src/Cli/Commands/ConfigureOptions.cs | 22 ++++-
src/Cli/ConfigGenerator.cs | 57 ++++++++++++
.../Authorization/AuthorizationResolver.cs | 45 +++++++++
.../GraphQLAuthorizationHandler.cs | 14 ++-
.../AuthorizationResolverUnitTests.cs | 93 ++++++++++++++++++-
5 files changed, 225 insertions(+), 6 deletions(-)
diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs
index 14234d24d7..d0b25b1957 100644
--- a/src/Cli/Commands/ConfigureOptions.cs
+++ b/src/Cli/Commands/ConfigureOptions.cs
@@ -73,6 +73,7 @@ public ConfigureOptions(
RollingInterval? fileSinkRollingInterval = null,
int? fileSinkRetainedFileCountLimit = null,
long? fileSinkFileSizeLimitBytes = null,
+ bool showEffectivePermissions = false,
string? config = null)
: base(config)
{
@@ -137,6 +138,7 @@ public ConfigureOptions(
FileSinkRollingInterval = fileSinkRollingInterval;
FileSinkRetainedFileCountLimit = fileSinkRetainedFileCountLimit;
FileSinkFileSizeLimitBytes = fileSinkFileSizeLimitBytes;
+ ShowEffectivePermissions = showEffectivePermissions;
}
[Option("data-source.database-type", Required = false, HelpText = "Database type. Allowed values: MSSQL, PostgreSQL, CosmosDB_NoSQL, MySQL.")]
@@ -292,11 +294,27 @@ public ConfigureOptions(
[Option("runtime.telemetry.file.file-size-limit-bytes", Required = false, HelpText = "Configure maximum file size limit in bytes. Default: 1048576")]
public long? FileSinkFileSizeLimitBytes { get; }
+ [Option("show-effective-permissions", Required = false, HelpText = "Display effective permissions for all entities, including inherited permissions. Entities are listed in alphabetical order.")]
+ public bool ShowEffectivePermissions { get; }
+
public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
{
logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion());
- bool isSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem);
- if (isSuccess)
+
+ if (ShowEffectivePermissions)
+ {
+ bool isSuccess = ConfigGenerator.TryShowEffectivePermissions(this, loader, fileSystem);
+ if (!isSuccess)
+ {
+ logger.LogError("Failed to display effective permissions.");
+ return CliReturnCode.GENERAL_ERROR;
+ }
+
+ return CliReturnCode.SUCCESS;
+ }
+
+ bool configSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem);
+ if (configSuccess)
{
logger.LogInformation("Successfully updated runtime settings in the config file.");
return CliReturnCode.SUCCESS;
diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs
index 9a3401a55a..fdaf44992c 100644
--- a/src/Cli/ConfigGenerator.cs
+++ b/src/Cli/ConfigGenerator.cs
@@ -585,6 +585,63 @@ public static bool TryCreateSourceObjectForNewEntity(
return true;
}
+
+ ///
+ /// Displays the effective permissions for all entities defined in the config, listed alphabetically by entity name.
+ /// Effective permissions include explicitly configured roles as well as inherited permissions:
+ /// - anonymous → authenticated (when authenticated is not explicitly configured)
+ /// - authenticated → any named role not explicitly configured for the entity
+ ///
+ /// True if the effective permissions were successfully displayed; otherwise, false.
+ public static bool TryShowEffectivePermissions(ConfigureOptions options, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
+ {
+ if (!TryGetConfigFileBasedOnCliPrecedence(loader, options.Config, out string runtimeConfigFile))
+ {
+ return false;
+ }
+
+ if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig))
+ {
+ _logger.LogError("Failed to read the config file: {runtimeConfigFile}.", runtimeConfigFile);
+ return false;
+ }
+
+ const string ROLE_ANONYMOUS = "anonymous";
+ const string ROLE_AUTHENTICATED = "authenticated";
+
+ // Iterate entities sorted a-z by name.
+ foreach ((string entityName, Entity entity) in runtimeConfig.Entities.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Entity: {entityName}", entityName);
+
+ bool hasAnonymous = entity.Permissions.Any(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase));
+ bool hasAuthenticated = entity.Permissions.Any(p => p.Role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase));
+
+ foreach (EntityPermission permission in entity.Permissions.OrderBy(p => p.Role, StringComparer.OrdinalIgnoreCase))
+ {
+ string actions = string.Join(", ", permission.Actions.Select(a => a.Action.ToString()));
+ _logger.LogInformation(" Role: {role} | Actions: {actions}", permission.Role, actions);
+ }
+
+ // Show inherited authenticated permissions when authenticated is not explicitly configured.
+ if (hasAnonymous && !hasAuthenticated)
+ {
+ EntityPermission anonPermission = entity.Permissions.First(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase));
+ string inheritedActions = string.Join(", ", anonPermission.Actions.Select(a => a.Action.ToString()));
+ _logger.LogInformation(" Role: {role} | Actions: {actions} (inherited from: {source})", ROLE_AUTHENTICATED, inheritedActions, ROLE_ANONYMOUS);
+ }
+
+ // Show inheritance note for named roles.
+ string inheritSource = hasAuthenticated ? ROLE_AUTHENTICATED : (hasAnonymous ? ROLE_ANONYMOUS : string.Empty);
+ if (!string.IsNullOrEmpty(inheritSource))
+ {
+ _logger.LogInformation(" Any unconfigured named role inherits from: {inheritSource}", inheritSource);
+ }
+ }
+
+ return true;
+ }
+
///
/// Tries to update the runtime settings based on the provided runtime options.
///
diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index 0f22b9cd28..ab9805bc51 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -119,6 +119,7 @@ public bool IsValidRoleContext(HttpContext httpContext)
///
public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string roleName, EntityActionOperation operation)
{
+ roleName = GetEffectiveRoleName(entityIdentifier, roleName);
if (EntityPermissionsMap.TryGetValue(entityIdentifier, out EntityMetadata? valueOfEntityToRole))
{
if (valueOfEntityToRole.RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? valueOfRoleToOperation))
@@ -135,6 +136,7 @@ public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string
public bool IsStoredProcedureExecutionPermitted(string entityName, string roleName, SupportedHttpVerb httpVerb)
{
+ roleName = GetEffectiveRoleName(entityName, roleName);
bool executionPermitted = EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata)
&& entityMetadata is not null
&& entityMetadata.RoleToOperationMap.TryGetValue(roleName, out _);
@@ -144,6 +146,7 @@ public bool IsStoredProcedureExecutionPermitted(string entityName, string roleNa
///
public bool AreColumnsAllowedForOperation(string entityName, string roleName, EntityActionOperation operation, IEnumerable columns)
{
+ roleName = GetEffectiveRoleName(entityName, roleName);
string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName);
ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName);
@@ -210,6 +213,7 @@ public string ProcessDBPolicy(string entityName, string roleName, EntityActionOp
///
public string GetDBPolicyForRequest(string entityName, string roleName, EntityActionOperation operation)
{
+ roleName = GetEffectiveRoleName(entityName, roleName);
if (!EntityPermissionsMap[entityName].RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? roleMetadata))
{
return string.Empty;
@@ -426,6 +430,46 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole(
}
}
+ ///
+ /// Returns the effective role name for permission lookups, implementing role inheritance.
+ /// System roles (anonymous, authenticated) always resolve to themselves.
+ /// For any other named role not explicitly configured for the entity, this method falls back
+ /// to the 'authenticated' role if it is present (which itself may already inherit from 'anonymous').
+ /// Inheritance chain: named-role → authenticated → anonymous → none.
+ ///
+ /// Name of the entity being accessed.
+ /// Role name from the request.
+ /// The role name whose permissions should apply for this request.
+ private string GetEffectiveRoleName(string entityName, string roleName)
+ {
+ // System roles always resolve to themselves; they do not inherit from other roles.
+ if (roleName.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) ||
+ roleName.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))
+ {
+ return roleName;
+ }
+
+ if (!EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata))
+ {
+ return roleName;
+ }
+
+ // Named role explicitly configured: use its own permissions.
+ if (entityMetadata.RoleToOperationMap.ContainsKey(roleName))
+ {
+ return roleName;
+ }
+
+ // Named role not configured: inherit from 'authenticated' if present.
+ // Note: 'authenticated' itself may already inherit from 'anonymous' via setup-time copy.
+ if (entityMetadata.RoleToOperationMap.ContainsKey(ROLE_AUTHENTICATED))
+ {
+ return ROLE_AUTHENTICATED;
+ }
+
+ return roleName;
+ }
+
///
/// Returns a list of all possible operations depending on the provided EntitySourceType.
/// Stored procedures only support Operation.Execute.
@@ -474,6 +518,7 @@ private static void PopulateAllowedExposedColumns(
///
public IEnumerable GetAllowedExposedColumns(string entityName, string roleName, EntityActionOperation operation)
{
+ roleName = GetEffectiveRoleName(entityName, roleName);
return EntityPermissionsMap[entityName].RoleToOperationMap[roleName].OperationToColumnMap[operation].AllowedExposedColumns;
}
diff --git a/src/Core/Authorization/GraphQLAuthorizationHandler.cs b/src/Core/Authorization/GraphQLAuthorizationHandler.cs
index 2760777e94..ed11b6d13c 100644
--- a/src/Core/Authorization/GraphQLAuthorizationHandler.cs
+++ b/src/Core/Authorization/GraphQLAuthorizationHandler.cs
@@ -134,10 +134,13 @@ private static bool TryGetApiRoleHeader(IDictionary contextData
/// The runtime's GraphQLSchemaBuilder will not add an @authorize directive without any roles defined,
/// however, since the Roles property of HotChocolate's AuthorizeDirective object is nullable,
/// handle the possible null gracefully.
+ /// Supports role inheritance: a named role not explicitly listed is permitted when 'authenticated'
+ /// is listed in the directive roles, implementing the chain: named-role → authenticated → anonymous.
///
/// Role defined in request HTTP Header, X-MS-API-ROLE
/// Roles defined on the @authorize directive. Case insensitive.
- /// True when the authenticated user's explicitly defined role is present in the authorize directive role list. Otherwise, false.
+ /// True when the authenticated user's explicitly defined role is present in the authorize directive role list,
+ /// or when the role inherits permissions from 'authenticated'. Otherwise, false.
private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyList? roles)
{
if (roles is null || roles.Count == 0)
@@ -150,6 +153,15 @@ private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyL
return true;
}
+ // Role inheritance: named roles (any role other than anonymous) inherit from 'authenticated'.
+ // If 'authenticated' is in the directive's roles and the requesting role is not 'anonymous',
+ // allow access because named roles inherit from 'authenticated'.
+ if (!clientRoleHeader.Equals(AuthorizationResolver.ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
+ roles.Any(role => role.Equals(AuthorizationResolver.ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
return false;
}
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index 0dff3ac016..d854ba618e 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -326,9 +326,9 @@ public void TestAuthenticatedRoleWhenAnonymousRoleIsDefined()
}
}
- // Anonymous role's permissions are copied over for authenticated role only.
- // Assert by checking for an arbitrary role.
- Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(AuthorizationHelpers.TEST_ENTITY,
+ // With role inheritance, named roles inherit from authenticated (which inherited from anonymous).
+ // Assert that an arbitrary named role now effectively has the Create operation via inheritance.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(AuthorizationHelpers.TEST_ENTITY,
AuthorizationHelpers.TEST_ROLE, EntityActionOperation.Create));
// Assert that the create operation has both anonymous, authenticated roles.
@@ -479,6 +479,93 @@ public void TestAuthenticatedRoleWhenBothAnonymousAndAuthenticatedAreDefined()
CollectionAssert.AreEquivalent(expectedRolesForUpdateCol1, actualRolesForUpdateCol1.ToList());
}
+ ///
+ /// Validates role inheritance for named roles: when a named role is not configured for an entity
+ /// but 'authenticated' is configured (or inherited from 'anonymous'), the named role inherits
+ /// the permissions of 'authenticated'.
+ /// Inheritance chain: named-role → authenticated → anonymous → none.
+ ///
+ [TestMethod]
+ public void TestNamedRoleInheritsFromAuthenticatedRole()
+ {
+ RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig(
+ entityName: AuthorizationHelpers.TEST_ENTITY,
+ roleName: AuthorizationResolver.ROLE_AUTHENTICATED,
+ operation: EntityActionOperation.Read);
+
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ // Named role (TEST_ROLE = "Writer") is not configured but should inherit from 'authenticated'.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Read));
+
+ // Named role should NOT have operations that 'authenticated' does not have.
+ Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Create));
+ }
+
+ ///
+ /// Validates that when neither 'anonymous' nor 'authenticated' is configured for an entity,
+ /// a named role that is also not configured inherits nothing (rule 5).
+ ///
+ [TestMethod]
+ public void TestNamedRoleInheritsNothingWhenNoSystemRolesDefined()
+ {
+ const string CONFIGURED_NAMED_ROLE = "admin";
+ RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig(
+ entityName: AuthorizationHelpers.TEST_ENTITY,
+ roleName: CONFIGURED_NAMED_ROLE,
+ operation: EntityActionOperation.Create);
+
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ // The configured 'admin' role has Create permission.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ CONFIGURED_NAMED_ROLE,
+ EntityActionOperation.Create));
+
+ // TEST_ROLE ("Writer") is not configured and neither anonymous nor authenticated is configured,
+ // so it inherits nothing (rule 5).
+ Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Create));
+ }
+
+ ///
+ /// Validates that a named role inherits from 'authenticated', which in turn has already
+ /// inherited from 'anonymous' at setup time (when anonymous is configured but authenticated is not).
+ /// Inheritance chain: named-role → authenticated (inherited from anonymous).
+ ///
+ [TestMethod]
+ public void TestNamedRoleInheritsFromAnonymousViaAuthenticated()
+ {
+ // Only 'anonymous' is configured; 'authenticated' will inherit from it at setup time.
+ RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig(
+ entityName: AuthorizationHelpers.TEST_ENTITY,
+ roleName: AuthorizationResolver.ROLE_ANONYMOUS,
+ operation: EntityActionOperation.Read);
+
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ // Named role ("Writer") should inherit Read via: Writer → authenticated → anonymous.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Read));
+
+ // Named role should NOT have operations that anonymous does not have.
+ Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationHelpers.TEST_ROLE,
+ EntityActionOperation.Create));
+ }
+
///
/// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName.
/// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc.
From dab4c6fa81977cfdf1a6791ec63740529f6678dc Mon Sep 17 00:00:00 2001
From: aaron burtle
Date: Thu, 26 Feb 2026 13:55:12 -0800
Subject: [PATCH 3/4] de-couple graphQL auth and auth resolver for one source
of truth
---
src/Auth/IAuthorizationResolver.cs | 37 ++++++++++
.../GraphQLAuthorizationHandler.cs | 47 +++----------
.../AuthorizationResolverUnitTests.cs | 69 +++++++++++++++++--
3 files changed, 110 insertions(+), 43 deletions(-)
diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs
index a17f61ade5..eaef1eb777 100644
--- a/src/Auth/IAuthorizationResolver.cs
+++ b/src/Auth/IAuthorizationResolver.cs
@@ -137,4 +137,41 @@ public static IEnumerable GetRolesForOperation(
return new List();
}
+
+ ///
+ /// Determines whether a given client role should be allowed through the GraphQL
+ /// schema-level authorization gate for a specific set of directive roles.
+ /// Centralizes the role inheritance logic so that callers (e.g. GraphQLAuthorizationHandler)
+ /// do not need to duplicate inheritance rules.
+ ///
+ /// Inheritance chain: named-role → authenticated → anonymous → none.
+ /// - If the role is explicitly listed in the directive roles, return true.
+ /// - If the role is not 'anonymous' and 'authenticated' is listed, return true (inheritance).
+ /// - Otherwise, return false.
+ ///
+ /// The role from the X-MS-API-ROLE header.
+ /// The roles listed on the @authorize directive.
+ /// True if the client role should be allowed through the gate.
+ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles)
+ {
+ if (directiveRoles is null || directiveRoles.Count == 0)
+ {
+ return false;
+ }
+
+ // Explicit match — role is directly listed.
+ if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ // Role inheritance: any non-anonymous role inherits from 'authenticated'.
+ if (!clientRole.Equals("anonymous", StringComparison.OrdinalIgnoreCase) &&
+ directiveRoles.Any(role => role.Equals("authenticated", StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ return false;
+ }
}
diff --git a/src/Core/Authorization/GraphQLAuthorizationHandler.cs b/src/Core/Authorization/GraphQLAuthorizationHandler.cs
index ed11b6d13c..54a6362c68 100644
--- a/src/Core/Authorization/GraphQLAuthorizationHandler.cs
+++ b/src/Core/Authorization/GraphQLAuthorizationHandler.cs
@@ -17,6 +17,13 @@ namespace Azure.DataApiBuilder.Core.Authorization;
///
public class GraphQLAuthorizationHandler : IAuthorizationHandler
{
+ private readonly Azure.DataApiBuilder.Auth.IAuthorizationResolver _authorizationResolver;
+
+ public GraphQLAuthorizationHandler(Azure.DataApiBuilder.Auth.IAuthorizationResolver authorizationResolver)
+ {
+ _authorizationResolver = authorizationResolver;
+ }
+
///
/// Authorize access to field based on contents of @authorize directive.
/// Validates that the requestor is authenticated, and that the
@@ -44,7 +51,7 @@ public ValueTask AuthorizeAsync(
// Schemas defining authorization policies are not supported, even when roles are defined appropriately.
// Requests will be short circuited and rejected (authorization forbidden).
- if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && IsInHeaderDesignatedRole(clientRole, directive.Roles))
+ if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles))
{
if (!string.IsNullOrEmpty(directive.Policy))
{
@@ -83,7 +90,7 @@ public ValueTask AuthorizeAsync(
{
// Schemas defining authorization policies are not supported, even when roles are defined appropriately.
// Requests will be short circuited and rejected (authorization forbidden).
- if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && IsInHeaderDesignatedRole(clientRole, directive.Roles))
+ if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles))
{
if (!string.IsNullOrEmpty(directive.Policy))
{
@@ -129,42 +136,6 @@ private static bool TryGetApiRoleHeader(IDictionary contextData
return false;
}
- ///
- /// Checks the pre-validated clientRoleHeader value against the roles listed in @authorize directive's roles.
- /// The runtime's GraphQLSchemaBuilder will not add an @authorize directive without any roles defined,
- /// however, since the Roles property of HotChocolate's AuthorizeDirective object is nullable,
- /// handle the possible null gracefully.
- /// Supports role inheritance: a named role not explicitly listed is permitted when 'authenticated'
- /// is listed in the directive roles, implementing the chain: named-role → authenticated → anonymous.
- ///
- /// Role defined in request HTTP Header, X-MS-API-ROLE
- /// Roles defined on the @authorize directive. Case insensitive.
- /// True when the authenticated user's explicitly defined role is present in the authorize directive role list,
- /// or when the role inherits permissions from 'authenticated'. Otherwise, false.
- private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyList? roles)
- {
- if (roles is null || roles.Count == 0)
- {
- return false;
- }
-
- if (roles.Any(role => role.Equals(clientRoleHeader, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- // Role inheritance: named roles (any role other than anonymous) inherit from 'authenticated'.
- // If 'authenticated' is in the directive's roles and the requesting role is not 'anonymous',
- // allow access because named roles inherit from 'authenticated'.
- if (!clientRoleHeader.Equals(AuthorizationResolver.ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) &&
- roles.Any(role => role.Equals(AuthorizationResolver.ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
-
- return false;
- }
-
///
/// Returns whether the ClaimsPrincipal in the HotChocolate IMiddlewareContext.ContextData is authenticated.
/// To be authenticated, at least one ClaimsIdentity in ClaimsPrincipal.Identities must be authenticated.
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index d854ba618e..4c051a1842 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -566,6 +566,65 @@ public void TestNamedRoleInheritsFromAnonymousViaAuthenticated()
EntityActionOperation.Create));
}
+ ///
+ /// SECURITY: Validates that a named role that IS explicitly configured for an entity
+ /// does NOT inherit broader permissions from 'authenticated'. This prevents privilege
+ /// escalation when a config author intentionally restricts a named role's permissions.
+ /// Example: authenticated has CRUD, but 'restricted' is configured with only Read.
+ /// A request from 'restricted' for Create must be denied.
+ ///
+ [TestMethod]
+ public void TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions()
+ {
+ // 'authenticated' gets Read + Create; 'restricted' gets only Read.
+ EntityActionFields fieldsForRole = new(
+ Include: new HashSet { "col1" },
+ Exclude: new());
+
+ EntityAction readAction = new(
+ Action: EntityActionOperation.Read,
+ Fields: fieldsForRole,
+ Policy: new(null, null));
+
+ EntityAction createAction = new(
+ Action: EntityActionOperation.Create,
+ Fields: fieldsForRole,
+ Policy: new(null, null));
+
+ EntityPermission authenticatedPermission = new(
+ Role: AuthorizationResolver.ROLE_AUTHENTICATED,
+ Actions: new[] { readAction, createAction });
+
+ EntityPermission restrictedPermission = new(
+ Role: "restricted",
+ Actions: new[] { readAction });
+
+ EntityPermission[] permissions = new[] { authenticatedPermission, restrictedPermission };
+ RuntimeConfig runtimeConfig = BuildTestRuntimeConfig(permissions, AuthorizationHelpers.TEST_ENTITY);
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ // 'restricted' is explicitly configured, so it should use its OWN permissions only.
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ "restricted",
+ EntityActionOperation.Read),
+ "Explicitly configured 'restricted' role should have Read permission.");
+
+ // CRITICAL: 'restricted' must NOT inherit Create from 'authenticated'.
+ Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ "restricted",
+ EntityActionOperation.Create),
+ "Explicitly configured 'restricted' role must NOT inherit Create from 'authenticated'.");
+
+ // Verify 'authenticated' still has Create (sanity check).
+ Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(
+ AuthorizationHelpers.TEST_ENTITY,
+ AuthorizationResolver.ROLE_AUTHENTICATED,
+ EntityActionOperation.Create),
+ "'authenticated' should retain its own Create permission.");
+ }
+
///
/// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName.
/// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc.
@@ -1006,7 +1065,7 @@ public void AreColumnsAllowedForOperationWithRoleWithDifferentCasing(
DisplayName = "Valid policy parsing test for string and int64 claimvaluetypes.")]
[DataRow("(@claims.isemployee eq @item.col1 and @item.col2 ne @claims.user_email) or" +
"('David' ne @item.col3 and @claims.contact_no ne @item.col3)", "(true eq col1 and col2 ne 'xyz@microsoft.com') or" +
- "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetype.")]
+ "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetypes.")]
[DataRow("(@item.rating gt @claims.emprating) and (@claims.isemployee eq true)",
"(rating gt 4.2) and (true eq true)", DisplayName = "Valid policy parsing test for double and boolean claimvaluetypes.")]
[DataRow("@item.rating eq @claims.emprating)", "rating eq 4.2)", DisplayName = "Valid policy parsing test for double claimvaluetype.")]
@@ -1385,11 +1444,11 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage()
};
//Add identity object to the Mock context object.
- ClaimsIdentity identityWithClientRoleHeaderClaim = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE);
- identityWithClientRoleHeaderClaim.AddClaims(claims);
+ ClaimsIdentity identity = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE);
+ identity.AddClaims(claims);
ClaimsPrincipal principal = new();
- principal.AddIdentity(identityWithClientRoleHeaderClaim);
+ principal.AddIdentity(identity);
context.Setup(x => x.User).Returns(principal);
context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE);
@@ -1403,7 +1462,7 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage()
Assert.AreEqual(expected: "Aa_0RISCzzZ-abC1De2fGHIjKLMNo123pQ4rStUVWXY", actual: claimsInRequestContext["sub"], message: "Expected the sub claim to be present.");
Assert.AreEqual(expected: "55296aad-ea7f-4c44-9a4c-bb1e8d43a005", actual: claimsInRequestContext["oid"], message: "Expected the oid claim to be present.");
Assert.AreEqual(claimsInRequestContext[AuthenticationOptions.ROLE_CLAIM_TYPE], actual: TEST_ROLE, message: "The roles claim should have the value:" + TEST_ROLE);
- Assert.AreEqual(expected: "[\"" + TEST_ROLE + "\",\"ROLE2\",\"ROLE3\"]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
+ Assert.AreEqual(expected: @"[""ROLE2"",""ROLE3""]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
}
///
From 04819a632a3a827b2fc13efc24e2aeb0f5d1de44 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Feb 2026 22:53:13 +0000
Subject: [PATCH 4/4] Fix incorrect ORIGINAL_ROLE_CLAIM_TYPE assertion in
UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage test
Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com>
---
.../Authorization/AuthorizationResolverUnitTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index 4c051a1842..0795efc8da 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -1462,7 +1462,7 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage()
Assert.AreEqual(expected: "Aa_0RISCzzZ-abC1De2fGHIjKLMNo123pQ4rStUVWXY", actual: claimsInRequestContext["sub"], message: "Expected the sub claim to be present.");
Assert.AreEqual(expected: "55296aad-ea7f-4c44-9a4c-bb1e8d43a005", actual: claimsInRequestContext["oid"], message: "Expected the oid claim to be present.");
Assert.AreEqual(claimsInRequestContext[AuthenticationOptions.ROLE_CLAIM_TYPE], actual: TEST_ROLE, message: "The roles claim should have the value:" + TEST_ROLE);
- Assert.AreEqual(expected: @"[""ROLE2"",""ROLE3""]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
+ Assert.AreEqual(expected: "[\"" + TEST_ROLE + "\",\"ROLE2\",\"ROLE3\"]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
}
///