using System.Security.Claims;
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;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services;
public interface IPermissionService
{
/// True if any of the given roles grants the module/action.
Task HasPermissionAsync(IEnumerable roles, string module, string action);
/// Effective permissions for a user (union across roles). super_admin ⇒ all.
Task> GetEffectivePermissionsAsync(IEnumerable roles);
/// Dense matrix (every role × every module) for the admin UI.
Task GetMatrixAsync();
/// Replaces a role's grants. Rejects super_admin. Invalidates the cache.
Task UpsertRoleAsync(string roleName, IEnumerable rows);
/// Drops the cached matrix so the next check rebuilds from the database.
void Invalidate();
}
///
/// 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 obtained from .
///
public class PermissionService : IPermissionService
{
private const string CacheKey = "rbac:matrix";
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMemoryCache _cache;
private readonly SystemLogQueue _logQueue;
private readonly IHttpContextAccessor _http;
public PermissionService(
IServiceScopeFactory scopeFactory,
IMemoryCache cache,
SystemLogQueue logQueue,
IHttpContextAccessor http)
{
_scopeFactory = scopeFactory;
_cache = cache;
_logQueue = logQueue;
_http = http;
}
public async Task HasPermissionAsync(IEnumerable 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> GetEffectivePermissionsAsync(IEnumerable roles)
{
var roleList = roles.ToList();
if (roleList.Contains(PermissionAuthorizationHandler.SuperAdminRole))
return AllModulesFull();
var snapshot = await GetSnapshotAsync();
var effective = new Dictionary();
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 GetMatrixAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
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();
foreach (var role in roles)
{
var isSuperAdmin = role.Name == PermissionAuthorizationHandler.SuperAdminRole;
var existing = byRoleId[role.Id].ToDictionary(p => p.Module);
var moduleRows = new List();
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 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();
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();
// Singleton service can't use the scoped IAuditLogger — enqueue directly.
var user = _http.HttpContext?.User;
_logQueue.TryEnqueue(new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelEnum.Warning,
Action = AuditActions.PermissionChanged,
Category = AuditCategories.Security,
EntityName = "Role",
EntityId = roleName,
Summary = $"Permissions updated for role '{roleName}'",
Changes = AuditChangeSerializer.BuildChanges(null, new { Role = roleName, Modules = rows }),
UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier) ?? user?.FindFirstValue("sub"),
UserEmail = user?.FindFirstValue("email"),
IpAddress = _http.HttpContext?.Connection.RemoteIpAddress?.ToString(),
CorrelationId = _http.HttpContext?.TraceIdentifier,
});
}
public void Invalidate() => _cache.Remove(CacheKey);
// ── Internals ────────────────────────────────────────────────────────────
/// roleName → (module → ModuleActions). Cached until invalidated.
private async Task>> GetSnapshotAsync()
{
if (_cache.TryGetValue(CacheKey, out Dictionary>? cached)
&& cached is not null)
{
return cached;
}
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
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>();
foreach (var row in rows)
{
if (!snapshot.TryGetValue(row.RoleName, out var modules))
snapshot[row.RoleName] = modules = new Dictionary();
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 AllModulesFull()
{
var all = new Dictionary();
foreach (var module in Modules.All)
all[module] = new ModuleActions { Read = true, Write = true, Delete = true, Approve = true };
return all;
}
}