feat(backend): Implement Day 6 Role Management API

Add complete role management functionality for tenant administrators to manage user roles within their tenants.

Changes:
- Extended IUserTenantRoleRepository with pagination, role counting, and last owner check methods
- Extended IUserRepository with GetByIdAsync(Guid) and GetByIdsAsync for flexible user retrieval
- Extended IRefreshTokenRepository with GetByUserAndTenantAsync and UpdateRangeAsync
- Implemented repository methods in Infrastructure layer
- Created DTOs: UserWithRoleDto and PagedResultDto<T>
- Implemented ListTenantUsersQuery with pagination support
- Implemented AssignUserRoleCommand to assign/update user roles
- Implemented RemoveUserFromTenantCommand with token revocation
- Created TenantUsersController with 4 endpoints (list, assign, remove, get-roles)
- Added comprehensive PowerShell test script

Security Features:
- Only TenantOwner can assign/update/remove roles
- Prevents removal of last TenantOwner (lockout protection)
- Prevents manual assignment of AIAgent role (reserved for MCP)
- Cross-tenant access protection
- Automatic refresh token revocation when user removed

API Endpoints:
- GET /api/tenants/{id}/users - List users with roles (paginated)
- POST /api/tenants/{id}/users/{userId}/role - Assign/update role
- DELETE /api/tenants/{id}/users/{userId} - Remove user from tenant
- GET /api/tenants/roles - Get available roles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-03 19:11:51 +01:00
parent e604b762ff
commit cbc040621f
16 changed files with 664 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
public record AssignUserRoleCommand(
Guid TenantId,
Guid UserId,
string Role) : IRequest<Unit>;

View File

@@ -0,0 +1,70 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleCommand, Unit>
{
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly IUserRepository _userRepository;
private readonly ITenantRepository _tenantRepository;
public AssignUserRoleCommandHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IUserRepository userRepository,
ITenantRepository tenantRepository)
{
_userTenantRoleRepository = userTenantRoleRepository;
_userRepository = userRepository;
_tenantRepository = tenantRepository;
}
public async Task<Unit> Handle(AssignUserRoleCommand request, CancellationToken cancellationToken)
{
// Validate user exists
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
throw new InvalidOperationException("User not found");
// Validate tenant exists
var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(request.TenantId), cancellationToken);
if (tenant == null)
throw new InvalidOperationException("Tenant not found");
// Parse and validate role
if (!Enum.TryParse<TenantRole>(request.Role, out var role))
throw new ArgumentException($"Invalid role: {request.Role}");
// Prevent manual assignment of AIAgent role
if (role == TenantRole.AIAgent)
throw new InvalidOperationException("AIAgent role cannot be assigned manually");
// Check if user already has a role in this tenant
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
request.UserId,
request.TenantId,
cancellationToken);
if (existingRole != null)
{
// Update existing role
existingRole.UpdateRole(role, Guid.Empty); // OperatorUserId can be set from HttpContext in controller
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
}
else
{
// Create new role assignment
var userTenantRole = UserTenantRole.Create(
UserId.Create(request.UserId),
TenantId.Create(request.TenantId),
role,
null); // AssignedByUserId can be set from HttpContext in controller
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
}
return Unit.Value;
}
}

View File

@@ -0,0 +1,7 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
public record RemoveUserFromTenantCommand(
Guid TenantId,
Guid UserId) : IRequest<Unit>;

View File

@@ -0,0 +1,58 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFromTenantCommand, Unit>
{
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
public RemoveUserFromTenantCommandHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IRefreshTokenRepository refreshTokenRepository)
{
_userTenantRoleRepository = userTenantRoleRepository;
_refreshTokenRepository = refreshTokenRepository;
}
public async Task<Unit> Handle(RemoveUserFromTenantCommand request, CancellationToken cancellationToken)
{
// Get user's role in tenant
var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
request.UserId,
request.TenantId,
cancellationToken);
if (userTenantRole == null)
throw new InvalidOperationException("User is not a member of this tenant");
// Check if this is the last TenantOwner
if (await _userTenantRoleRepository.IsLastTenantOwnerAsync(request.TenantId, request.UserId, cancellationToken))
{
throw new InvalidOperationException("Cannot remove the last TenantOwner from the tenant");
}
// Revoke all user's refresh tokens for this tenant
var userTokens = await _refreshTokenRepository.GetByUserAndTenantAsync(
request.UserId,
request.TenantId,
cancellationToken);
foreach (var token in userTokens.Where(t => !t.RevokedAt.HasValue))
{
token.Revoke("User removed from tenant");
}
if (userTokens.Any())
{
await _refreshTokenRepository.UpdateRangeAsync(userTokens, cancellationToken);
}
// Remove user's role
await _userTenantRoleRepository.DeleteAsync(userTenantRole, cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,8 @@
namespace ColaFlow.Modules.Identity.Application.Dtos;
public record PagedResultDto<T>(
List<T> Items,
int TotalCount,
int PageNumber,
int PageSize,
int TotalPages);

View File

@@ -0,0 +1,9 @@
namespace ColaFlow.Modules.Identity.Application.Dtos;
public record UserWithRoleDto(
Guid UserId,
string Email,
string FullName,
string Role,
DateTime AssignedAt,
bool EmailVerified);

View File

@@ -0,0 +1,10 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Queries.ListTenantUsers;
public record ListTenantUsersQuery(
Guid TenantId,
int PageNumber = 1,
int PageSize = 20,
string? SearchTerm = null) : IRequest<PagedResultDto<UserWithRoleDto>>;

View File

@@ -0,0 +1,58 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Queries.ListTenantUsers;
public class ListTenantUsersQueryHandler : IRequestHandler<ListTenantUsersQuery, PagedResultDto<UserWithRoleDto>>
{
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly IUserRepository _userRepository;
public ListTenantUsersQueryHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IUserRepository userRepository)
{
_userTenantRoleRepository = userTenantRoleRepository;
_userRepository = userRepository;
}
public async Task<PagedResultDto<UserWithRoleDto>> Handle(
ListTenantUsersQuery request,
CancellationToken cancellationToken)
{
var (roles, totalCount) = await _userTenantRoleRepository.GetTenantUsersWithRolesAsync(
request.TenantId,
request.PageNumber,
request.PageSize,
request.SearchTerm,
cancellationToken);
var userDtos = new List<UserWithRoleDto>();
foreach (var role in roles)
{
var user = await _userRepository.GetByIdAsync(role.UserId, cancellationToken);
if (user != null)
{
userDtos.Add(new UserWithRoleDto(
user.Id,
user.Email.Value,
user.FullName.Value,
role.Role.ToString(),
role.AssignedAt,
user.EmailVerifiedAt.HasValue));
}
}
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
return new PagedResultDto<UserWithRoleDto>(
userDtos,
totalCount,
request.PageNumber,
request.PageSize,
totalPages);
}
}