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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
@@ -13,11 +16,14 @@ public class AuthController : ControllerBase
|
||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
|
||||
private readonly IAuthService _authService;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
|
||||
public AuthController(IAuthService authService, IWebHostEnvironment env)
|
||||
public AuthController(
|
||||
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||
{
|
||||
_authService = authService;
|
||||
_userManager = userManager;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
@@ -79,17 +85,43 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/me (dev-only diagnostic — remove before production)
|
||||
// GET /api/auth/me
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the claims ASP.NET Core parsed from the Bearer token.
|
||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
|
||||
/// fine; if you then get 403 on /api/users the role claim isn't matching.
|
||||
/// Returns the current user's identity, roles, and effective permissions.
|
||||
/// The SPA calls this on startup and after an admin edits the permission matrix
|
||||
/// to refresh what the UI shows — without forcing a re-login.
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize] // no role restriction — just needs a valid JWT
|
||||
public IActionResult GetMe()
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMe()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user is null || !user.IsActive)
|
||||
return Unauthorized();
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
return Ok(await _authService.BuildUserInfoAsync(user, roles));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/claims (dev-only diagnostic)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the raw claims ASP.NET Core parsed from the Bearer token.
|
||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine;
|
||||
/// if you then get 403 on a protected endpoint the role/permission isn't matching.
|
||||
/// </summary>
|
||||
[HttpGet("claims")]
|
||||
[Authorize]
|
||||
public IActionResult GetClaims()
|
||||
{
|
||||
var claims = User.Claims
|
||||
.Select(c => new { c.Type, c.Value })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Disbursement;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/church-profile")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class ChurchProfileController : ControllerBase
|
||||
{
|
||||
private readonly IChurchProfileService _svc;
|
||||
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
|
||||
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
||||
|
||||
[HttpPut]
|
||||
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
||||
{
|
||||
await _svc.UpdateAsync(r);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Disbursement;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/disbursements")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class DisbursementsController : ControllerBase
|
||||
{
|
||||
private readonly IDisbursementService _svc;
|
||||
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
||||
|
||||
[HttpGet("approved-unpaid")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetApprovedUnpaid()
|
||||
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
||||
|
||||
[HttpPost("issue")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
||||
{
|
||||
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
||||
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("checks")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetRegister(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
||||
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
||||
|
||||
[HttpGet("checks/{id:int}")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("checks/{id:int}/void")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
||||
{
|
||||
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
||||
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/pdf")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPdf(int id)
|
||||
{
|
||||
var result = await _svc.RenderPdfAsync(id);
|
||||
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/receipt-pdf")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetReceiptPdf(int id)
|
||||
{
|
||||
var result = await _svc.RenderReceiptPdfAsync(id);
|
||||
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("checks/{id:int}/acknowledge")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
|
||||
[RequestSizeLimit(5_242_880)]
|
||||
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
|
||||
{
|
||||
@@ -82,6 +91,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/signature")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetSignature(int id)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -19,32 +20,32 @@ public class ExpenseCategoriesController : ControllerBase
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
|
||||
[HttpPost("groups")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
||||
|
||||
[HttpPut("groups/{id:int}")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
||||
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpDelete("groups/{id:int}")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeactivateGroup(int id)
|
||||
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpPost("subcategories")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
||||
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpPut("subcategories/{id:int}")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
||||
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpDelete("subcategories/{id:int}")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeactivateSub(int id)
|
||||
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
}
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
// Class is [Authorize] only — any authenticated member may submit/view their OWN
|
||||
// reimbursements. Finance-level privileges (view-all, edit-any, approve) are resolved
|
||||
// against the configurable permission matrix on the "Expenses" module.
|
||||
[ApiController]
|
||||
[Route("api/expenses")]
|
||||
[Authorize]
|
||||
public class ExpensesController : ControllerBase
|
||||
{
|
||||
private readonly IExpenseService _svc;
|
||||
public ExpensesController(IExpenseService svc) => _svc = svc;
|
||||
private readonly IExpenseService _svc;
|
||||
private readonly IPermissionService _perms;
|
||||
public ExpensesController(IExpenseService svc, IPermissionService perms)
|
||||
{
|
||||
_svc = svc;
|
||||
_perms = perms;
|
||||
}
|
||||
|
||||
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin");
|
||||
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
|
||||
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
|
||||
|
||||
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
|
||||
private async Task<bool> CanManageAsync() =>
|
||||
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
|
||||
|
||||
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
|
||||
private async Task<bool> CanViewAllAsync() =>
|
||||
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
|
||||
|
||||
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
||||
private string CurrentUserId() =>
|
||||
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
|
||||
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
||||
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
|
||||
{
|
||||
if (!CanViewAll()) return Forbid();
|
||||
if (!await CanViewAllAsync()) return Forbid();
|
||||
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
|
||||
}
|
||||
|
||||
@@ -43,21 +60,21 @@ public class ExpensesController : ControllerBase
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
if (dto is null) return NotFound();
|
||||
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
||||
{
|
||||
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); }
|
||||
try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); }
|
||||
try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); }
|
||||
try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/approve")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Approve(int id)
|
||||
{
|
||||
try { await _svc.ApproveAsync(id); return NoContent(); }
|
||||
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/reject")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
||||
{
|
||||
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
||||
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/pay")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
||||
{
|
||||
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
||||
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance());
|
||||
await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _svc.OpenReceiptAsync(id, IsFinance());
|
||||
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/finance-dashboard")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
|
||||
public class FinanceDashboardController : ControllerBase
|
||||
{
|
||||
private readonly IFinanceDashboardService _svc;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/giving-categories")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class GivingCategoriesController : ControllerBase
|
||||
{
|
||||
private readonly IGivingCategoryService _svc;
|
||||
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
||||
{
|
||||
var id = await _svc.CreateAsync(request);
|
||||
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Deactivate(int id)
|
||||
{
|
||||
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/givings")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class GivingsController : ControllerBase
|
||||
{
|
||||
private readonly IGivingService _svc;
|
||||
public GivingsController(IGivingService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
|
||||
@@ -21,6 +23,7 @@ public class GivingsController : ControllerBase
|
||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
||||
{
|
||||
var id = await _svc.CreateAsync(request);
|
||||
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Members;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -15,7 +16,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>GET /api/members/{id}</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _members.GetByIdAsync(id);
|
||||
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>POST /api/members</summary>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "super_admin,secretary")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
||||
{
|
||||
var id = await _members.CreateAsync(request);
|
||||
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>PUT /api/members/{id}</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
[Authorize(Roles = "super_admin,secretary")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
||||
{
|
||||
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
||||
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
[Authorize(Roles = "super_admin,secretary")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _members.DeleteAsync(id); return NoContent(); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/monthly-statements")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class MonthlyStatementsController : ControllerBase
|
||||
{
|
||||
private readonly IMonthlyStatementService _svc;
|
||||
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
||||
=> Ok(await _svc.GetAllAsync(year));
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
||||
{
|
||||
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/finalize")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Finalize(int id)
|
||||
{
|
||||
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/offering-sessions")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class OfferingSessionsController : ControllerBase
|
||||
{
|
||||
private readonly IOfferingSessionService _svc;
|
||||
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
||||
|
||||
[HttpGet("check-date")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
||||
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
||||
{
|
||||
try
|
||||
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/reopen")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Reopen(int id)
|
||||
{
|
||||
try { await _svc.ReopenAsync(id); return NoContent(); }
|
||||
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
||||
{
|
||||
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
|
||||
@@ -60,6 +67,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
|
||||
|
||||
[HttpPost("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
|
||||
public async Task<IActionResult> UploadProof(int id, IFormFile file)
|
||||
{
|
||||
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetProof(int id)
|
||||
{
|
||||
try
|
||||
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeleteProof(int id)
|
||||
{
|
||||
try { await _svc.DeleteProofAsync(id); return NoContent(); }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin surface for the configurable RBAC matrix. Restricted to super_admin —
|
||||
/// the role that governs who governs everyone else.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/permissions")]
|
||||
[Authorize(Roles = "super_admin")]
|
||||
public class PermissionsController : ControllerBase
|
||||
{
|
||||
private readonly IPermissionService _permissions;
|
||||
public PermissionsController(IPermissionService permissions) => _permissions = permissions;
|
||||
|
||||
/// <summary>GET /api/permissions — the full role × module matrix.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetMatrix() => Ok(await _permissions.GetMatrixAsync());
|
||||
|
||||
/// <summary>GET /api/permissions/catalog — module + action names for the grid.</summary>
|
||||
[HttpGet("catalog")]
|
||||
public IActionResult GetCatalog() => Ok(new PermissionCatalogDto
|
||||
{
|
||||
Modules = Modules.All,
|
||||
Actions = PermissionActions.All,
|
||||
});
|
||||
|
||||
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
|
||||
[HttpPut("{roleName}")]
|
||||
public async Task<IActionResult> UpdateRole(string roleName, [FromBody] UpdateRolePermissionsRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _permissions.UpsertRoleAsync(roleName, request.Modules);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Users;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize(Roles = "super_admin")]
|
||||
[Authorize]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserManagementService _users;
|
||||
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>GET /api/users/{id}</summary>
|
||||
[HttpGet("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(string id)
|
||||
{
|
||||
var dto = await _users.GetByIdAsync(id);
|
||||
@@ -34,6 +37,7 @@ public class UsersController : ControllerBase
|
||||
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||||
{
|
||||
try
|
||||
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
||||
[HttpPut("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
||||
@@ -58,6 +63,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
||||
[HttpDelete("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Deactivate(string id)
|
||||
{
|
||||
try { await _users.DeactivateAsync(id); return NoContent(); }
|
||||
@@ -66,6 +72,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
||||
[HttpPost("{id}/reset-password")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> ResetPassword(string id)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
|
||||
namespace ROLAC.API.DTOs.Auth;
|
||||
|
||||
public class LoginResponse
|
||||
@@ -17,4 +19,10 @@ public class UserInfo
|
||||
public string Email { get; set; } = null!;
|
||||
public IList<string> Roles { get; set; } = [];
|
||||
public string LanguagePreference { get; set; } = "en";
|
||||
|
||||
/// <summary>
|
||||
/// Effective permissions (union across the user's roles), keyed by module name.
|
||||
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
||||
/// </summary>
|
||||
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace ROLAC.API.DTOs.Permissions;
|
||||
|
||||
/// <summary>Effective action flags for one module (union across a user's roles).</summary>
|
||||
public class ModuleActions
|
||||
{
|
||||
public bool Read { get; set; }
|
||||
public bool Write { get; set; }
|
||||
public bool Delete { get; set; }
|
||||
public bool Approve { get; set; }
|
||||
|
||||
public bool Any => Read || Write || Delete || Approve;
|
||||
}
|
||||
|
||||
/// <summary>One module's grant for a single role — used in the admin matrix and updates.</summary>
|
||||
public class ModulePermissionDto
|
||||
{
|
||||
public string Module { get; set; } = null!;
|
||||
public bool CanRead { get; set; }
|
||||
public bool CanWrite { get; set; }
|
||||
public bool CanDelete { get; set; }
|
||||
public bool CanApprove { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One role's full row in the admin matrix (every module, dense).</summary>
|
||||
public class RolePermissionRow
|
||||
{
|
||||
public string RoleName { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
/// <summary>super_admin is shown read-only/full — it bypasses the matrix.</summary>
|
||||
public bool IsSuperAdmin { get; set; }
|
||||
public List<ModulePermissionDto> Modules { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>GET /api/permissions — the whole matrix plus the catalog for grid headers.</summary>
|
||||
public class PermissionMatrixDto
|
||||
{
|
||||
public IReadOnlyList<string> AllModules { get; set; } = [];
|
||||
public IReadOnlyList<string> AllActions { get; set; } = [];
|
||||
public List<RolePermissionRow> Roles { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>GET /api/permissions/catalog — module + action names for building the UI.</summary>
|
||||
public class PermissionCatalogDto
|
||||
{
|
||||
public IReadOnlyList<string> Modules { get; set; } = [];
|
||||
public IReadOnlyList<string> Actions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
|
||||
public class UpdateRolePermissionsRequest
|
||||
{
|
||||
public List<ModulePermissionDto> Modules { get; set; } = [];
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<Check> Checks => Set<Check>();
|
||||
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@@ -60,6 +61,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.Description).HasMaxLength(500);
|
||||
});
|
||||
|
||||
// ── RolePermission (configurable RBAC matrix) ───────────────────────
|
||||
builder.Entity<RolePermission>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
|
||||
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
|
||||
// One row per (role, module).
|
||||
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
|
||||
entity.HasOne(e => e.Role).WithMany()
|
||||
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ── FamilyUnit ──────────────────────────────────────────────────────
|
||||
builder.Entity<FamilyUnit>(entity =>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Data;
|
||||
@@ -62,6 +63,60 @@ public static class DbSeeder
|
||||
("visitor", "Visitor — public pages only"),
|
||||
];
|
||||
|
||||
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
|
||||
// existed before the configurable RBAC system, so day-one behavior is unchanged.
|
||||
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
|
||||
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
|
||||
// later edits via the Permissions UI are never clobbered on restart.
|
||||
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
|
||||
[
|
||||
// Secretary — manages member data.
|
||||
("secretary", Modules.Members, true, true, true, false),
|
||||
|
||||
// Pastor — read-only overview of members and all expenses.
|
||||
("pastor", Modules.Members, true, false, false, false),
|
||||
("pastor", Modules.Expenses, true, false, false, false),
|
||||
|
||||
// Finance — full control over the finance modules.
|
||||
("finance", Modules.Givings, true, true, true, false),
|
||||
("finance", Modules.GivingCategories, true, true, true, false),
|
||||
("finance", Modules.Expenses, true, true, true, true),
|
||||
("finance", Modules.ExpenseCategories, true, true, true, false),
|
||||
("finance", Modules.OfferingSessions, true, true, true, true),
|
||||
("finance", Modules.FinanceDashboard, true, false, false, false),
|
||||
("finance", Modules.MonthlyStatements, true, true, false, true),
|
||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||
("finance", Modules.Disbursements, true, true, true, true),
|
||||
];
|
||||
|
||||
public static async Task SeedRolePermissionsAsync(AppDbContext db)
|
||||
{
|
||||
var rolesByName = await db.Roles
|
||||
.Where(r => r.Name != null)
|
||||
.ToDictionaryAsync(r => r.Name!, r => r.Id);
|
||||
|
||||
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
|
||||
{
|
||||
if (!rolesByName.TryGetValue(role, out var roleId))
|
||||
continue;
|
||||
|
||||
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
|
||||
if (exists)
|
||||
continue; // never clobber an admin's edit
|
||||
|
||||
db.RolePermissions.Add(new RolePermission
|
||||
{
|
||||
RoleId = roleId,
|
||||
Module = module,
|
||||
CanRead = read,
|
||||
CanWrite = write,
|
||||
CanDelete = delete,
|
||||
CanApprove = approve,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
|
||||
{
|
||||
foreach (var (name, description) in Roles)
|
||||
@@ -159,6 +214,7 @@ public static class DbSeeder
|
||||
await SeedRolesAsync(roleManager);
|
||||
|
||||
var db = services.GetRequiredService<AppDbContext>();
|
||||
await SeedRolePermissionsAsync(db);
|
||||
await SeedGivingCategoriesAsync(db);
|
||||
await SeedMinistriesAsync(db);
|
||||
await SeedExpenseCategoriesAsync(db);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (Role × Module). Stores what the role may do on that module.
|
||||
/// The effective permission for a user is the boolean OR of these flags across
|
||||
/// all of the user's roles. <c>super_admin</c> is never stored here — it bypasses
|
||||
/// permission checks entirely (see PermissionAuthorizationHandler).
|
||||
/// </summary>
|
||||
public class RolePermission
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>FK to AspNetRoles.Id.</summary>
|
||||
public string RoleId { get; set; } = null!;
|
||||
|
||||
/// <summary>Module constant name (see <see cref="Authorization.Modules"/>).</summary>
|
||||
public string Module { get; set; } = null!;
|
||||
|
||||
public bool CanRead { get; set; }
|
||||
public bool CanWrite { get; set; }
|
||||
public bool CanDelete { get; set; }
|
||||
public bool CanApprove { get; set; }
|
||||
|
||||
public AppRole Role { get; set; } = null!;
|
||||
}
|
||||
@@ -1310,6 +1310,44 @@ namespace ROLAC.API.Migrations
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("CanApprove")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("CanDelete")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("CanRead")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("CanWrite")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Module")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId", "Module")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||
@@ -1481,6 +1519,17 @@ namespace ROLAC.API.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.AppRole", "Role")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Role");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||
{
|
||||
b.Navigation("RefreshTokens");
|
||||
|
||||
@@ -135,6 +135,19 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable role-based permissions (RBAC matrix)
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<IPermissionService, PermissionService>();
|
||||
builder.Services.AddAuthorization();
|
||||
// Dynamic policy provider materializes "PERM:<module>:<action>" policies on demand;
|
||||
// must be registered AFTER AddAuthorization so it overrides the default provider.
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
|
||||
ROLAC.API.Authorization.PermissionPolicyProvider>();
|
||||
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
|
||||
ROLAC.API.Authorization.PermissionAuthorizationHandler>();
|
||||
|
||||
// Real-time hub for the live Sunday attendance counter.
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
|
||||
@@ -12,17 +12,20 @@ public class AuthService : IAuthService
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ITokenService _tokenService;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPermissionService _permissions;
|
||||
private readonly int _refreshTokenExpiryDays;
|
||||
|
||||
public AuthService(
|
||||
UserManager<AppUser> userManager,
|
||||
ITokenService tokenService,
|
||||
AppDbContext db,
|
||||
IPermissionService permissions,
|
||||
IConfiguration config)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_tokenService = tokenService;
|
||||
_db = db;
|
||||
_permissions = permissions;
|
||||
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
|
||||
}
|
||||
|
||||
@@ -62,7 +65,7 @@ public class AuthService : IAuthService
|
||||
await _userManager.UpdateAsync(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return (BuildResponse(accessToken, user, roles), rawRefresh);
|
||||
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -104,7 +107,7 @@ public class AuthService : IAuthService
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return (BuildResponse(newAccess, user, roles), newRaw);
|
||||
return (await BuildResponseAsync(newAccess, user, roles), newRaw);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -128,18 +131,23 @@ public class AuthService : IAuthService
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static LoginResponse BuildResponse(
|
||||
private async Task<LoginResponse> BuildResponseAsync(
|
||||
string accessToken, AppUser user, IList<string> roles)
|
||||
=> new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
ExpiresIn = 15 * 60,
|
||||
User = new UserInfo
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email!,
|
||||
Roles = roles,
|
||||
LanguagePreference = user.LanguagePreference,
|
||||
},
|
||||
User = await BuildUserInfoAsync(user, roles),
|
||||
};
|
||||
|
||||
/// <summary>Builds UserInfo including the effective permission map. Reused by /me.</summary>
|
||||
public async Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles)
|
||||
=> new()
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email!,
|
||||
Roles = roles,
|
||||
LanguagePreference = user.LanguagePreference,
|
||||
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
@@ -28,4 +29,11 @@ public interface IAuthService
|
||||
/// Silently succeeds if the token is not found.
|
||||
/// </summary>
|
||||
Task LogoutAsync(string rawRefreshToken);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
|
||||
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
|
||||
/// after an admin edits the matrix, without forcing a re-login.
|
||||
/// </summary>
|
||||
Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IPermissionService
|
||||
{
|
||||
/// <summary>True if any of the given roles grants the module/action.</summary>
|
||||
Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action);
|
||||
|
||||
/// <summary>Effective permissions for a user (union across roles). super_admin ⇒ all.</summary>
|
||||
Task<Dictionary<string, ModuleActions>> GetEffectivePermissionsAsync(IEnumerable<string> roles);
|
||||
|
||||
/// <summary>Dense matrix (every role × every module) for the admin UI.</summary>
|
||||
Task<PermissionMatrixDto> GetMatrixAsync();
|
||||
|
||||
/// <summary>Replaces a role's grants. Rejects super_admin. Invalidates the cache.</summary>
|
||||
Task UpsertRoleAsync(string roleName, IEnumerable<ModulePermissionDto> rows);
|
||||
|
||||
/// <summary>Drops the cached matrix so the next check rebuilds from the database.</summary>
|
||||
void Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configurable RBAC matrix. Registered as a singleton; the in-memory
|
||||
/// snapshot is shared across requests and rebuilt on demand. Database access goes
|
||||
/// through a scoped <see cref="AppDbContext"/> obtained from <see cref="IServiceScopeFactory"/>.
|
||||
/// </summary>
|
||||
public class PermissionService : IPermissionService
|
||||
{
|
||||
private const string CacheKey = "rbac:matrix";
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public PermissionService(IServiceScopeFactory scopeFactory, IMemoryCache cache)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action)
|
||||
{
|
||||
var snapshot = await GetSnapshotAsync();
|
||||
|
||||
foreach (var role in roles)
|
||||
{
|
||||
if (snapshot.TryGetValue(role, out var modules)
|
||||
&& modules.TryGetValue(module, out var actions)
|
||||
&& Grants(actions, action))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, ModuleActions>> GetEffectivePermissionsAsync(IEnumerable<string> roles)
|
||||
{
|
||||
var roleList = roles.ToList();
|
||||
|
||||
if (roleList.Contains(PermissionAuthorizationHandler.SuperAdminRole))
|
||||
return AllModulesFull();
|
||||
|
||||
var snapshot = await GetSnapshotAsync();
|
||||
var effective = new Dictionary<string, ModuleActions>();
|
||||
|
||||
foreach (var role in roleList)
|
||||
{
|
||||
if (!snapshot.TryGetValue(role, out var modules))
|
||||
continue;
|
||||
|
||||
foreach (var (module, actions) in modules)
|
||||
{
|
||||
if (!effective.TryGetValue(module, out var merged))
|
||||
effective[module] = merged = new ModuleActions();
|
||||
|
||||
merged.Read |= actions.Read;
|
||||
merged.Write |= actions.Write;
|
||||
merged.Delete |= actions.Delete;
|
||||
merged.Approve |= actions.Approve;
|
||||
}
|
||||
}
|
||||
|
||||
// Only surface modules the user can actually touch.
|
||||
return effective.Where(kvp => kvp.Value.Any)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
}
|
||||
|
||||
public async Task<PermissionMatrixDto> GetMatrixAsync()
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var roles = await db.Roles.OrderBy(r => r.Name).ToListAsync();
|
||||
var perms = await db.RolePermissions.ToListAsync();
|
||||
var byRoleId = perms.ToLookup(p => p.RoleId);
|
||||
|
||||
var rows = new List<RolePermissionRow>();
|
||||
foreach (var role in roles)
|
||||
{
|
||||
var isSuperAdmin = role.Name == PermissionAuthorizationHandler.SuperAdminRole;
|
||||
var existing = byRoleId[role.Id].ToDictionary(p => p.Module);
|
||||
|
||||
var moduleRows = new List<ModulePermissionDto>();
|
||||
foreach (var module in Modules.All)
|
||||
{
|
||||
existing.TryGetValue(module, out var rp);
|
||||
moduleRows.Add(new ModulePermissionDto
|
||||
{
|
||||
Module = module,
|
||||
// super_admin is always-full (display only — never persisted).
|
||||
CanRead = isSuperAdmin || (rp?.CanRead ?? false),
|
||||
CanWrite = isSuperAdmin || (rp?.CanWrite ?? false),
|
||||
CanDelete = isSuperAdmin || (rp?.CanDelete ?? false),
|
||||
CanApprove = isSuperAdmin || (rp?.CanApprove ?? false),
|
||||
});
|
||||
}
|
||||
|
||||
rows.Add(new RolePermissionRow
|
||||
{
|
||||
RoleName = role.Name!,
|
||||
Description = role.Description,
|
||||
IsSuperAdmin = isSuperAdmin,
|
||||
Modules = moduleRows,
|
||||
});
|
||||
}
|
||||
|
||||
return new PermissionMatrixDto
|
||||
{
|
||||
AllModules = Modules.All,
|
||||
AllActions = PermissionActions.All,
|
||||
Roles = rows,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpsertRoleAsync(string roleName, IEnumerable<ModulePermissionDto> rows)
|
||||
{
|
||||
if (roleName == PermissionAuthorizationHandler.SuperAdminRole)
|
||||
throw new InvalidOperationException("super_admin permissions are fixed and cannot be edited.");
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var role = await db.Roles.FirstOrDefaultAsync(r => r.Name == roleName)
|
||||
?? throw new KeyNotFoundException($"Role '{roleName}' not found.");
|
||||
|
||||
var existing = await db.RolePermissions
|
||||
.Where(p => p.RoleId == role.Id)
|
||||
.ToListAsync();
|
||||
db.RolePermissions.RemoveRange(existing);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!Modules.IsValid(row.Module))
|
||||
continue;
|
||||
if (!(row.CanRead || row.CanWrite || row.CanDelete || row.CanApprove))
|
||||
continue; // don't store all-false rows
|
||||
|
||||
db.RolePermissions.Add(new RolePermission
|
||||
{
|
||||
RoleId = role.Id,
|
||||
Module = row.Module,
|
||||
CanRead = row.CanRead,
|
||||
CanWrite = row.CanWrite,
|
||||
CanDelete = row.CanDelete,
|
||||
CanApprove = row.CanApprove,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void Invalidate() => _cache.Remove(CacheKey);
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>roleName → (module → ModuleActions). Cached until invalidated.</summary>
|
||||
private async Task<Dictionary<string, Dictionary<string, ModuleActions>>> GetSnapshotAsync()
|
||||
{
|
||||
if (_cache.TryGetValue(CacheKey, out Dictionary<string, Dictionary<string, ModuleActions>>? cached)
|
||||
&& cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var rows = await (
|
||||
from rp in db.RolePermissions
|
||||
join r in db.Roles on rp.RoleId equals r.Id
|
||||
select new { RoleName = r.Name!, rp.Module, rp.CanRead, rp.CanWrite, rp.CanDelete, rp.CanApprove }
|
||||
).ToListAsync();
|
||||
|
||||
var snapshot = new Dictionary<string, Dictionary<string, ModuleActions>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!snapshot.TryGetValue(row.RoleName, out var modules))
|
||||
snapshot[row.RoleName] = modules = new Dictionary<string, ModuleActions>();
|
||||
|
||||
modules[row.Module] = new ModuleActions
|
||||
{
|
||||
Read = row.CanRead,
|
||||
Write = row.CanWrite,
|
||||
Delete = row.CanDelete,
|
||||
Approve = row.CanApprove,
|
||||
};
|
||||
}
|
||||
|
||||
_cache.Set(CacheKey, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static bool Grants(ModuleActions actions, string action) => action switch
|
||||
{
|
||||
PermissionActions.Read => actions.Read,
|
||||
PermissionActions.Write => actions.Write,
|
||||
PermissionActions.Delete => actions.Delete,
|
||||
PermissionActions.Approve => actions.Approve,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static Dictionary<string, ModuleActions> AllModulesFull()
|
||||
{
|
||||
var all = new Dictionary<string, ModuleActions>();
|
||||
foreach (var module in Modules.All)
|
||||
all[module] = new ModuleActions { Read = true, Write = true, Delete = true, Approve = true };
|
||||
return all;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user