Add role control
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user