feat: Role inheritance for entity permissions + dab configure --show-effective-permissions#3164
feat: Role inheritance for entity permissions + dab configure --show-effective-permissions#3164
dab configure --show-effective-permissions#3164Conversation
… with a-z entity ordering Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
dab configure --show-effective-permissions
|
@copilot I pushed changes which try to eliminate the parallel logic we had here for auth inheritance, instead having a single source of truth, can you review these changes please? |
|
Also @copilot please update the description to match the new functionality with a single source of truth. |
…vedForDbPolicy_SessionCtx_Usage test Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com>
The refactoring looks great overall — moving the directive role logic into I found one issue: the assertion change in |
There was a problem hiding this comment.
Pull request overview
Implements role inheritance for entity permissions across the runtime authorization layer, aligns GraphQL schema-level authorization gating with the new inheritance semantics, and adds a CLI option to display effective permissions to reduce configuration duplication.
Changes:
- Added runtime permission lookup inheritance for unconfigured named roles (named → authenticated → anonymous → none) via
AuthorizationResolver. - Updated GraphQL authorization handling to permit inherited named roles through the
@authorizedirective gate whenauthenticatedis present. - Added
dab configure --show-effective-permissionsto print effective permissions per entity/role (with sorting) and expanded unit tests around inheritance.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Core/Authorization/AuthorizationResolver.cs | Adds effective-role resolution to apply inheritance across permission lookups. |
| src/Core/Authorization/GraphQLAuthorizationHandler.cs | Switches directive role gating to centralized resolver logic to support inheritance. |
| src/Auth/IAuthorizationResolver.cs | Adds a shared helper for GraphQL directive role gating behavior. |
| src/Cli/Commands/ConfigureOptions.cs | Introduces --show-effective-permissions CLI flag and handler branch. |
| src/Cli/ConfigGenerator.cs | Implements effective-permissions display output (sorted, with inheritance notes). |
| src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs | Updates/extends unit tests to cover named-role inheritance scenarios and related assertions. |
Comments suppressed due to low confidence (2)
src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs:1465
- This expected value doesn’t match the current AuthorizationResolver.GetAllAuthenticatedUserClaims/GetProcessedUserClaims behavior: ORIGINAL_ROLE_CLAIM_TYPE collects all role claims from the identity (including the role matching X-MS-API-ROLE). With the arranged claims (TEST_ROLE, ROLE2, ROLE3), the serialized original roles array should still include TEST_ROLE; otherwise this test will fail.
Assert.AreEqual(expected: "[\"" + TEST_ROLE + "\",\"ROLE2\",\"ROLE3\"]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context");
src/Core/Authorization/GraphQLAuthorizationHandler.cs:65
- GraphQLAuthorizationHandler now allows any non-anonymous role through the schema-level gate when the directive includes
authenticated(via IsRoleAllowedByDirective). There are existing integration tests for GraphQLAuthorizationHandler behavior, but none appear to validate this new inheritance path. Add a test case where the directive roles includeauthenticatedand the request uses an unconfigured named role, asserting the request is allowed (and thatanonymousis still rejected).
private readonly Azure.DataApiBuilder.Auth.IAuthorizationResolver _authorizationResolver;
public GraphQLAuthorizationHandler(Azure.DataApiBuilder.Auth.IAuthorizationResolver authorizationResolver)
{
_authorizationResolver = authorizationResolver;
}
/// <summary>
/// Authorize access to field based on contents of @authorize directive.
/// Validates that the requestor is authenticated, and that the
/// clientRoleHeader is present.
/// Role membership is checked
/// and/or (authorize directive may define policy, roles, or both)
/// An authorization policy is evaluated, if present.
/// </summary>
/// <param name="context">The current middleware context.</param>
/// <param name="directive">The authorization directive.</param>
/// <param name="cancellationToken">The cancellation token - not used here.</param>
/// <returns>
/// Returns a value indicating if the current session is authorized to
/// access the resolver data.
/// </returns>
public ValueTask<AuthorizeResult> AuthorizeAsync(
IMiddlewareContext context,
AuthorizeDirective directive,
CancellationToken cancellationToken = default)
{
if (!IsUserAuthenticated(context.ContextData))
{
return new ValueTask<AuthorizeResult>(AuthorizeResult.NotAuthenticated);
}
// 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) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles))
{
if (!string.IsNullOrEmpty(directive.Policy))
{
return new ValueTask<AuthorizeResult>(AuthorizeResult.NotAllowed);
}
return new ValueTask<AuthorizeResult>(AuthorizeResult.Allowed);
}
return new ValueTask<AuthorizeResult>(AuthorizeResult.NotAllowed);
}
| /// <summary> | ||
| /// 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. | ||
| /// </summary> |
There was a problem hiding this comment.
GetEffectiveRoleName relies on the setup-time CopyOverPermissionsFromAnonymousToAuthenticatedRole behavior, but that helper currently assigns RoleToOperationMap[authenticated] to the same RoleMetadata instance as anonymous (shallow copy). With expanded inheritance, this shared reference becomes more risky (any future mutation of one role’s permissions would silently affect the other). Consider deep-cloning RoleMetadata/OperationMetadata (or constructing a new RoleMetadata with copied dictionaries/sets) when copying anonymous → authenticated so inherited roles don’t share mutable objects.
| [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); |
There was a problem hiding this comment.
The new --show-effective-permissions execution path isn’t covered by CLI unit tests. Since src/Cli.Tests/ConfigureOptionsTests.cs already exercises ConfigureOptions extensively, add tests validating this mode (e.g., output ordering by entity/role, authenticated-inherits-anonymous line, and the final inheritance note), and that it doesn’t inadvertently modify the config file.
Updated the PR description to accurately reflect the single source of truth architecture:
|
Why make this change?
Developers were required to repeat identical permission configurations across every role, leading to verbose configs and unexpected access denials. This implements role inheritance so unconfigured roles fall back through the chain:
named-role → authenticated → anonymous → none.What is this change?
Role inheritance at runtime (
AuthorizationResolver)GetEffectiveRoleName(entityName, roleName)private helper implementing the inheritance chainAreRoleAndOperationDefinedForEntity,AreColumnsAllowedForOperation,GetDBPolicyForRequest,GetAllowedExposedColumns,IsStoredProcedureExecutionPermittedanonymous,authenticated) always resolve to themselves — no inheritance applies to themGraphQL
@authorizedirective support — single source of truth (IAuthorizationResolver,GraphQLAuthorizationHandler)IsRoleAllowedByDirective(clientRole, directiveRoles)as a default method onIAuthorizationResolver, making it the single source of truth for directive-level role checking with inheritanceGraphQLAuthorizationHandlernow injectsIAuthorizationResolverand delegates all directive role checks toIsRoleAllowedByDirective, eliminating duplicated inheritance logicIsInHeaderDesignatedRoleprivate static method fromGraphQLAuthorizationHandlerwhich previously duplicated the inheritance logicauthenticatedif listed → deniedCLI:
dab configure --show-effective-permissions(ConfigureOptions,ConfigGenerator)authenticated→anonymousinheritance when applicableRules implemented
authenticatedinherits fromanonymousat startup when not explicitly configured (existing behavior)authenticatedat lookup timeauthenticatedis also absent, named roles inherit fromanonymous(via the setup-time copy)How was this tested?
TestAuthenticatedRoleWhenAnonymousRoleIsDefinedto reflect that named roles now inheritTestNamedRoleInheritsFromAuthenticatedRole— validates rule 3TestNamedRoleInheritsNothingWhenNoSystemRolesDefined— validates rule 5TestNamedRoleInheritsFromAnonymousViaAuthenticated— validates rule 4 (chain through both system roles)TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions— security test validating that a named role with explicitly restricted permissions does not escalate to broaderauthenticatedpermissionsSample Request(s)
Config with only
anonymousdefined —authenticatedand any named role (e.g.editor) both get Read access:CLI usage:
Original prompt
This section details on the original issue you should resolve
<issue_title>[Enh]: Implement role inheritance for entity permissions</issue_title>
<issue_description>## Problem
Today, a developer is required to repeat permissions across all possible roles.
Desired Behavior
Introduce role inheritance that let's unlisted roles inherit from roles with fewer permissions.
Specific-role -(not found)-> Authenticated -(not found)-> Anonymous -(not found)-> NoneRules
permissions, that role always gets its that configuration.authenticatedis not configured,authenticatedinherits the permissions ofanonymous, if present.named-roleis not configured, it inherits the permissions ofauthenticated, if present.named-roleis not configured and neither isauthenticated, it inherits the permissions ofanonymous, if present.named-roleis not configured and neither isauthenticatedoranonymous, it inherits nothing.actions,policiesandfields`.Command line
We need to ensure the developer always has a way to know and understand inheritance.
dab configure --show-effective-permissions <role-name>.Note: In this release, this feature does not work with auto-entities.
Output
Example Matrix
Note: none of the examples include
executebelow, but the behavior for stored procedures would be the same.1. All roles configured:
{ "permissions": { "anonymous": [ "read" ], "authenticated": [ "update" ], "special-role": [ "delete" ] } }2.
special-rolemissing{ "permissions": { "anonymous": [ "read" ], "authenticated": [ "update" ] } }3.
authenticatedandspecial-rolemissing{ "permissions": { "anonymous": [ "read" ] } }4. Only a custom role defined
{ "permissions": { "jerry-role": [ "read" ] } }Coding considerations
The implementation of
[CopyOverPermissionsFromAnonymousToAuthenticatedRole](https://github.com/Azure/data-api-builder/blob/29b0e6eee594027e0787b3ce9c9aace015128f49/src/Core/Authorization/AuthorizationResolver.cs#L398-L427)already exists. This is a nice start, but not the complete story. It has a bug: This is a reference assignment, not a deep copy. Both authenticated and anonymous share the same RoleMetadata object. If any downstream code ever mutates the inherited permissions for one role (e.g., appending an action), it silently mutates the other. Extending this pattern to named roles creates a three-way shared reference chain, a subtle and dangerous source of bugs. We want to fix this and not repeat it.The method
GetRolesForEntity(string entityName)would return the wrong result. This is used by GraphQL to build @authorize directives on object types. With inheritance, you'd need to materialize all possible roles (including those that aren't explicitly configured but would inherit), which is unbounded, DAB can't know what named roles a JWT might carry ahead of time. This is fundamentally different from today, where every role that can access an entity is explicitly listed. The GraphQL schema generation would break or become incomplete.The method
AreRoleAndOperationDefinedForEntity()would need to implement the fallback chain (named-role → authenticated → anonymous). But if you materialize everything at startup (like the current anon...💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.