Skip to content

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

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

feat: Role inheritance for entity permissions + dab configure --show-effective-permissions#3164
Copilot wants to merge 18 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
  • Deep-cloned RoleMetadata when copying anonymous → authenticated to prevent shared mutable state between the two roles (RoleMetadata.DeepClone() and OperationMetadata.DeepClone() added to AuthorizationMetadataHelpers)

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

  • Added IsRoleAllowedByDirective(clientRole, directiveRoles) as an abstract interface method on IAuthorizationResolver (not a default interface method, keeping auth policy logic in the concrete class and preserving clean mockability)
  • Implemented concretely in AuthorizationResolver — the single source of truth for directive-level role checking with the following inheritance rules:
    • Explicit role match → allowed
    • authenticated in directive + clientRole is authenticated → allowed (authenticated inherits from anonymous)
    • Directive contains authenticated or anonymous + clientRole is an unconfigured named role (not present in any entity's explicit permission map) → allowed (inheritance for truly unconfigured roles)
    • Explicitly configured named roles use strict directive matching only — they do not inherit at the directive level, preventing unintended access to operations outside their explicitly scoped permissions
  • 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
  • A _explicitlyConfiguredNamedRoles HashSet<string> (case-insensitive, O(1) lookup) is built once during SetEntityPermissionMap and used by IsRoleAllowedByDirective to distinguish unconfigured roles from explicitly restricted ones

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: anonymous

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, unconfigured named roles inherit from anonymous (via the setup-time copy)
  5. If neither system role is configured, unconfigured named roles inherit nothing
  6. Explicitly configured named roles with restricted permissions do not inherit broader system-role permissions at the GraphQL directive level

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
    • Added TestIsRoleAllowedByDirective — 11-case data-driven test covering the full directive inheritance chain for unconfigured roles: explicit match, named→authenticated, authenticated→anonymous, named→anonymous (via chain), deny cases, and case-insensitivity
    • Added TestIsRoleAllowedByDirective_ExplicitlyConfiguredRoleUsesStrictMatching — 4-case data-driven test validating that explicitly configured named roles use strict directive matching and do not inherit from system roles at the directive level
    • Added CLI tests for --show-effective-permissions in ConfigureOptionsTests.cs:
      • TestShowEffectivePermissions — parameterized test covering alphabetical entity ordering, config file immutability, authenticated-inherits-anonymous line, and inheritance notes
      • TestShowEffectivePermissions_EntitiesSortedAlphabetically — validates a-z entity ordering
      • TestShowEffectivePermissions_RolesSortedAlphabeticallyWithinEntity — validates a-z role ordering within each entity
      • TestShowEffectivePermissions_AuthenticatedInheritsAnonymousNote — validates the inherited-authenticated display line and inheritance note
      • TestShowEffectivePermissions_NoInheritanceNoteWhenAuthenticatedExplicitlyConfigured — validates note is suppressed when authenticated is explicitly configured
      • TestShowEffectivePermissions_ReturnsFalseWhenConfigMissing — validates error handling for missing config

Sample Request(s)

Config with only anonymous defined — authenticated and any unconfigured 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.

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

… test

Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
…itance; add unit tests

Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
@Aniruddh25
Copy link
Collaborator

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

@Aniruddh25
Copy link
Collaborator

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

@Aniruddh25 Aniruddh25 enabled auto-merge (squash) March 9, 2026 00:06
@Aniruddh25
Copy link
Collaborator

…onfigured named roles only

Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 9, 2026

@copilot, look at logs and fix the failures:

https://[dev.azure.com/sqldab/fcb212b3-b288-4c9e-b55a-5842a268b16d/_apis/build/builds/18365/logs/15](https://dev.azure.com/sqldab/fcb...

Fixed in 340f9a8.

The 4 failing tests were caused by IsRoleAllowedByDirective applying inheritance too broadly at the GraphQL @authorize directive level — any non-anonymous role was allowed through when authenticated or anonymous appeared in the directive. This caused:

  1. Security regression: only-create-role passed the @authorize gate on deletePlanetAgain (because authenticated was in directive roles), and since CosmosMutationEngine.AuthorizeMutation skips field-level checks for delete, the delete executed — even though only-create-role has no delete permission.
  2. only-update-role/only-create-role create/update failures: These passed the schema-level gate but were rejected at the resolver with a different error message than the tests expected.
  3. limited-read-role filter test: Inheritance allowed limited-read-role to bypass the @authorize on the filter field (which only had anonymous/authenticated), letting the query succeed when it should have been rejected.

The fix restricts inheritance at the directive level to only unconfigured named roles (roles not appearing in any entity's explicit permission map). Explicitly configured roles — which have their own scoped permissions — use strict directive matching. A _explicitlyConfiguredNamedRoles HashSet (case-insensitive, O(1) lookup) is built once during SetEntityPermissionMap and checked in IsRoleAllowedByDirective. Added 4 new unit test cases to TestIsRoleAllowedByDirective_ExplicitlyConfiguredRoleUsesStrictMatching to cover this behaviour.

@aaronburtle
Copy link
Contributor

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

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

6 participants