Skip to content

feat: Role inheritance for entity permissions + dab configure --show-effective-permissions#3164

Open
Copilot wants to merge 5 commits intomainfrom
copilot/add-role-inheritance-permissions
Open

feat: Role inheritance for entity permissions + dab configure --show-effective-permissions#3164
Copilot wants to merge 5 commits intomainfrom
copilot/add-role-inheritance-permissions

Conversation

Copy link
Contributor

Copilot AI commented Feb 25, 2026

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)

  • Added GetEffectiveRoleName(entityName, roleName) private helper implementing the inheritance chain
  • Applied at every permission lookup: AreRoleAndOperationDefinedForEntity, AreColumnsAllowedForOperation, GetDBPolicyForRequest, GetAllowedExposedColumns, IsStoredProcedureExecutionPermitted
  • System roles (anonymous, authenticated) always resolve to themselves — no inheritance applies to them

GraphQL @authorize directive support — single source of truth (IAuthorizationResolver, GraphQLAuthorizationHandler)

  • Added IsRoleAllowedByDirective(clientRole, directiveRoles) as a default method on IAuthorizationResolver, making it the single source of truth for directive-level role checking with inheritance
  • GraphQLAuthorizationHandler now injects IAuthorizationResolver and delegates all directive role checks to IsRoleAllowedByDirective, eliminating duplicated inheritance logic
  • Removed the old IsInHeaderDesignatedRole private static method from GraphQLAuthorizationHandler which previously duplicated the inheritance logic
  • Inheritance chain at the directive gate: named-role explicitly listed → named-role inherits from authenticated if listed → denied

CLI: dab configure --show-effective-permissions (ConfigureOptions, ConfigGenerator)

  • New flag reads the config and prints effective permissions for every entity
  • Entities are output sorted a-z by name; roles within each entity are also sorted a-z
  • Explicitly surfaces the authenticatedanonymous inheritance when applicable
  • Prints a note per entity indicating what unconfigured named roles would inherit from
$ dab configure --show-effective-permissions
Entity: Book
  Role: anonymous | Actions: Read
  Role: authenticated | Actions: Read (inherited from: anonymous)
  Any unconfigured named role inherits from: authenticated

Entity: Order
  Role: admin | Actions: Create, Read, Update, Delete
  Role: anonymous | Actions: Read
  Role: authenticated | Actions: Read (inherited from: anonymous)
  Any unconfigured named role inherits from: authenticated

Rules implemented

  1. Explicitly configured roles always use their own permissions
  2. authenticated inherits from anonymous at startup when not explicitly configured (existing behavior)
  3. Any unconfigured named role inherits from authenticated at lookup time
  4. If authenticated is also absent, named roles inherit from anonymous (via the setup-time copy)
  5. If neither system role is configured, unconfigured named roles inherit nothing

How was this tested?

  • Integration Tests
  • Unit Tests
    • Updated TestAuthenticatedRoleWhenAnonymousRoleIsDefined to reflect that named roles now inherit
    • Added TestNamedRoleInheritsFromAuthenticatedRole — validates rule 3
    • Added TestNamedRoleInheritsNothingWhenNoSystemRolesDefined — validates rule 5
    • Added TestNamedRoleInheritsFromAnonymousViaAuthenticated — validates rule 4 (chain through both system roles)
    • Added TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions — security test validating that a named role with explicitly restricted permissions does not escalate to broader authenticated permissions

Sample Request(s)

Config with only anonymous defined — authenticated and any named role (e.g. editor) both get Read access:

"permissions": [
  { "role": "anonymous", "actions": ["read"] }
]

CLI usage:

dab configure --show-effective-permissions
dab configure --show-effective-permissions --config my-config.json
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.

Today's lack of permissions inheritance can lead to very verbose configs and unexpected denials.

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)-> None

