add rbac
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Role CRUD + permission assignment. AuthHex is the source of truth for Role
|
||||
/// identity (docs/10 C.9 "shadow user" pattern, applied to Role): every write is
|
||||
/// forwarded to AuthHex first, then mirrored into the local shadow <c>Role</c> row.
|
||||
/// Permission assignment is purely local (ERPCore/UI concern, not an AuthHex one).
|
||||
/// </summary>
|
||||
public interface IRoleService
|
||||
{
|
||||
Task<PagedResponse<RoleDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<ETagged<RoleDto>?> GetAsync(int roleId, CancellationToken ct = default);
|
||||
Task<ETagged<RoleDto>> CreateAsync(CreateRoleRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<RoleDto>> UpdateAsync(int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default);
|
||||
Task DeleteAsync(int roleId, CancellationToken ct = default);
|
||||
|
||||
Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default);
|
||||
Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolves the nav codes a role (by AuthHex `RoleCode` claim) may see. Used by `GET /auth/me`.</summary>
|
||||
Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Users;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-facing user management: list/create/reassign-role against the local
|
||||
/// shadow `User` table, orchestrating account creation in AuthHex too (see
|
||||
/// <see cref="CreateUserRequest"/>).
|
||||
/// </summary>
|
||||
public interface IUserManagementService
|
||||
{
|
||||
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class RoleService : IRoleService
|
||||
{
|
||||
private readonly IRepository<Role> _roles;
|
||||
private readonly IRepository<RolePermission> _rolePermissions;
|
||||
private readonly IRepository<Permission> _permissions;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public RoleService(
|
||||
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
|
||||
IAuthHexClient authHex, IUnitOfWork uow)
|
||||
{
|
||||
_roles = roles;
|
||||
_rolePermissions = rolePermissions;
|
||||
_permissions = permissions;
|
||||
_authHex = authHex;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<RoleDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _roles.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(r => EF.Functions.ILike(r.Code, $"%{term}%") || EF.Functions.ILike(r.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(r => r.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(r => r.Code)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<RoleDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<RoleDto>?> GetAsync(int roleId, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.Query().AsNoTracking().FirstOrDefaultAsync(r => r.RoleId == roleId, ct);
|
||||
return role is null ? null : new ETagged<RoleDto>(Map(role), role.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<RoleDto>> CreateAsync(CreateRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var code = request.Code.Trim();
|
||||
if (await _roles.Query().AnyAsync(r => r.Code == code, ct))
|
||||
throw new ConflictException($"A role with code '{code}' already exists.");
|
||||
|
||||
var authRole = await _authHex.CreateRoleAsync(
|
||||
new CreateAuthHexRoleRequest { Code = code, Name = request.Name.Trim() }, ct);
|
||||
|
||||
var role = new Role
|
||||
{
|
||||
AuthRoleId = authRole.RoleId,
|
||||
Code = code,
|
||||
Name = request.Name.Trim(),
|
||||
IsSystemRole = authRole.IsSystemRole ?? false,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _roles.AddAsync(role, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<RoleDto>(Map(role), role.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<RoleDto>> UpdateAsync(
|
||||
int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
if (role.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412);
|
||||
|
||||
var code = request.Code.Trim();
|
||||
if (!string.Equals(role.Code, code, StringComparison.Ordinal)
|
||||
&& await _roles.Query().AnyAsync(r => r.Code == code && r.RoleId != roleId, ct))
|
||||
throw new ConflictException($"A role with code '{code}' already exists.");
|
||||
|
||||
await _authHex.UpdateRoleAsync(
|
||||
new UpdateAuthHexRoleRequest { RoleId = role.AuthRoleId, Code = code, Name = request.Name.Trim() }, ct);
|
||||
|
||||
role.Code = code;
|
||||
role.Name = request.Name.Trim();
|
||||
role.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return new ETagged<RoleDto>(Map(role), role.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
role.Status = status;
|
||||
role.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int roleId, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
|
||||
}
|
||||
catch (DomainException ex) when (ex.Message.Contains("ROLE_IN_USE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new DomainException(ErrorCodes.RoleInUse, "This role is assigned to one or more users.", 409);
|
||||
}
|
||||
|
||||
_roles.Remove(role);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default)
|
||||
{
|
||||
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
var granted = await _rolePermissions.Query().AsNoTracking()
|
||||
.Where(rp => rp.RoleId == roleId)
|
||||
.Include(rp => rp.Permission)
|
||||
.Select(rp => rp.Permission!)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new RolePermissionsDto(
|
||||
roleId,
|
||||
granted.Where(p => p.NavItemId is not null).Select(p => p.NavItemId!.Value).ToList(),
|
||||
granted.Where(p => p.SubNavItemId is not null).Select(p => p.SubNavItemId!.Value).ToList());
|
||||
}
|
||||
|
||||
public async Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
var existing = await _rolePermissions.Query().Where(rp => rp.RoleId == roleId).ToListAsync(ct);
|
||||
foreach (var rp in existing) _rolePermissions.Remove(rp);
|
||||
|
||||
var navIds = request.NavItemIds.Distinct().ToList();
|
||||
var subNavIds = request.SubNavItemIds.Distinct().ToList();
|
||||
|
||||
var permissionIds = await _permissions.Query().AsNoTracking()
|
||||
.Where(p => (p.NavItemId != null && navIds.Contains(p.NavItemId.Value))
|
||||
|| (p.SubNavItemId != null && subNavIds.Contains(p.SubNavItemId.Value)))
|
||||
.Select(p => p.PermissionId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var permissionId in permissionIds)
|
||||
await _rolePermissions.AddAsync(new RolePermission { RoleId = roleId, PermissionId = permissionId }, ct);
|
||||
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return await GetPermissionsAsync(roleId, ct);
|
||||
}
|
||||
|
||||
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roleCode))
|
||||
return new MeResponseDto(null, null, Array.Empty<string>());
|
||||
|
||||
var role = await _roles.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Code == roleCode, ct);
|
||||
if (role is null)
|
||||
return new MeResponseDto(roleCode, null, Array.Empty<string>());
|
||||
|
||||
var permissions = await _rolePermissions.Query().AsNoTracking()
|
||||
.Where(rp => rp.RoleId == role.RoleId)
|
||||
.Include(rp => rp.Permission!).ThenInclude(p => p.NavItem)
|
||||
.Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem)
|
||||
.Select(rp => rp.Permission!)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var navCodes = permissions
|
||||
.Select(p => p.NavItem?.Code ?? p.SubNavItem?.Code)
|
||||
.Where(code => code is not null)
|
||||
.Select(code => code!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return new MeResponseDto(role.Code, role.Name, navCodes);
|
||||
}
|
||||
|
||||
private static RoleDto Map(Role r) => new(
|
||||
r.RoleId, r.Code, r.Name, r.IsSystemRole, r.Status, r.CreatedAt, r.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class UserManagementService : IUserManagementService
|
||||
{
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<Role> _roles;
|
||||
private readonly IAuthUserService _authUsers;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public UserManagementService(
|
||||
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex, IUnitOfWork uow)
|
||||
{
|
||||
_users = users;
|
||||
_roles = roles;
|
||||
_authUsers = authUsers;
|
||||
_authHex = authHex;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(u => u.Username)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ManagedUserDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.Query().AsNoTracking().Include(u => u.Role)
|
||||
.FirstOrDefaultAsync(u => u.UserId == userId, ct);
|
||||
return user is null ? null : Map(user);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(request.RoleId, ct)
|
||||
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
|
||||
|
||||
var username = request.Username.Trim();
|
||||
if (await _users.Query().AnyAsync(u => u.Username == username, ct))
|
||||
throw new ConflictException($"A user with username '{username}' already exists.");
|
||||
|
||||
var authUserId = Guid.NewGuid();
|
||||
|
||||
// Source of truth: AuthHex creates the credential + emails it (registerUser,
|
||||
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs).
|
||||
await _authUsers.RegisterAsync(new RegisterRequest
|
||||
{
|
||||
UserId = authUserId,
|
||||
RoleId = role.AuthRoleId,
|
||||
UserTypeId = request.UserTypeId,
|
||||
Fullname = request.FullName.Trim(),
|
||||
UserName = username,
|
||||
Nic = request.Nic,
|
||||
Email = request.Email.Trim(),
|
||||
MobileNumber = request.MobileNumber,
|
||||
Password = request.Password,
|
||||
ChkUser = true
|
||||
}, ct);
|
||||
|
||||
// Mirror into the local shadow User row immediately, rather than waiting
|
||||
// for ShadowUserClaimsTransformation's next-login JIT provisioning.
|
||||
var user = new User
|
||||
{
|
||||
AuthUserId = authUserId,
|
||||
Username = username,
|
||||
DisplayName = request.FullName.Trim(),
|
||||
RoleId = role.RoleId,
|
||||
Status = EntityStatus.Active
|
||||
};
|
||||
|
||||
await _users.AddAsync(user, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
user.Role = role;
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.GetByIdAsync(userId, ct)
|
||||
?? throw new NotFoundException($"User {userId} was not found.");
|
||||
var role = await _roles.GetByIdAsync(request.RoleId, ct)
|
||||
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
|
||||
|
||||
user.RoleId = role.RoleId;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
user.Role = role;
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
||||
return userTypes.Select(t => new UserTypeOptionDto(t.UserTypeId, t.Code, t.Description)).ToList();
|
||||
}
|
||||
|
||||
private static ManagedUserDto Map(User u) => new(
|
||||
u.UserId, u.Username, u.DisplayName, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name);
|
||||
}
|
||||
Reference in New Issue
Block a user