237 lines
8.7 KiB
C#
237 lines
8.7 KiB
C#
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;
|
||
}
|
||
}
|