Commit 3199765
feat: Role inheritance for entity permissions +
## 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 full inheritance
chain:
- Explicit role match → allowed
- `authenticated` in directive + clientRole is not `anonymous` → allowed
(named roles inherit from `authenticated`)
- `anonymous` in directive + clientRole is not `anonymous` → allowed
(`authenticated` inherits from `anonymous`; named roles inherit via the
full chain)
- `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
**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 `authenticated` → `anonymous` 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, 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
- [x] 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: explicit match,
named→authenticated, authenticated→anonymous, named→anonymous (via
chain), deny cases, and case-insensitivity
- 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 named
role (e.g. `editor`) both get Read access:**
```json
"permissions": [
{ "role": "anonymous", "actions": ["read"] }
]
```
**CLI usage:**
```bash
dab configure --show-effective-permissions
dab configure --show-effective-permissions --config my-config.json
```
<!-- START COPILOT ORIGINAL PROMPT -->
<details>
<summary>Original prompt</summary>
----
*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, `policies` and `fields`.
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:
```json
{
"permissions": {
"anonymous": [ "read" ],
"authenticated": [ "update" ],
"special-role": [ "delete" ]
}
}
```
|anonymous|authenticated|special-role|
|---------|-------------|------------|
|read |update |delete |
### 2. `special-role` missing
```json
{
"permissions": {
"anonymous": [ "read" ],
"authenticated": [ "update" ]
}
}
```
|anonymous|authenticated|special-role|
|---------|-------------|------------|
|read |update |update |
### 3. `authenticated` and `special-role` missing
```json
{
"permissions": {
"anonymous": [ "read" ]
}
}
```
|anonymous|authenticated|special-role|
|---------|-------------|------------|
|read |read |read |
### 4. Only a custom role defined
```json
{
"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...
</details>
<!-- START COPILOT CODING AGENT SUFFIX -->
- Fixes #3163
<!-- START COPILOT CODING AGENT TIPS -->
---
💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).
---------
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: JerryNixon <[email protected]>
Co-authored-by: aaron burtle <[email protected]>
Co-authored-by: aaronburtle <[email protected]>
Co-authored-by: Aniruddh25 <[email protected]>
Co-authored-by: Aniruddh Munde <[email protected]>dab configure --show-effective-permissions (#3164)1 parent 2da7963 commit 3199765
10 files changed
Lines changed: 847 additions & 46 deletions
File tree
- src
- Auth
- Cli.Tests
- Cli
- Commands
- Core/Authorization
- Product
- Service.Tests
- Authorization
- UnitTests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
58 | 75 | | |
59 | 76 | | |
60 | 77 | | |
| |||
68 | 85 | | |
69 | 86 | | |
70 | 87 | | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
71 | 103 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
137 | 137 | | |
138 | 138 | | |
139 | 139 | | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
140 | 159 | | |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| 80 | + | |
80 | 81 | | |
81 | 82 | | |
82 | 83 | | |
| |||
145 | 146 | | |
146 | 147 | | |
147 | 148 | | |
| 149 | + | |
148 | 150 | | |
149 | 151 | | |
150 | 152 | | |
| |||
312 | 314 | | |
313 | 315 | | |
314 | 316 | | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
315 | 320 | | |
316 | 321 | | |
317 | 322 | | |
318 | | - | |
319 | | - | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
320 | 338 | | |
321 | 339 | | |
322 | 340 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
601 | 601 | | |
602 | 602 | | |
603 | 603 | | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
604 | 661 | | |
605 | 662 | | |
606 | 663 | | |
| |||
0 commit comments