Add role control

This commit is contained in:
Chris Chen
2026-06-23 07:19:08 -07:00
parent deff2264a6
commit 870eeec82a
45 changed files with 1923 additions and 165 deletions
+18 -10
View File
@@ -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),
};
}
+8
View File
@@ -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);
}
+236
View File
@@ -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;
}
}