Rules

  1. When any role is configured in permissions, that role always gets its that configuration.
  2. When authenticated is not configured, authenticated inherits the permissions of anonymous, if present.
  3. When named-role is not configured, it inherits the permissions of authenticated, if present.
  4. When named-role is not configured and neither is authenticated, it inherits the permissions of anonymous, if present.
  5. When named-role is not configured and neither is authenticated or anonymous, it inherits nothing.
  6. Permissions inheritance includes actions, policiesandfields`.
  7. It is still Data API builder's permission model that the requestor is only ONE role at a time.

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

Entity         Effective Role    Actions         Policy
─────────────  ────────────────  ──────────────  ──────────────
Employees      anonymous         read            (none)
Products       authenticated     read, update    @item.active
Inventory      special-role      *               (none)

Example Matrix

Note: none of the examples include execute below, but the behavior for stored procedures would be the same.

1. All roles configured:

{
  "permissions": {
    "anonymous": [ "read" ],
    "authenticated": [ "update" ],
    "special-role": [ "delete" ]
  }
}
anonymous authenticated special-role
read update delete

2. special-role missing

{
  "permissions": {
    "anonymous": [ "read" ],
    "authenticated": [ "update" ]
  }
}
anonymous authenticated special-role
read update update

3. authenticated and special-role missing

{
  "permissions": {
    "anonymous": [ "read" ]
  }
}
anonymous authenticated special-role
read read read

4. Only a custom role defined

{
  "permissions": {
    "jerry-role": [ "read" ]
  }
}
anonymous authenticated special-role jerry-role
none none none 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.

  • Option A: GraphQL @authorize directives only list explicitly-configured roles (status quo). A named role that inherits at runtime would pass authorization checks but wouldn't appear in the schema's directive. This is functionally correct but the schema is "incomplete."
  • Option B: Add a synthetic authenticated entry to @authorize directives when inheritance is active, since any authenticated named role would inherit from authenticated anyway. This is a closer approximation.

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.

… with a-z entity ordering

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement role inheritance for entity permissions feat: Role inheritance for entity permissions + dab configure --show-effective-permissions Feb 25, 2026
@aaronburtle
Copy link
Contributor

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

@aaronburtle
Copy link
Contributor

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>
Copy link
Contributor Author

Copilot AI commented Feb 26, 2026

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

The refactoring looks great overall — moving the directive role logic into IAuthorizationResolver.IsRoleAllowedByDirective gives a clean single source of truth, and the security test for explicitly configured roles is a valuable addition.

I found one issue: the assertion change in UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage was incorrect. GetAllAuthenticatedUserClaims stores all role claims (including the active request role TEST_ROLE = "Writer") into ORIGINAL_ROLE_CLAIM_TYPE, so the serialized value is ["Writer","ROLE2","ROLE3"], not ["ROLE2","ROLE3"]. Reverted to the original assertion in commit 04819a6.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 @authorize directive gate when authenticated is present.
  • Added dab configure --show-effective-permissions to 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 include authenticated and the request uses an unconfigured named role, asserting the request is allowed (and that anonymous is 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);
    }

Comment on lines +433 to +439
/// <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>
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +297 to +316
[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);
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Copy link
Contributor Author

Copilot AI commented Feb 26, 2026

Also @copilot please update the description to match the new functionality with a single source of truth.

Updated the PR description to accurately reflect the single source of truth architecture:

  • IAuthorizationResolver.IsRoleAllowedByDirective is now the single source of truth for directive-level role checks with inheritance
  • GraphQLAuthorizationHandler injects IAuthorizationResolver and delegates to IsRoleAllowedByDirective — no duplicated logic
  • GraphQLAuthorizationHandler.IsInHeaderDesignatedRole (the old duplicated private static method) is removed
  • AuthorizationResolver.GetEffectiveRoleName remains the source of truth for resolver-level (REST/query) inheritance

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

[Enh]: Implement role inheritance for entity permissions

4 participants