Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/Auth/IAuthorizationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,41 @@ public static IEnumerable<string> GetRolesForOperation(

return new List<string>();
}

/// <summary>
/// Determines whether a given client role should be allowed through the GraphQL
/// schema-level authorization gate for a specific set of directive roles.
/// Centralizes the role inheritance logic so that callers (e.g. GraphQLAuthorizationHandler)
/// do not need to duplicate inheritance rules.
///
/// Inheritance chain: named-role → authenticated → anonymous → none.
/// - If the role is explicitly listed in the directive roles, return true.
/// - If the role is not 'anonymous' and 'authenticated' is listed, return true (inheritance).
/// - Otherwise, return false.
/// </summary>
/// <param name="clientRole">The role from the X-MS-API-ROLE header.</param>
/// <param name="directiveRoles">The roles listed on the @authorize directive.</param>
/// <returns>True if the client role should be allowed through the gate.</returns>
public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList<string>? directiveRoles)
{
if (directiveRoles is null || directiveRoles.Count == 0)
{
return false;
}

// Explicit match — role is directly listed.
if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase)))
{
return true;
}

// Role inheritance: any non-anonymous role inherits from 'authenticated'.
if (!clientRole.Equals("anonymous", StringComparison.OrdinalIgnoreCase) &&
directiveRoles.Any(role => role.Equals("authenticated", StringComparison.OrdinalIgnoreCase)))
{
return true;
}

return false;
}
}
22 changes: 20 additions & 2 deletions src/Cli/Commands/ConfigureOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public ConfigureOptions(
RollingInterval? fileSinkRollingInterval = null,
int? fileSinkRetainedFileCountLimit = null,
long? fileSinkFileSizeLimitBytes = null,
bool showEffectivePermissions = false,
string? config = null)
: base(config)
{
Expand Down Expand Up @@ -137,6 +138,7 @@ public ConfigureOptions(
FileSinkRollingInterval = fileSinkRollingInterval;
FileSinkRetainedFileCountLimit = fileSinkRetainedFileCountLimit;
FileSinkFileSizeLimitBytes = fileSinkFileSizeLimitBytes;
ShowEffectivePermissions = showEffectivePermissions;
}

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

[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);
Comment on lines +297 to +316
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.
if (configSuccess)
{
logger.LogInformation("Successfully updated runtime settings in the config file.");
return CliReturnCode.SUCCESS;
Expand Down
57 changes: 57 additions & 0 deletions src/Cli/ConfigGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,63 @@ public static bool TryCreateSourceObjectForNewEntity(

return true;
}

/// <summary>
/// Displays the effective permissions for all entities defined in the config, listed alphabetically by entity name.
/// Effective permissions include explicitly configured roles as well as inherited permissions:
/// - anonymous → authenticated (when authenticated is not explicitly configured)
/// - authenticated → any named role not explicitly configured for the entity
/// </summary>
/// <returns>True if the effective permissions were successfully displayed; otherwise, false.</returns>
public static bool TryShowEffectivePermissions(ConfigureOptions options, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
{
if (!TryGetConfigFileBasedOnCliPrecedence(loader, options.Config, out string runtimeConfigFile))
{
return false;
}

if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig))
{
_logger.LogError("Failed to read the config file: {runtimeConfigFile}.", runtimeConfigFile);
return false;
}

const string ROLE_ANONYMOUS = "anonymous";
const string ROLE_AUTHENTICATED = "authenticated";

// Iterate entities sorted a-z by name.
foreach ((string entityName, Entity entity) in runtimeConfig.Entities.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase))
{
_logger.LogInformation("Entity: {entityName}", entityName);

bool hasAnonymous = entity.Permissions.Any(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase));
bool hasAuthenticated = entity.Permissions.Any(p => p.Role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase));

foreach (EntityPermission permission in entity.Permissions.OrderBy(p => p.Role, StringComparer.OrdinalIgnoreCase))
{
string actions = string.Join(", ", permission.Actions.Select(a => a.Action.ToString()));
_logger.LogInformation(" Role: {role} | Actions: {actions}", permission.Role, actions);
}

// Show inherited authenticated permissions when authenticated is not explicitly configured.
if (hasAnonymous && !hasAuthenticated)
{
EntityPermission anonPermission = entity.Permissions.First(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase));
string inheritedActions = string.Join(", ", anonPermission.Actions.Select(a => a.Action.ToString()));
_logger.LogInformation(" Role: {role} | Actions: {actions} (inherited from: {source})", ROLE_AUTHENTICATED, inheritedActions, ROLE_ANONYMOUS);
}

// Show inheritance note for named roles.
string inheritSource = hasAuthenticated ? ROLE_AUTHENTICATED : (hasAnonymous ? ROLE_ANONYMOUS : string.Empty);
if (!string.IsNullOrEmpty(inheritSource))
{
_logger.LogInformation(" Any unconfigured named role inherits from: {inheritSource}", inheritSource);
}
}

return true;
}

