Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
66e2112
Initial plan
Copilot Feb 25, 2026
22f6714
Implement role inheritance and show-effective-permissions CLI command…
Copilot Feb 25, 2026
dab4c6f
de-couple graphQL auth and auth resolver for one source of truth
aaronburtle Feb 26, 2026
c69318b
Merge branch 'main' into copilot/add-role-inheritance-permissions
aaronburtle Feb 26, 2026
04819a6
Fix incorrect ORIGINAL_ROLE_CLAIM_TYPE assertion in UniqueClaimsResol…
Copilot Feb 26, 2026
80b18e6
addressing comments, deep copy
aaronburtle Mar 5, 2026
7d31cf5
add missing test coverage
aaronburtle Mar 5, 2026
c5b5055
move logic to concrete class
aaronburtle Mar 5, 2026
6b64910
Move IsRoleAllowedByDirective from default interface method to concre…
Copilot Mar 5, 2026
e8158d2
Remove duplicate IsRoleAllowedByDirective implementation from Authori…
Copilot Mar 5, 2026
3f8b2ff
merge
aaronburtle Mar 6, 2026
00e0b63
Merge branch 'main' into copilot/add-role-inheritance-permissions
aaronburtle Mar 6, 2026
6a52e37
Remove duplicate TestShowEffectivePermissions_DoesNotModifyConfigFile…
Copilot Mar 8, 2026
b412a4e
Fix IsRoleAllowedByDirective to include authenticated→anonymous inher…
Copilot Mar 8, 2026
31a8756
Revert unrelated change to RequestParserUnitTests.cs
Copilot Mar 8, 2026
da821c6
Merge branch 'main' into copilot/add-role-inheritance-permissions
Aniruddh25 Mar 8, 2026
18110df
Merge branch 'main' into copilot/add-role-inheritance-permissions
Aniruddh25 Mar 8, 2026
340f9a8
Fix CI failures: restrict IsRoleAllowedByDirective inheritance to unc…
Copilot Mar 9, 2026
876e15c
test fix
aaronburtle Mar 9, 2026
f44127e
resolve merge conflicts
aaronburtle Mar 9, 2026
3d583e9
Merge branch 'main' into copilot/add-role-inheritance-permissions
aaronburtle Mar 9, 2026
ca3579f
fix build errors merge fix
aaronburtle Mar 10, 2026
8e68a44
Merge branch 'copilot/add-role-inheritance-permissions' of github.com…
aaronburtle Mar 10, 2026
72b703d
missing test
aaronburtle Mar 10, 2026
5dea0a0
Merge branch 'main' into copilot/add-role-inheritance-permissions
Aniruddh25 Mar 10, 2026
88c5245
removed accidently added file
aaronburtle Mar 10, 2026
86fa5c0
Merge branch 'copilot/add-role-inheritance-permissions' of github.com…
aaronburtle Mar 10, 2026
395877a
revert nullable guards
aaronburtle Mar 10, 2026
02d1327
revert nullable local fix
aaronburtle Mar 10, 2026
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
32 changes: 32 additions & 0 deletions src/Auth/AuthorizationMetadataHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ public class RoleMetadata
/// Given the key (operation) returns the associated OperationMetadata object.
/// </summary>
public Dictionary<EntityActionOperation, OperationMetadata> OperationToColumnMap { get; set; } = new();

/// <summary>
/// Creates a deep clone of this RoleMetadata instance so that mutations
/// to the clone do not affect the original (and vice versa).
/// This is critical when copying permissions from one role to another
/// (e.g., anonymous → authenticated) to prevent shared mutable state.
/// </summary>
public RoleMetadata DeepClone()
{
RoleMetadata clone = new();
foreach ((EntityActionOperation operation, OperationMetadata metadata) in OperationToColumnMap)
{
clone.OperationToColumnMap[operation] = metadata.DeepClone();
}

return clone;
}
}

/// <summary>
Expand All @@ -68,4 +85,19 @@ public class OperationMetadata
public HashSet<string> Included { get; set; } = new();
public HashSet<string> Excluded { get; set; } = new();
public HashSet<string> AllowedExposedColumns { get; set; } = new();

/// <summary>
/// Creates a deep clone of this OperationMetadata instance so that
/// mutations to the clone do not affect the original (and vice versa).
/// </summary>
public OperationMetadata DeepClone()
{
return new OperationMetadata
{
DatabasePolicy = DatabasePolicy,
Included = new HashSet<string>(Included),
Excluded = new HashSet<string>(Excluded),
AllowedExposedColumns = new HashSet<string>(AllowedExposedColumns)
};
}
}
19 changes: 19 additions & 0 deletions src/Auth/IAuthorizationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,23 @@ 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 'authenticated' and 'anonymous' is listed, return true (inheritance).
/// - If the role is an unconfigured named role (not in any entity's explicit permissions)
/// and either 'authenticated' or 'anonymous' is listed, return true (inheritance).
/// - Explicitly configured named roles use strict matching only, to prevent unintended
/// access to operations outside their explicitly scoped permissions.
/// </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);
}
370 changes: 370 additions & 0 deletions src/Cli.Tests/ConfigureOptionsTests.cs

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions src/Cli/Commands/ConfigureOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public ConfigureOptions(
RollingInterval? fileSinkRollingInterval = null,
int? fileSinkRetainedFileCountLimit = null,
long? fileSinkFileSizeLimitBytes = null,
bool showEffectivePermissions = false,
string? config = null)
: base(config)
{
Expand Down Expand Up @@ -141,6 +142,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 @@ -302,11 +304,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);
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 @@ -589,6 +589,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
Loading
Loading