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; } }