Skip to content

Commit 3199765

Browse files
CopilotJerryNixonaaronburtleAniruddh25
authored
feat: Role inheritance for entity permissions + dab configure --show-effective-permissions (#3164)
## 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]>
1 parent 2da7963 commit 3199765

10 files changed

Lines changed: 847 additions & 46 deletions

File tree

src/Auth/AuthorizationMetadataHelpers.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ public class RoleMetadata
5555
/// Given the key (operation) returns the associated OperationMetadata object.
5656
/// </summary>
5757
public Dictionary<EntityActionOperation, OperationMetadata> OperationToColumnMap { get; set; } = new();
58+
59+
/// <summary>
60+
/// Creates a deep clone of this RoleMetadata instance so that mutations
61+
/// to the clone do not affect the original (and vice versa).
62+
/// This is critical when copying permissions from one role to another
63+
/// (e.g., anonymous → authenticated) to prevent shared mutable state.
64+
/// </summary>
65+
public RoleMetadata DeepClone()
66+
{
67+
RoleMetadata clone = new();
68+
foreach ((EntityActionOperation operation, OperationMetadata metadata) in OperationToColumnMap)
69+
{
70+
clone.OperationToColumnMap[operation] = metadata.DeepClone();
71+
}
72+
73+
return clone;
74+
}
5875
}
5976

6077
/// <summary>
@@ -68,4 +85,19 @@ public class OperationMetadata
6885
public HashSet<string> Included { get; set; } = new();
6986
public HashSet<string> Excluded { get; set; } = new();
7087
public HashSet<string> AllowedExposedColumns { get; set; } = new();
88+
89+
/// <summary>
90+
/// Creates a deep clone of this OperationMetadata instance so that
91+
/// mutations to the clone do not affect the original (and vice versa).
92+
/// </summary>
93+
public OperationMetadata DeepClone()
94+
{
95+
return new OperationMetadata
96+
{
97+
DatabasePolicy = DatabasePolicy,
98+
Included = new HashSet<string>(Included),
99+
Excluded = new HashSet<string>(Excluded),
100+
AllowedExposedColumns = new HashSet<string>(AllowedExposedColumns)
101+
};
102+
}
71103
}

src/Auth/IAuthorizationResolver.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,23 @@ public static IEnumerable<string> GetRolesForOperation(
137137

138138
return new List<string>();
139139
}
140+
141+
/// <summary>
142+
/// Determines whether a given client role should be allowed through the GraphQL
143+
/// schema-level authorization gate for a specific set of directive roles.
144+
/// Centralizes the role inheritance logic so that callers (e.g. GraphQLAuthorizationHandler)
145+
/// do not need to duplicate inheritance rules.
146+
///
147+
/// Inheritance chain: named-role → authenticated → anonymous → none.
148+
/// - If the role is explicitly listed in the directive roles, return true.
149+
/// - If the role is 'authenticated' and 'anonymous' is listed, return true (inheritance).
150+
/// - If the role is an unconfigured named role (not in any entity's explicit permissions)
151+
/// and either 'authenticated' or 'anonymous' is listed, return true (inheritance).
152+
/// - Explicitly configured named roles use strict matching only, to prevent unintended
153+
/// access to operations outside their explicitly scoped permissions.
154+
/// </summary>
155+
/// <param name="clientRole">The role from the X-MS-API-ROLE header.</param>
156+
/// <param name="directiveRoles">The roles listed on the @authorize directive.</param>
157+
/// <returns>True if the client role should be allowed through the gate.</returns>
158+
public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList<string>? directiveRoles);
140159
}

src/Cli.Tests/ConfigureOptionsTests.cs

Lines changed: 370 additions & 0 deletions
Large diffs are not rendered by default.