/// <summary>
/// Tries to update the runtime settings based on the provided runtime options.
/// </summary>
Expand Down
45 changes: 45 additions & 0 deletions src/Core/Authorization/AuthorizationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ public bool IsValidRoleContext(HttpContext httpContext)
/// <inheritdoc />
public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string roleName, EntityActionOperation operation)
{
roleName = GetEffectiveRoleName(entityIdentifier, roleName);
if (EntityPermissionsMap.TryGetValue(entityIdentifier, out EntityMetadata? valueOfEntityToRole))
{
if (valueOfEntityToRole.RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? valueOfRoleToOperation))
Expand All @@ -135,6 +136,7 @@ public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string

public bool IsStoredProcedureExecutionPermitted(string entityName, string roleName, SupportedHttpVerb httpVerb)
{
roleName = GetEffectiveRoleName(entityName, roleName);
bool executionPermitted = EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata)
&& entityMetadata is not null
&& entityMetadata.RoleToOperationMap.TryGetValue(roleName, out _);
Expand All @@ -144,6 +146,7 @@ public bool IsStoredProcedureExecutionPermitted(string entityName, string roleNa
/// <inheritdoc />
public bool AreColumnsAllowedForOperation(string entityName, string roleName, EntityActionOperation operation, IEnumerable<string> columns)
{
roleName = GetEffectiveRoleName(entityName, roleName);
string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName);
ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName);

Expand Down Expand Up @@ -210,6 +213,7 @@ public string ProcessDBPolicy(string entityName, string roleName, EntityActionOp
/// <inheritdoc />
public string GetDBPolicyForRequest(string entityName, string roleName, EntityActionOperation operation)
{
roleName = GetEffectiveRoleName(entityName, roleName);
if (!EntityPermissionsMap[entityName].RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? roleMetadata))
{
return string.Empty;
Expand Down Expand Up @@ -426,6 +430,46 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole(
}
}

/// <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>
Comment on lines +433 to +439
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.
/// <param name="entityName">Name of the entity being accessed.</param>
/// <param name="roleName">Role name from the request.</param>
/// <returns>The role name whose permissions should apply for this request.</returns>
private string GetEffectiveRoleName(string entityName, string roleName)
{
// System roles always resolve to themselves; they do not inherit from other roles.
if (roleName.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) ||
roleName.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))
{
return roleName;
}

if (!EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata))
{
return roleName;
}

// Named role explicitly configured: use its own permissions.
if (entityMetadata.RoleToOperationMap.ContainsKey(roleName))
{
return roleName;
}

// Named role not configured: inherit from 'authenticated' if present.
// Note: 'authenticated' itself may already inherit from 'anonymous' via setup-time copy.
if (entityMetadata.RoleToOperationMap.ContainsKey(ROLE_AUTHENTICATED))
{
return ROLE_AUTHENTICATED;
}

return roleName;
}

/// <summary>
/// Returns a list of all possible operations depending on the provided EntitySourceType.
/// Stored procedures only support Operation.Execute.
Expand Down Expand Up @@ -474,6 +518,7 @@ private static void PopulateAllowedExposedColumns(
/// <inheritdoc />
public IEnumerable<string> GetAllowedExposedColumns(string entityName, string roleName, EntityActionOperation operation)
{
roleName = GetEffectiveRoleName(entityName, roleName);
return EntityPermissionsMap[entityName].RoleToOperationMap[roleName].OperationToColumnMap[operation].AllowedExposedColumns;
}

Expand Down
35 changes: 9 additions & 26 deletions src/Core/Authorization/GraphQLAuthorizationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ namespace Azure.DataApiBuilder.Core.Authorization;
/// </summary>
public class GraphQLAuthorizationHandler : IAuthorizationHandler
{
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
Expand Down Expand Up @@ -44,7 +51,7 @@ public ValueTask<AuthorizeResult> AuthorizeAsync(

// 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) && IsInHeaderDesignatedRole(clientRole, directive.Roles))
if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles))
{
if (!string.IsNullOrEmpty(directive.Policy))
{
Expand Down Expand Up @@ -83,7 +90,7 @@ public ValueTask<AuthorizeResult> AuthorizeAsync(
{
// 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) && IsInHeaderDesignatedRole(clientRole, directive.Roles))
if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles))
{
if (!string.IsNullOrEmpty(directive.Policy))
{
Expand Down Expand Up @@ -129,30 +136,6 @@ private static bool TryGetApiRoleHeader(IDictionary<string, object?> contextData
return false;
}

/// <summary>
/// Checks the pre-validated clientRoleHeader value against the roles listed in @authorize directive's roles.
/// The runtime's GraphQLSchemaBuilder will not add an @authorize directive without any roles defined,
/// however, since the Roles property of HotChocolate's AuthorizeDirective object is nullable,
/// handle the possible null gracefully.
/// </summary>
/// <param name="clientRoleHeader">Role defined in request HTTP Header, X-MS-API-ROLE</param>
/// <param name="roles">Roles defined on the @authorize directive. Case insensitive.</param>
/// <returns>True when the authenticated user's explicitly defined role is present in the authorize directive role list. Otherwise, false.</returns>
private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyList<string>? roles)
{
if (roles is null || roles.Count == 0)
{
return false;
}

if (roles.Any(role => role.Equals(clientRoleHeader, StringComparison.OrdinalIgnoreCase)))
{
return true;
}

return false;
}

/// <summary>
/// Returns whether the ClaimsPrincipal in the HotChocolate IMiddlewareContext.ContextData is authenticated.
/// To be authenticated, at least one ClaimsIdentity in ClaimsPrincipal.Identities must be authenticated.
Expand Down
Loading