Implemented all 3 critical fixes identified in Day 6 Architecture Gap Analysis:
**Fix 1: UpdateUserRole Feature (RESTful PUT endpoint)**
- Created UpdateUserRoleCommand and UpdateUserRoleCommandHandler
- Added PUT /api/tenants/{tenantId}/users/{userId}/role endpoint
- Implements self-demotion prevention (cannot demote self from TenantOwner)
- Implements last owner protection (cannot remove last TenantOwner)
- Returns UserWithRoleDto with updated role information
- Follows RESTful best practices (PUT for updates)
**Fix 2: Last TenantOwner Deletion Prevention (Security)**
- Verified CountByTenantAndRoleAsync repository method exists
- Verified IsLastTenantOwnerAsync validation in RemoveUserFromTenantCommandHandler
- UpdateUserRoleCommandHandler now prevents:
* Self-demotion from TenantOwner role
* Removing the last TenantOwner from tenant
- SECURITY: Prevents tenant from becoming ownerless (critical vulnerability fix)
**Fix 3: Database-Backed Rate Limiting (Security & Reliability)**
- Created EmailRateLimit entity with proper domain logic
- Added EmailRateLimitConfiguration for EF Core
- Implemented DatabaseEmailRateLimiter service (replaces MemoryRateLimitService)
- Updated DependencyInjection to use database-backed implementation
- Created database migration: AddEmailRateLimitsTable
- Added composite unique index on (email, tenant_id, operation_type)
- SECURITY: Rate limit state persists across server restarts (prevents email bombing)
- Implements cleanup logic for expired rate limit records
**Testing:**
- Added 9 comprehensive integration tests in Day8GapFixesTests.cs
- Fix 1: 3 tests (valid update, self-demote prevention, idempotency)
- Fix 2: 3 tests (remove last owner fails, update last owner fails, remove 2nd-to-last succeeds)
- Fix 3: 3 tests (persists across requests, expiry after window, prevents bulk emails)
- 6 tests passing, 3 skipped (long-running/environment-specific tests)
**Files Changed:**
- 6 new files created
- 6 existing files modified
- 1 database migration added
- All existing tests still pass (no regressions)
**Verification:**
- Build succeeds with no errors
- All critical business logic tests pass
- Database migration generated successfully
- Security vulnerabilities addressed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
166 lines
6.4 KiB
C#
166 lines
6.4 KiB
C#
using ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
|
|
using ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
|
|
using ColaFlow.Modules.Identity.Application.Commands.UpdateUserRole;
|
|
using ColaFlow.Modules.Identity.Application.Queries.ListTenantUsers;
|
|
using ColaFlow.Modules.Identity.Application.Dtos;
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace ColaFlow.API.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/tenants/{tenantId}/users")]
|
|
[Authorize]
|
|
public class TenantUsersController(IMediator mediator) : ControllerBase
|
|
{
|
|
/// <summary>
|
|
/// List all users in a tenant with their roles
|
|
/// </summary>
|
|
[HttpGet]
|
|
[Authorize(Policy = "RequireTenantAdmin")]
|
|
public async Task<IActionResult> ListUsers(
|
|
[FromRoute] Guid tenantId,
|
|
[FromQuery] int pageNumber = 1,
|
|
[FromQuery] int pageSize = 20,
|
|
[FromQuery] string? search = null)
|
|
{
|
|
// SECURITY: Validate user belongs to target tenant
|
|
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
|
|
if (userTenantIdClaim == null)
|
|
return Unauthorized(new { error = "Tenant information not found in token" });
|
|
|
|
var userTenantId = Guid.Parse(userTenantIdClaim);
|
|
if (userTenantId != tenantId)
|
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
|
|
|
var query = new ListTenantUsersQuery(tenantId, pageNumber, pageSize, search);
|
|
var result = await mediator.Send(query);
|
|
return Ok(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assign or update a user's role in the tenant
|
|
/// </summary>
|
|
[HttpPost("{userId}/role")]
|
|
[Authorize(Policy = "RequireTenantOwner")]
|
|
public async Task<IActionResult> AssignRole(
|
|
[FromRoute] Guid tenantId,
|
|
[FromRoute] Guid userId,
|
|
[FromBody] AssignRoleRequest request)
|
|
{
|
|
// SECURITY: Validate user belongs to target tenant
|
|
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
|
|
if (userTenantIdClaim == null)
|
|
return Unauthorized(new { error = "Tenant information not found in token" });
|
|
|
|
var userTenantId = Guid.Parse(userTenantIdClaim);
|
|
if (userTenantId != tenantId)
|
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
|
|
|
// Extract current user ID from claims
|
|
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
|
if (currentUserIdClaim == null)
|
|
return Unauthorized(new { error = "User ID not found in token" });
|
|
|
|
var currentUserId = Guid.Parse(currentUserIdClaim);
|
|
|
|
var command = new AssignUserRoleCommand(tenantId, userId, request.Role, currentUserId);
|
|
await mediator.Send(command);
|
|
return Ok(new { Message = "Role assigned successfully" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update an existing user's role in the tenant (RESTful PUT endpoint)
|
|
/// </summary>
|
|
[HttpPut("{userId:guid}/role")]
|
|
[Authorize(Policy = "RequireTenantOwner")]
|
|
public async Task<ActionResult<UserWithRoleDto>> UpdateRole(
|
|
[FromRoute] Guid tenantId,
|
|
[FromRoute] Guid userId,
|
|
[FromBody] AssignRoleRequest request)
|
|
{
|
|
// SECURITY: Validate user belongs to target tenant
|
|
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
|
|
if (userTenantIdClaim == null)
|
|
return Unauthorized(new { error = "Tenant information not found in token" });
|
|
|
|
var userTenantId = Guid.Parse(userTenantIdClaim);
|
|
if (userTenantId != tenantId)
|
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
|
|
|
// Extract current user ID from claims
|
|
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
|
if (currentUserIdClaim == null)
|
|
return Unauthorized(new { error = "User ID not found in token" });
|
|
|
|
var currentUserId = Guid.Parse(currentUserIdClaim);
|
|
|
|
try
|
|
{
|
|
var command = new UpdateUserRoleCommand(tenantId, userId, request.Role, currentUserId);
|
|
var result = await mediator.Send(command);
|
|
return Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message });
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove a user from the tenant
|
|
/// </summary>
|
|
[HttpDelete("{userId}")]
|
|
[Authorize(Policy = "RequireTenantOwner")]
|
|
public async Task<IActionResult> RemoveUser(
|
|
[FromRoute] Guid tenantId,
|
|
[FromRoute] Guid userId)
|
|
{
|
|
// SECURITY: Validate user belongs to target tenant
|
|
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
|
|
if (userTenantIdClaim == null)
|
|
return Unauthorized(new { error = "Tenant information not found in token" });
|
|
|
|
var userTenantId = Guid.Parse(userTenantIdClaim);
|
|
if (userTenantId != tenantId)
|
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
|
|
|
// Extract current user ID from claims
|
|
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
|
if (currentUserIdClaim == null)
|
|
return Unauthorized(new { error = "User ID not found in token" });
|
|
|
|
var currentUserId = Guid.Parse(currentUserIdClaim);
|
|
|
|
var command = new RemoveUserFromTenantCommand(tenantId, userId, currentUserId, null);
|
|
await mediator.Send(command);
|
|
return Ok(new { Message = "User removed from tenant successfully" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get available roles (Note: This endpoint doesn't use tenantId from route, so tenant validation is skipped.
|
|
/// It only returns static role definitions, not tenant-specific data.)
|
|
/// </summary>
|
|
[HttpGet("../roles")]
|
|
[Authorize(Policy = "RequireTenantAdmin")]
|
|
public IActionResult GetAvailableRoles()
|
|
{
|
|
var roles = new[]
|
|
{
|
|
new { Name = "TenantOwner", Description = "Full control over the tenant" },
|
|
new { Name = "TenantAdmin", Description = "Manage users and projects" },
|
|
new { Name = "TenantMember", Description = "Create and edit tasks" },
|
|
new { Name = "TenantGuest", Description = "Read-only access" }
|
|
};
|
|
|
|
return Ok(roles);
|
|
}
|
|
}
|
|
|
|
public record AssignRoleRequest(string Role);
|