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"); } ///