src/Cli/Commands/ConfigureOptions.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ public ConfigureOptions(
7777
RollingInterval? fileSinkRollingInterval = null,
7878
int? fileSinkRetainedFileCountLimit = null,
7979
long? fileSinkFileSizeLimitBytes = null,
80+
bool showEffectivePermissions = false,
8081
string? config = null)
8182
: base(config)
8283
{
@@ -145,6 +146,7 @@ public ConfigureOptions(
145146
FileSinkRollingInterval = fileSinkRollingInterval;
146147
FileSinkRetainedFileCountLimit = fileSinkRetainedFileCountLimit;
147148
FileSinkFileSizeLimitBytes = fileSinkFileSizeLimitBytes;
149+
ShowEffectivePermissions = showEffectivePermissions;
148150
}
149151

150152
[Option("data-source.database-type", Required = false, HelpText = "Database type. Allowed values: MSSQL, PostgreSQL, CosmosDB_NoSQL, MySQL.")]
@@ -312,11 +314,27 @@ public ConfigureOptions(
312314
[Option("runtime.telemetry.file.file-size-limit-bytes", Required = false, HelpText = "Configure maximum file size limit in bytes. Default: 1048576")]
313315
public long? FileSinkFileSizeLimitBytes { get; }
314316

317+
[Option("show-effective-permissions", Required = false, HelpText = "Display effective permissions for all entities, including inherited permissions. Entities are listed in alphabetical order.")]
318+
public bool ShowEffectivePermissions { get; }
319+
315320
public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
316321
{
317322
logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion());
318-
bool isSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem);
319-
if (isSuccess)
323+
324+
if (ShowEffectivePermissions)
325+
{
326+
bool isSuccess = ConfigGenerator.TryShowEffectivePermissions(this, loader, fileSystem);
327+
if (!isSuccess)
328+
{
329+
logger.LogError("Failed to display effective permissions.");
330+
return CliReturnCode.GENERAL_ERROR;
331+
}
332+
333+
return CliReturnCode.SUCCESS;
334+
}
335+
336+
bool configSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem);
337+
if (configSuccess)
320338
{
321339
logger.LogInformation("Successfully updated runtime settings in the config file.");
322340
return CliReturnCode.SUCCESS;

src/Cli/ConfigGenerator.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,63 @@ public static bool TryCreateSourceObjectForNewEntity(
601601

602602
return true;
603603
}
604+
605+
/// <summary>
606+
/// Displays the effective permissions for all entities defined in the config, listed alphabetically by entity name.
607+
/// Effective permissions include explicitly configured roles as well as inherited permissions:
608+
/// - anonymous → authenticated (when authenticated is not explicitly configured)
609+
/// - authenticated → any named role not explicitly configured for the entity
610+
/// </summary>
611+
/// <returns>True if the effective permissions were successfully displayed; otherwise, false.</returns>
612+
public static bool TryShowEffectivePermissions(ConfigureOptions options, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
613+
{
614+
if (!TryGetConfigFileBasedOnCliPrecedence(loader, options.Config, out string runtimeConfigFile))
615+
{
616+
return false;
617+
}
618+
619+
if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig))
620+
{
621+
_logger.LogError("Failed to read the config file: {runtimeConfigFile}.", runtimeConfigFile);
622+
return false;
623+
}
624+
625+
const string ROLE_ANONYMOUS = "anonymous";
626+
const string ROLE_AUTHENTICATED = "authenticated";
627+
628+
// Iterate entities sorted a-z by name.
629+
foreach ((string entityName, Entity entity) in runtimeConfig.Entities.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase))
630+
{
631+
_logger.LogInformation("Entity: {entityName}", entityName);
632+
633+
bool hasAnonymous = entity.Permissions.Any(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase));
634+
bool hasAuthenticated = entity.Permissions.Any(p => p.Role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase));
635+
636+
foreach (EntityPermission permission in entity.Permissions.OrderBy(p => p.Role, StringComparer.OrdinalIgnoreCase))
637+
{
638+
string actions = string.Join(", ", permission.Actions.Select(a => a.Action.ToString()));
639+
_logger.LogInformation(" Role: {role} | Actions: {actions}", permission.Role, actions);
640+
}
641+
642+
// Show inherited authenticated permissions when authenticated is not explicitly configured.
643+
if (hasAnonymous && !hasAuthenticated)
644+
{
645+
EntityPermission anonPermission = entity.Permissions.First(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase));
646+
string inheritedActions = string.Join(", ", anonPermission.Actions.Select(a => a.Action.ToString()));
647+
_logger.LogInformation(" Role: {role} | Actions: {actions} (inherited from: {source})", ROLE_AUTHENTICATED, inheritedActions, ROLE_ANONYMOUS);
648+
}
649+
650+
// Show inheritance note for named roles.
651+
string inheritSource = hasAuthenticated ? ROLE_AUTHENTICATED : (hasAnonymous ? ROLE_ANONYMOUS : string.Empty);
652+
if (!string.IsNullOrEmpty(inheritSource))
653+
{
654+
_logger.LogInformation(" Any unconfigured named role inherits from: {inheritSource}", inheritSource);
655+
}
656+
}
657+
658+
return true;
659+
}
660+
604661
/// <summary>
605662
/// Tries to update the runtime settings based on the provided runtime options.
606663
/// </summary>

0 commit comments

Comments
 (0)