Add role control
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Gates an action/controller on a configurable permission. Usage:
|
||||
/// <c>[HasPermission(Modules.Members, PermissionActions.Write)]</c>.
|
||||
/// Encodes the policy name <c>PERM:<module>:<action></c>, which
|
||||
/// <see cref="PermissionPolicyProvider"/> turns into a <see cref="PermissionRequirement"/>.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
||||
public class HasPermissionAttribute : AuthorizeAttribute
|
||||
{
|
||||
public const string PolicyPrefix = "PERM:";
|
||||
|
||||
public HasPermissionAttribute(string module, string action)
|
||||
=> Policy = $"{PolicyPrefix}{module}:{action}";
|
||||
|
||||
/// <summary>Parses a policy name back into (module, action), or null if not a PERM policy.</summary>
|
||||
public static (string Module, string Action)? Parse(string policyName)
|
||||
{
|
||||
if (!policyName.StartsWith(PolicyPrefix, StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var body = policyName[PolicyPrefix.Length..];
|
||||
var split = body.IndexOf(':');
|
||||
if (split <= 0 || split == body.Length - 1)
|
||||
return null;
|
||||
|
||||
return (body[..split], body[(split + 1)..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical list of permission-controlled modules. The names are stored verbatim
|
||||
/// in <see cref="Entities.RolePermission.Module"/> and used in <c>[HasPermission]</c>
|
||||
/// attributes, so changing a string here is a breaking change requiring a data update.
|
||||
/// </summary>
|
||||
public static class Modules
|
||||
{
|
||||
public const string Members = "Members";
|
||||
public const string Users = "Users";
|
||||
public const string Givings = "Givings";
|
||||
public const string GivingCategories = "GivingCategories";
|
||||
public const string Expenses = "Expenses";
|
||||
public const string ExpenseCategories = "ExpenseCategories";
|
||||
public const string OfferingSessions = "OfferingSessions";
|
||||
public const string Ministries = "Ministries";
|
||||
public const string FinanceDashboard = "FinanceDashboard";
|
||||
public const string MonthlyStatements = "MonthlyStatements";
|
||||
public const string ChurchProfile = "ChurchProfile";
|
||||
public const string Disbursements = "Disbursements";
|
||||
public const string MealAttendance = "MealAttendance";
|
||||
public const string Permissions = "Permissions";
|
||||
|
||||
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
Members,
|
||||
Users,
|
||||
Givings,
|
||||
GivingCategories,
|
||||
Expenses,
|
||||
ExpenseCategories,
|
||||
OfferingSessions,
|
||||
Ministries,
|
||||
FinanceDashboard,
|
||||
MonthlyStatements,
|
||||
ChurchProfile,
|
||||
Disbursements,
|
||||
MealAttendance,
|
||||
Permissions,
|
||||
];
|
||||
|
||||
public static bool IsValid(string module) => All.Contains(module);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The four actions a role can be granted on a module. The default HTTP-verb mapping
|
||||
/// is GET→Read, POST/PUT/PATCH→Write, DELETE→Delete; "Approve" is applied explicitly
|
||||
/// to state-transition endpoints (approve / finalize / issue / sign, etc.).
|
||||
/// </summary>
|
||||
public static class PermissionActions
|
||||
{
|
||||
public const string Read = "Read";
|
||||
public const string Write = "Write";
|
||||
public const string Delete = "Delete";
|
||||
public const string Approve = "Approve";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [Read, Write, Delete, Approve];
|
||||
|
||||
public static bool IsValid(string action) => All.Contains(action);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates <see cref="PermissionRequirement"/> against the user's roles.
|
||||
/// <c>super_admin</c> always passes (bypass); otherwise the requirement succeeds if
|
||||
/// ANY of the user's roles grants the requested module/action (union across roles).
|
||||
/// </summary>
|
||||
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||
{
|
||||
public const string SuperAdminRole = "super_admin";
|
||||
|
||||
private readonly IPermissionService _permissions;
|
||||
|
||||
public PermissionAuthorizationHandler(IPermissionService permissions)
|
||||
=> _permissions = permissions;
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context, PermissionRequirement requirement)
|
||||
{
|
||||
// Roles live in "role" claims (RoleClaimType = "role", MapInboundClaims = false).
|
||||
var roles = context.User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||
|
||||
if (roles.Contains(SuperAdminRole))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await _permissions.HasPermissionAsync(roles, requirement.Module, requirement.Action))
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Materializes <c>PERM:<module>:<action></c> policies on demand so we never
|
||||
/// have to register every module/action combination at startup. Any other policy name
|
||||
/// (including the default and <c>Roles=</c> policies) is delegated to the framework's
|
||||
/// default provider, so existing <c>[Authorize(Roles=...)]</c> usages keep working.
|
||||
/// </summary>
|
||||
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
||||
{
|
||||
private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||
|
||||
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||
|
||||
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
{
|
||||
var parsed = HasPermissionAttribute.Parse(policyName);
|
||||
if (parsed is null)
|
||||
return _fallback.GetPolicyAsync(policyName);
|
||||
|
||||
var policy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new PermissionRequirement(parsed.Value.Module, parsed.Value.Action))
|
||||
.Build();
|
||||
|
||||
return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization requirement carrying the module + action a request needs.
|
||||
/// Materialized on demand by <see cref="PermissionPolicyProvider"/> from a policy
|
||||
/// name of the form <c>PERM:<module>:<action></c>.
|
||||
/// </summary>
|
||||
public class PermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public string Module { get; }
|
||||
public string Action { get; }
|
||||
|
||||
public PermissionRequirement(string module, string action)
|
||||
{
|
||||
Module = module;
|
||||
Action = action;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user