Files
ROLAC/API/ROLAC.API/Services/PermissionService.cs
T
2026-06-23 07:19:08 -07:00

237 lines
8.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}