2709 lines
83 KiB
Markdown
2709 lines
83 KiB
Markdown
# Day 6 Architecture Design: Role Management API + Email Verification
|
|
|
|
**Date**: 2025-11-03
|
|
**Author**: System Architect
|
|
**Status**: Ready for Implementation
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
This document provides comprehensive technical architecture for **Day 6 development**, building upon the successful Day 5 implementation (Refresh Token + RBAC + Integration Tests). Day 6 focuses on two key feature areas:
|
|
|
|
1. **Role Management API** (Priority 1) - Enable tenant owners to manage user roles
|
|
2. **Email Verification** (Priority 2) - Complete email verification flow with anti-abuse mechanisms
|
|
|
|
Both features are designed with **MCP integration** in mind, following Clean Architecture principles and maintaining backward compatibility with existing Day 5 implementation.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [1. Day 5 Recap: What's Already Built](#1-day-5-recap-whats-already-built)
|
|
- [2. Scenario A: Role Management API](#2-scenario-a-role-management-api)
|
|
- [3. Scenario B: Email Verification](#3-scenario-b-email-verification)
|
|
- [4. Scenario C: Combined Implementation](#4-scenario-c-combined-implementation)
|
|
- [5. Implementation Roadmap](#5-implementation-roadmap)
|
|
- [6. Risk Assessment](#6-risk-assessment)
|
|
- [7. Testing Strategy](#7-testing-strategy)
|
|
- [8. MCP Integration Considerations](#8-mcp-integration-considerations)
|
|
|
|
---
|
|
|
|
## 1. Day 5 Recap: What's Already Built
|
|
|
|
### 1.1 Existing Infrastructure
|
|
|
|
Day 5 successfully implemented:
|
|
|
|
✅ **Refresh Token Mechanism**
|
|
- `RefreshToken` entity with token family tracking
|
|
- `RefreshTokenService` with rotation and revocation
|
|
- `/api/auth/refresh`, `/api/auth/logout`, `/api/auth/logout-all` endpoints
|
|
|
|
✅ **RBAC System**
|
|
- 5 tenant-level roles: `TenantOwner`, `TenantAdmin`, `TenantMember`, `TenantGuest`, `AIAgent`
|
|
- `UserTenantRole` entity with role assignment tracking
|
|
- JWT claims include `tenant_role` for authorization
|
|
- Authorization policies configured
|
|
|
|
✅ **Integration Testing**
|
|
- 31 tests, 100% pass rate
|
|
- Test infrastructure for auth flows
|
|
|
|
### 1.2 Existing Database Schema
|
|
|
|
**Already in database**:
|
|
```sql
|
|
-- identity.users (with email verification fields)
|
|
CREATE TABLE identity.users (
|
|
id UUID PRIMARY KEY,
|
|
tenant_id UUID NOT NULL,
|
|
email VARCHAR(255) NOT NULL,
|
|
password_hash VARCHAR(255),
|
|
full_name VARCHAR(255) NOT NULL,
|
|
status VARCHAR(50) NOT NULL,
|
|
auth_provider VARCHAR(50) NOT NULL,
|
|
email_verified_at TIMESTAMP NULL,
|
|
email_verification_token VARCHAR(500) NULL,
|
|
password_reset_token VARCHAR(500) NULL,
|
|
password_reset_token_expires_at TIMESTAMP NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NULL,
|
|
last_login_at TIMESTAMP NULL
|
|
);
|
|
|
|
-- identity.user_tenant_roles
|
|
CREATE TABLE identity.user_tenant_roles (
|
|
id UUID PRIMARY KEY,
|
|
user_id UUID NOT NULL,
|
|
tenant_id UUID NOT NULL,
|
|
role VARCHAR(50) NOT NULL,
|
|
assigned_at TIMESTAMP NOT NULL,
|
|
assigned_by_user_id UUID NULL,
|
|
CONSTRAINT uq_user_tenant_role UNIQUE (user_id, tenant_id)
|
|
);
|
|
|
|
-- identity.refresh_tokens
|
|
CREATE TABLE identity.refresh_tokens (
|
|
id UUID PRIMARY KEY,
|
|
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
|
user_id UUID NOT NULL,
|
|
tenant_id UUID NOT NULL,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
revoked_at TIMESTAMP NULL,
|
|
token_family UUID NOT NULL
|
|
);
|
|
```
|
|
|
|
### 1.3 What's Missing (Day 6 Goals)
|
|
|
|
❌ **Role Management API**: No endpoints to assign/update/remove roles
|
|
❌ **Email Verification Flow**: Tokens not generated, emails not sent
|
|
❌ **Email Service**: No email provider integration (SendGrid/SMTP)
|
|
❌ **Anti-abuse Mechanisms**: No rate limiting on email operations
|
|
❌ **User Management API**: No endpoints to list/view users
|
|
|
|
---
|
|
|
|
## 2. Scenario A: Role Management API
|
|
|
|
### 2.1 Overview
|
|
|
|
Enable **TenantOwner** to manage user roles within their tenant. This is critical for:
|
|
- Delegating administrative responsibilities
|
|
- Controlling access to sensitive operations
|
|
- Preparing for multi-project role assignments
|
|
|
|
### 2.2 Database Design
|
|
|
|
**No new tables needed** - Day 5 already created `user_tenant_roles` table.
|
|
|
|
**Add index for performance**:
|
|
```sql
|
|
-- Optimize role lookups by tenant
|
|
CREATE INDEX IF NOT EXISTS idx_user_tenant_roles_tenant_role
|
|
ON identity.user_tenant_roles(tenant_id, role);
|
|
```
|
|
|
|
### 2.3 API Design
|
|
|
|
#### 2.3.1 Endpoints
|
|
|
|
| Method | Endpoint | Description | Auth Required |
|
|
|--------|----------|-------------|---------------|
|
|
| GET | `/api/tenants/{tenantId}/users` | List all users in tenant | TenantAdmin+ |
|
|
| GET | `/api/tenants/{tenantId}/users/{userId}` | Get user details | TenantAdmin+ |
|
|
| POST | `/api/tenants/{tenantId}/users/{userId}/role` | Assign role to user | TenantOwner |
|
|
| PUT | `/api/tenants/{tenantId}/users/{userId}/role` | Update user's role | TenantOwner |
|
|
| DELETE | `/api/tenants/{tenantId}/users/{userId}/role` | Remove user from tenant | TenantOwner |
|
|
|
|
#### 2.3.2 DTOs
|
|
|
|
**Request DTOs**:
|
|
|
|
```csharp
|
|
// POST/PUT /api/tenants/{tenantId}/users/{userId}/role
|
|
public record AssignRoleRequest
|
|
{
|
|
[Required]
|
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
|
public TenantRole Role { get; init; }
|
|
}
|
|
|
|
// Query parameters for user listing
|
|
public record ListUsersQuery
|
|
{
|
|
public TenantRole? Role { get; init; }
|
|
public UserStatus? Status { get; init; }
|
|
public int Page { get; init; } = 1;
|
|
public int PageSize { get; init; } = 20;
|
|
public string? SearchTerm { get; init; }
|
|
}
|
|
```
|
|
|
|
**Response DTOs**:
|
|
|
|
```csharp
|
|
public record UserWithRoleDto
|
|
{
|
|
public Guid UserId { get; init; }
|
|
public string Email { get; init; } = string.Empty;
|
|
public string FullName { get; init; } = string.Empty;
|
|
public TenantRole Role { get; init; }
|
|
public UserStatus Status { get; init; }
|
|
public DateTime? LastLoginAt { get; init; }
|
|
public DateTime? EmailVerifiedAt { get; init; }
|
|
public DateTime AssignedAt { get; init; }
|
|
public Guid? AssignedByUserId { get; init; }
|
|
public string? AssignedByUserName { get; init; }
|
|
}
|
|
|
|
public record PagedResult<T>
|
|
{
|
|
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
|
|
public int TotalCount { get; init; }
|
|
public int Page { get; init; }
|
|
public int PageSize { get; init; }
|
|
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
|
}
|
|
```
|
|
|
|
### 2.4 Domain Layer Design
|
|
|
|
**No new entities needed** - Day 5 already has `UserTenantRole`.
|
|
|
|
**Add business validation methods to `UserTenantRole`**:
|
|
|
|
```csharp
|
|
// Add to UserTenantRole.cs
|
|
public static class UserTenantRoleValidator
|
|
{
|
|
public static void ValidateRoleChange(UserTenantRole existingRole, TenantRole newRole, Guid operatorUserId)
|
|
{
|
|
// Rule 1: Cannot remove the last TenantOwner
|
|
if (existingRole.Role == TenantRole.TenantOwner && newRole != TenantRole.TenantOwner)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Cannot remove the last TenantOwner. Assign another TenantOwner first.");
|
|
}
|
|
|
|
// Rule 2: Cannot self-demote from TenantOwner
|
|
if (existingRole.Role == TenantRole.TenantOwner &&
|
|
existingRole.UserId.Value == operatorUserId &&
|
|
newRole != TenantRole.TenantOwner)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Cannot demote yourself from TenantOwner. Have another owner perform this action.");
|
|
}
|
|
|
|
// Rule 3: AIAgent role requires special permission (future)
|
|
if (newRole == TenantRole.AIAgent)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"AIAgent role cannot be assigned manually. Use MCP integration.");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.5 Application Layer Design
|
|
|
|
#### 2.5.1 Commands
|
|
|
|
**File**: `Application/Commands/AssignUserRole/AssignUserRoleCommand.cs`
|
|
|
|
```csharp
|
|
public record AssignUserRoleCommand(
|
|
Guid TenantId,
|
|
Guid UserId,
|
|
TenantRole Role
|
|
) : IRequest<UserWithRoleDto>;
|
|
|
|
public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleCommand, UserWithRoleDto>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IUserTenantRoleRepository _roleRepository;
|
|
private readonly ITenantRepository _tenantRepository;
|
|
private readonly ILogger<AssignUserRoleCommandHandler> _logger;
|
|
|
|
public async Task<UserWithRoleDto> Handle(
|
|
AssignUserRoleCommand request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 1. Validate tenant exists
|
|
var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken);
|
|
if (tenant == null || tenant.Status != TenantStatus.Active)
|
|
throw new NotFoundException($"Tenant {request.TenantId} not found or inactive");
|
|
|
|
// 2. Validate user exists in tenant
|
|
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
|
if (user == null || user.TenantId.Value != request.TenantId)
|
|
throw new NotFoundException($"User {request.UserId} not found in tenant");
|
|
|
|
if (user.Status != UserStatus.Active)
|
|
throw new InvalidOperationException("Cannot assign role to inactive user");
|
|
|
|
// 3. Check if role already assigned
|
|
var existingRole = await _roleRepository.GetByUserAndTenantAsync(
|
|
request.UserId,
|
|
request.TenantId,
|
|
cancellationToken);
|
|
|
|
if (existingRole != null)
|
|
throw new InvalidOperationException(
|
|
$"User already has role {existingRole.Role}. Use update endpoint instead.");
|
|
|
|
// 4. Validate AIAgent role restriction
|
|
if (request.Role == TenantRole.AIAgent)
|
|
throw new InvalidOperationException("AIAgent role cannot be assigned manually");
|
|
|
|
// 5. Create role assignment
|
|
var role = UserTenantRole.Create(
|
|
UserId.From(request.UserId),
|
|
TenantId.From(request.TenantId),
|
|
request.Role,
|
|
assignedByUserId: null // Set from HTTP context in controller
|
|
);
|
|
|
|
await _roleRepository.AddAsync(role, cancellationToken);
|
|
|
|
_logger.LogInformation(
|
|
"Assigned role {Role} to user {UserId} in tenant {TenantId}",
|
|
request.Role, request.UserId, request.TenantId);
|
|
|
|
// 6. Return DTO
|
|
return new UserWithRoleDto
|
|
{
|
|
UserId = user.Id,
|
|
Email = user.Email.Value,
|
|
FullName = user.FullName.Value,
|
|
Role = role.Role,
|
|
Status = user.Status,
|
|
LastLoginAt = user.LastLoginAt,
|
|
EmailVerifiedAt = user.EmailVerifiedAt,
|
|
AssignedAt = role.AssignedAt
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**File**: `Application/Commands/UpdateUserRole/UpdateUserRoleCommand.cs`
|
|
|
|
```csharp
|
|
public record UpdateUserRoleCommand(
|
|
Guid TenantId,
|
|
Guid UserId,
|
|
TenantRole NewRole,
|
|
Guid OperatorUserId
|
|
) : IRequest<UserWithRoleDto>;
|
|
|
|
public class UpdateUserRoleCommandHandler : IRequestHandler<UpdateUserRoleCommand, UserWithRoleDto>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IUserTenantRoleRepository _roleRepository;
|
|
private readonly ILogger<UpdateUserRoleCommandHandler> _logger;
|
|
|
|
public async Task<UserWithRoleDto> Handle(
|
|
UpdateUserRoleCommand request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 1. Get existing role
|
|
var existingRole = await _roleRepository.GetByUserAndTenantAsync(
|
|
request.UserId,
|
|
request.TenantId,
|
|
cancellationToken);
|
|
|
|
if (existingRole == null)
|
|
throw new NotFoundException("User role not found. Use assign endpoint to create.");
|
|
|
|
// 2. Validate role change
|
|
await ValidateRoleChangeAsync(
|
|
existingRole,
|
|
request.NewRole,
|
|
request.OperatorUserId,
|
|
request.TenantId,
|
|
cancellationToken);
|
|
|
|
// 3. Update role
|
|
existingRole.UpdateRole(request.NewRole, request.OperatorUserId);
|
|
await _roleRepository.UpdateAsync(existingRole, cancellationToken);
|
|
|
|
_logger.LogInformation(
|
|
"Updated role for user {UserId} in tenant {TenantId} from {OldRole} to {NewRole}",
|
|
request.UserId, request.TenantId, existingRole.Role, request.NewRole);
|
|
|
|
// 4. Load user for DTO
|
|
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
|
|
|
return new UserWithRoleDto
|
|
{
|
|
UserId = user!.Id,
|
|
Email = user.Email.Value,
|
|
FullName = user.FullName.Value,
|
|
Role = existingRole.Role,
|
|
Status = user.Status,
|
|
LastLoginAt = user.LastLoginAt,
|
|
EmailVerifiedAt = user.EmailVerifiedAt,
|
|
AssignedAt = existingRole.AssignedAt,
|
|
AssignedByUserId = existingRole.AssignedByUserId
|
|
};
|
|
}
|
|
|
|
private async Task ValidateRoleChangeAsync(
|
|
UserTenantRole existingRole,
|
|
TenantRole newRole,
|
|
Guid operatorUserId,
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Rule 1: Cannot self-demote from TenantOwner
|
|
if (existingRole.Role == TenantRole.TenantOwner &&
|
|
existingRole.UserId.Value == operatorUserId &&
|
|
newRole != TenantRole.TenantOwner)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Cannot demote yourself from TenantOwner");
|
|
}
|
|
|
|
// Rule 2: Cannot remove last TenantOwner
|
|
if (existingRole.Role == TenantRole.TenantOwner && newRole != TenantRole.TenantOwner)
|
|
{
|
|
var ownerCount = await _roleRepository.CountByTenantAndRoleAsync(
|
|
tenantId,
|
|
TenantRole.TenantOwner,
|
|
cancellationToken);
|
|
|
|
if (ownerCount <= 1)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Cannot remove the last TenantOwner. Assign another owner first.");
|
|
}
|
|
}
|
|
|
|
// Rule 3: AIAgent role restriction
|
|
if (newRole == TenantRole.AIAgent)
|
|
{
|
|
throw new InvalidOperationException("AIAgent role cannot be assigned manually");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**File**: `Application/Commands/RemoveUserFromTenant/RemoveUserFromTenantCommand.cs`
|
|
|
|
```csharp
|
|
public record RemoveUserFromTenantCommand(
|
|
Guid TenantId,
|
|
Guid UserId,
|
|
Guid OperatorUserId
|
|
) : IRequest<bool>;
|
|
|
|
public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFromTenantCommand, bool>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IUserTenantRoleRepository _roleRepository;
|
|
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
|
private readonly ILogger<RemoveUserFromTenantCommandHandler> _logger;
|
|
|
|
public async Task<bool> Handle(
|
|
RemoveUserFromTenantCommand request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 1. Get existing role
|
|
var existingRole = await _roleRepository.GetByUserAndTenantAsync(
|
|
request.UserId,
|
|
request.TenantId,
|
|
cancellationToken);
|
|
|
|
if (existingRole == null)
|
|
throw new NotFoundException("User not found in tenant");
|
|
|
|
// 2. Validate not removing last owner
|
|
if (existingRole.Role == TenantRole.TenantOwner)
|
|
{
|
|
var ownerCount = await _roleRepository.CountByTenantAndRoleAsync(
|
|
request.TenantId,
|
|
TenantRole.TenantOwner,
|
|
cancellationToken);
|
|
|
|
if (ownerCount <= 1)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Cannot remove the last TenantOwner");
|
|
}
|
|
}
|
|
|
|
// 3. Validate not removing self (optional - can be allowed)
|
|
if (request.UserId == request.OperatorUserId)
|
|
{
|
|
throw new InvalidOperationException("Cannot remove yourself from tenant");
|
|
}
|
|
|
|
// 4. Delete role (cascade will handle cleanup)
|
|
await _roleRepository.DeleteAsync(existingRole, cancellationToken);
|
|
|
|
// 5. Revoke all refresh tokens for this user in this tenant
|
|
var tokens = await _refreshTokenRepository.GetByUserAndTenantAsync(
|
|
request.UserId,
|
|
request.TenantId,
|
|
cancellationToken);
|
|
|
|
foreach (var token in tokens.Where(t => !t.RevokedAt.HasValue))
|
|
{
|
|
token.Revoke("User removed from tenant");
|
|
}
|
|
|
|
await _refreshTokenRepository.UpdateRangeAsync(tokens, cancellationToken);
|
|
|
|
// 6. Optionally deactivate user (if they're not in other tenants)
|
|
// For now, just remove role
|
|
|
|
_logger.LogInformation(
|
|
"Removed user {UserId} from tenant {TenantId}",
|
|
request.UserId, request.TenantId);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.5.2 Queries
|
|
|
|
**File**: `Application/Queries/ListTenantUsers/ListTenantUsersQuery.cs`
|
|
|
|
```csharp
|
|
public record ListTenantUsersQuery(
|
|
Guid TenantId,
|
|
TenantRole? Role = null,
|
|
UserStatus? Status = null,
|
|
string? SearchTerm = null,
|
|
int Page = 1,
|
|
int PageSize = 20
|
|
) : IRequest<PagedResult<UserWithRoleDto>>;
|
|
|
|
public class ListTenantUsersQueryHandler : IRequestHandler<ListTenantUsersQuery, PagedResult<UserWithRoleDto>>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IUserTenantRoleRepository _roleRepository;
|
|
|
|
public async Task<PagedResult<UserWithRoleDto>> Handle(
|
|
ListTenantUsersQuery request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 1. Get all roles for tenant
|
|
var roles = await _roleRepository.GetByTenantAsync(
|
|
request.TenantId,
|
|
cancellationToken);
|
|
|
|
// 2. Filter by role if specified
|
|
if (request.Role.HasValue)
|
|
{
|
|
roles = roles.Where(r => r.Role == request.Role.Value).ToList();
|
|
}
|
|
|
|
// 3. Load users for these roles
|
|
var userIds = roles.Select(r => r.UserId.Value).ToList();
|
|
var users = await _userRepository.GetByIdsAsync(userIds, cancellationToken);
|
|
|
|
// 4. Filter by status
|
|
if (request.Status.HasValue)
|
|
{
|
|
users = users.Where(u => u.Status == request.Status.Value).ToList();
|
|
}
|
|
|
|
// 5. Filter by search term
|
|
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
|
|
{
|
|
var searchLower = request.SearchTerm.ToLower();
|
|
users = users.Where(u =>
|
|
u.Email.Value.ToLower().Contains(searchLower) ||
|
|
u.FullName.Value.ToLower().Contains(searchLower)
|
|
).ToList();
|
|
}
|
|
|
|
// 6. Pagination
|
|
var totalCount = users.Count;
|
|
var pagedUsers = users
|
|
.Skip((request.Page - 1) * request.PageSize)
|
|
.Take(request.PageSize)
|
|
.ToList();
|
|
|
|
// 7. Build DTOs
|
|
var userDtos = pagedUsers.Select(user =>
|
|
{
|
|
var role = roles.First(r => r.UserId.Value == user.Id);
|
|
return new UserWithRoleDto
|
|
{
|
|
UserId = user.Id,
|
|
Email = user.Email.Value,
|
|
FullName = user.FullName.Value,
|
|
Role = role.Role,
|
|
Status = user.Status,
|
|
LastLoginAt = user.LastLoginAt,
|
|
EmailVerifiedAt = user.EmailVerifiedAt,
|
|
AssignedAt = role.AssignedAt,
|
|
AssignedByUserId = role.AssignedByUserId
|
|
};
|
|
}).ToList();
|
|
|
|
return new PagedResult<UserWithRoleDto>
|
|
{
|
|
Items = userDtos,
|
|
TotalCount = totalCount,
|
|
Page = request.Page,
|
|
PageSize = request.PageSize
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.6 Infrastructure Layer
|
|
|
|
**Add repository method**: `IUserTenantRoleRepository.cs`
|
|
|
|
```csharp
|
|
// Add to existing interface
|
|
Task<int> CountByTenantAndRoleAsync(
|
|
Guid tenantId,
|
|
TenantRole role,
|
|
CancellationToken cancellationToken = default);
|
|
```
|
|
|
|
**Implementation**: `UserTenantRoleRepository.cs`
|
|
|
|
```csharp
|
|
// Add to existing repository
|
|
public async Task<int> CountByTenantAndRoleAsync(
|
|
Guid tenantId,
|
|
TenantRole role,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await _context.UserTenantRoles
|
|
.CountAsync(r => r.TenantId.Value == tenantId && r.Role == role, cancellationToken);
|
|
}
|
|
```
|
|
|
|
**Add repository method**: `IUserRepository.cs`
|
|
|
|
```csharp
|
|
// Add to existing interface
|
|
Task<IReadOnlyList<User>> GetByIdsAsync(
|
|
IEnumerable<Guid> userIds,
|
|
CancellationToken cancellationToken = default);
|
|
```
|
|
|
|
### 2.7 API Layer
|
|
|
|
**New Controller**: `API/Controllers/TenantUsersController.cs`
|
|
|
|
```csharp
|
|
[ApiController]
|
|
[Route("api/tenants/{tenantId:guid}/users")]
|
|
[Authorize]
|
|
public class TenantUsersController : ControllerBase
|
|
{
|
|
private readonly IMediator _mediator;
|
|
private readonly ILogger<TenantUsersController> _logger;
|
|
|
|
public TenantUsersController(IMediator mediator, ILogger<TenantUsersController> logger)
|
|
{
|
|
_mediator = mediator;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// List all users in tenant
|
|
/// </summary>
|
|
[HttpGet]
|
|
[Authorize(Roles = "TenantOwner,TenantAdmin")]
|
|
[ProducesResponseType(typeof(PagedResult<UserWithRoleDto>), 200)]
|
|
public async Task<ActionResult<PagedResult<UserWithRoleDto>>> ListUsers(
|
|
Guid tenantId,
|
|
[FromQuery] ListUsersQuery query)
|
|
{
|
|
// Validate tenant access
|
|
var userTenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
|
|
if (userTenantId != tenantId)
|
|
return Forbid();
|
|
|
|
var fullQuery = query with { TenantId = tenantId };
|
|
var result = await _mediator.Send(fullQuery);
|
|
|
|
return Ok(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assign role to user (creates new role assignment)
|
|
/// </summary>
|
|
[HttpPost("{userId:guid}/role")]
|
|
[Authorize(Roles = "TenantOwner")]
|
|
[ProducesResponseType(typeof(UserWithRoleDto), 200)]
|
|
[ProducesResponseType(400)]
|
|
[ProducesResponseType(403)]
|
|
[ProducesResponseType(409)]
|
|
public async Task<ActionResult<UserWithRoleDto>> AssignRole(
|
|
Guid tenantId,
|
|
Guid userId,
|
|
[FromBody] AssignRoleRequest request)
|
|
{
|
|
try
|
|
{
|
|
// Validate tenant access
|
|
var userTenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
|
|
if (userTenantId != tenantId)
|
|
return Forbid();
|
|
|
|
var command = new AssignUserRoleCommand(tenantId, userId, request.Role);
|
|
var result = await _mediator.Send(command);
|
|
|
|
return Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to assign role");
|
|
return Conflict(new { message = ex.Message });
|
|
}
|
|
catch (NotFoundException ex)
|
|
{
|
|
return NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update user's role
|
|
/// </summary>
|
|
[HttpPut("{userId:guid}/role")]
|
|
[Authorize(Roles = "TenantOwner")]
|
|
[ProducesResponseType(typeof(UserWithRoleDto), 200)]
|
|
public async Task<ActionResult<UserWithRoleDto>> UpdateRole(
|
|
Guid tenantId,
|
|
Guid userId,
|
|
[FromBody] AssignRoleRequest request)
|
|
{
|
|
try
|
|
{
|
|
var userTenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
|
|
if (userTenantId != tenantId)
|
|
return Forbid();
|
|
|
|
var operatorUserId = Guid.Parse(User.FindFirstValue("user_id")!);
|
|
var command = new UpdateUserRoleCommand(tenantId, userId, request.Role, operatorUserId);
|
|
var result = await _mediator.Send(command);
|
|
|
|
return Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to update role");
|
|
return Conflict(new { message = ex.Message });
|
|
}
|
|
catch (NotFoundException ex)
|
|
{
|
|
return NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove user from tenant (deletes role assignment)
|
|
/// </summary>
|
|
[HttpDelete("{userId:guid}/role")]
|
|
[Authorize(Roles = "TenantOwner")]
|
|
[ProducesResponseType(204)]
|
|
public async Task<IActionResult> RemoveUser(Guid tenantId, Guid userId)
|
|
{
|
|
try
|
|
{
|
|
var userTenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
|
|
if (userTenantId != tenantId)
|
|
return Forbid();
|
|
|
|
var operatorUserId = Guid.Parse(User.FindFirstValue("user_id")!);
|
|
var command = new RemoveUserFromTenantCommand(tenantId, userId, operatorUserId);
|
|
await _mediator.Send(command);
|
|
|
|
return NoContent();
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to remove user");
|
|
return Conflict(new { message = ex.Message });
|
|
}
|
|
catch (NotFoundException ex)
|
|
{
|
|
return NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.8 Security Considerations
|
|
|
|
**Authorization Rules**:
|
|
1. Only `TenantOwner` can assign/update/remove roles
|
|
2. `TenantAdmin` can view user list
|
|
3. Users must be in the same tenant as the target user
|
|
4. Cannot self-demote from `TenantOwner`
|
|
5. Cannot remove last `TenantOwner`
|
|
6. `AIAgent` role cannot be assigned manually (reserved for MCP)
|
|
|
|
**Audit Logging** (future enhancement):
|
|
```csharp
|
|
// Log all role changes to audit table
|
|
public record RoleChangeAuditLog
|
|
{
|
|
public Guid Id { get; init; }
|
|
public Guid TenantId { get; init; }
|
|
public Guid UserId { get; init; }
|
|
public TenantRole OldRole { get; init; }
|
|
public TenantRole NewRole { get; init; }
|
|
public Guid ChangedByUserId { get; init; }
|
|
public DateTime ChangedAt { get; init; }
|
|
public string Reason { get; init; } = string.Empty;
|
|
}
|
|
```
|
|
|
|
### 2.9 Complexity & Time Estimate
|
|
|
|
| Task | Complexity | Time |
|
|
|------|-----------|------|
|
|
| Commands (Assign/Update/Remove) | Medium | 3 hours |
|
|
| Queries (List users) | Low | 1 hour |
|
|
| Repository methods | Low | 1 hour |
|
|
| Controller & DTOs | Low | 1.5 hours |
|
|
| Validation logic | Medium | 1.5 hours |
|
|
| Integration tests | Medium | 2 hours |
|
|
| **Total** | - | **10 hours** |
|
|
|
|
---
|
|
|
|
## 3. Scenario B: Email Verification
|
|
|
|
### 3.1 Overview
|
|
|
|
Complete the email verification flow with:
|
|
- Email verification token generation
|
|
- SendGrid/SMTP integration
|
|
- Verification endpoint
|
|
- Resend verification email
|
|
- Anti-abuse mechanisms (rate limiting)
|
|
|
|
### 3.2 Database Design
|
|
|
|
**Update existing `users` table**:
|
|
|
|
```sql
|
|
-- Add missing column
|
|
ALTER TABLE identity.users
|
|
ADD COLUMN IF NOT EXISTS email_verification_token_expires_at TIMESTAMP NULL;
|
|
|
|
-- Add index for verification token lookup
|
|
CREATE INDEX IF NOT EXISTS idx_users_email_verification_token
|
|
ON identity.users(email_verification_token)
|
|
WHERE email_verification_token IS NOT NULL;
|
|
```
|
|
|
|
**New table for rate limiting** (optional, can use in-memory cache):
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS identity.email_rate_limits (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
email VARCHAR(255) NOT NULL,
|
|
tenant_id UUID NOT NULL,
|
|
operation_type VARCHAR(50) NOT NULL, -- 'verification', 'password_reset'
|
|
last_sent_at TIMESTAMP NOT NULL,
|
|
attempts_count INT NOT NULL DEFAULT 1,
|
|
|
|
CONSTRAINT uq_email_rate_limit UNIQUE (email, tenant_id, operation_type)
|
|
);
|
|
|
|
CREATE INDEX idx_email_rate_limits_email ON identity.email_rate_limits(email, tenant_id);
|
|
CREATE INDEX idx_email_rate_limits_cleanup ON identity.email_rate_limits(last_sent_at);
|
|
```
|
|
|
|
### 3.3 Email Service Design
|
|
|
|
#### 3.3.1 Technology Selection
|
|
|
|
| Provider | Pros | Cons | Recommendation |
|
|
|----------|------|------|----------------|
|
|
| **SendGrid** | Easy setup, 100 emails/day free, good deliverability | Rate limits on free tier | ✅ **Recommended for MVP** |
|
|
| **AWS SES** | Very cheap ($0.10/1000), highly scalable | Complex setup, requires AWS account | Production upgrade |
|
|
| **MailKit (SMTP)** | No external dependency, self-hosted | Requires SMTP server, lower deliverability | Development fallback |
|
|
| **Mailgun** | Developer-friendly | Limited free tier | Alternative |
|
|
|
|
**Decision**: Use **SendGrid** for MVP with **MailKit fallback** for local development.
|
|
|
|
#### 3.3.2 Interface Design
|
|
|
|
**File**: `Application/Services/IEmailService.cs`
|
|
|
|
```csharp
|
|
public interface IEmailService
|
|
{
|
|
/// <summary>
|
|
/// Send email verification email
|
|
/// </summary>
|
|
Task SendEmailVerificationAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string verificationToken,
|
|
string tenantSlug,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Send password reset email
|
|
/// </summary>
|
|
Task SendPasswordResetAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string resetToken,
|
|
string tenantSlug,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Send welcome email after verification
|
|
/// </summary>
|
|
Task SendWelcomeEmailAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string tenantName,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
```
|
|
|
|
#### 3.3.3 SendGrid Implementation
|
|
|
|
**File**: `Infrastructure/Services/SendGridEmailService.cs`
|
|
|
|
```csharp
|
|
public class SendGridEmailService : IEmailService
|
|
{
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<SendGridEmailService> _logger;
|
|
private readonly SendGridClient _client;
|
|
|
|
public SendGridEmailService(
|
|
IConfiguration configuration,
|
|
ILogger<SendGridEmailService> logger)
|
|
{
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
|
|
var apiKey = _configuration["SendGrid:ApiKey"];
|
|
if (string.IsNullOrEmpty(apiKey))
|
|
{
|
|
_logger.LogWarning("SendGrid API key not configured");
|
|
throw new InvalidOperationException("SendGrid API key not configured");
|
|
}
|
|
|
|
_client = new SendGridClient(apiKey);
|
|
}
|
|
|
|
public async Task SendEmailVerificationAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string verificationToken,
|
|
string tenantSlug,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var from = new EmailAddress(
|
|
_configuration["SendGrid:FromEmail"] ?? "noreply@colaflow.com",
|
|
"ColaFlow");
|
|
|
|
var to = new EmailAddress(recipientEmail, recipientName);
|
|
|
|
var verificationUrl = BuildVerificationUrl(verificationToken, tenantSlug);
|
|
|
|
var subject = "Verify your ColaFlow email address";
|
|
var plainTextContent = $@"
|
|
Hello {recipientName},
|
|
|
|
Please verify your email address by clicking the link below:
|
|
|
|
{verificationUrl}
|
|
|
|
This link expires in 24 hours.
|
|
|
|
If you didn't create this account, please ignore this email.
|
|
|
|
Best regards,
|
|
ColaFlow Team
|
|
";
|
|
|
|
var htmlContent = $@"
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background-color: #4CAF50; color: white; padding: 20px; text-align: center; }}
|
|
.content {{ padding: 20px; background-color: #f9f9f9; }}
|
|
.button {{ display: inline-block; background-color: #4CAF50; color: white;
|
|
padding: 12px 24px; text-decoration: none; border-radius: 4px;
|
|
font-weight: bold; }}
|
|
.footer {{ text-align: center; padding: 20px; color: #666; font-size: 12px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class=""container"">
|
|
<div class=""header"">
|
|
<h1>Welcome to ColaFlow!</h1>
|
|
</div>
|
|
<div class=""content"">
|
|
<p>Hello {recipientName},</p>
|
|
<p>Thank you for registering with ColaFlow. Please verify your email address to complete your registration.</p>
|
|
<p style=""text-align: center; margin: 30px 0;"">
|
|
<a href=""{verificationUrl}"" class=""button"">Verify Email Address</a>
|
|
</p>
|
|
<p>Or copy and paste this link into your browser:</p>
|
|
<p style=""word-break: break-all; color: #666;"">{verificationUrl}</p>
|
|
<p><strong>This link expires in 24 hours.</strong></p>
|
|
<p>If you didn't create this account, please ignore this email.</p>
|
|
</div>
|
|
<div class=""footer"">
|
|
<p>© 2025 ColaFlow. All rights reserved.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
";
|
|
|
|
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
|
|
|
|
var response = await _client.SendEmailAsync(msg, cancellationToken);
|
|
|
|
if (response.StatusCode != System.Net.HttpStatusCode.OK &&
|
|
response.StatusCode != System.Net.HttpStatusCode.Accepted)
|
|
{
|
|
_logger.LogError(
|
|
"Failed to send verification email to {Email}, status: {Status}",
|
|
recipientEmail, response.StatusCode);
|
|
|
|
throw new InvalidOperationException($"Failed to send verification email: {response.StatusCode}");
|
|
}
|
|
|
|
_logger.LogInformation("Sent verification email to {Email}", recipientEmail);
|
|
}
|
|
|
|
public async Task SendPasswordResetAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string resetToken,
|
|
string tenantSlug,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Similar implementation
|
|
// URL: https://app.colaflow.com/{tenantSlug}/reset-password?token={resetToken}
|
|
throw new NotImplementedException("Password reset email - Day 7");
|
|
}
|
|
|
|
public async Task SendWelcomeEmailAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string tenantName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Similar implementation
|
|
throw new NotImplementedException("Welcome email - Day 7");
|
|
}
|
|
|
|
private string BuildVerificationUrl(string token, string tenantSlug)
|
|
{
|
|
var baseUrl = _configuration["App:FrontendUrl"] ?? "http://localhost:3000";
|
|
return $"{baseUrl}/{tenantSlug}/verify-email?token={token}";
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 3.3.4 SMTP Fallback (Development)
|
|
|
|
**File**: `Infrastructure/Services/SmtpEmailService.cs`
|
|
|
|
```csharp
|
|
public class SmtpEmailService : IEmailService
|
|
{
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<SmtpEmailService> _logger;
|
|
|
|
public async Task SendEmailVerificationAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string verificationToken,
|
|
string tenantSlug,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var message = new MimeMessage();
|
|
message.From.Add(new MailboxAddress("ColaFlow", "noreply@colaflow.local"));
|
|
message.To.Add(new MailboxAddress(recipientName, recipientEmail));
|
|
message.Subject = "Verify your ColaFlow email address";
|
|
|
|
var verificationUrl = BuildVerificationUrl(verificationToken, tenantSlug);
|
|
|
|
var bodyBuilder = new BodyBuilder
|
|
{
|
|
TextBody = $"Please verify your email: {verificationUrl}",
|
|
HtmlBody = $"<p>Please verify your email:</p><p><a href=\"{verificationUrl}\">Verify Email</a></p>"
|
|
};
|
|
|
|
message.Body = bodyBuilder.ToMessageBody();
|
|
|
|
using var client = new SmtpClient();
|
|
await client.ConnectAsync(
|
|
_configuration["Smtp:Host"] ?? "localhost",
|
|
_configuration.GetValue<int>("Smtp:Port", 587),
|
|
SecureSocketOptions.StartTls,
|
|
cancellationToken);
|
|
|
|
await client.AuthenticateAsync(
|
|
_configuration["Smtp:Username"],
|
|
_configuration["Smtp:Password"],
|
|
cancellationToken);
|
|
|
|
await client.SendAsync(message, cancellationToken);
|
|
await client.DisconnectAsync(true, cancellationToken);
|
|
|
|
_logger.LogInformation("Sent verification email to {Email} via SMTP", recipientEmail);
|
|
}
|
|
|
|
// Other methods similar...
|
|
}
|
|
```
|
|
|
|
### 3.4 Domain Layer Updates
|
|
|
|
**Update `User.cs`** with token validation:
|
|
|
|
```csharp
|
|
// Add to User.cs
|
|
public void SetEmailVerificationToken(string plainTextToken, DateTime expiresAt)
|
|
{
|
|
// Hash token before storage
|
|
EmailVerificationToken = ComputeSha256Hash(plainTextToken);
|
|
EmailVerificationTokenExpiresAt = expiresAt;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public bool IsEmailVerificationTokenValid(string plainTextToken)
|
|
{
|
|
if (string.IsNullOrEmpty(EmailVerificationToken) ||
|
|
!EmailVerificationTokenExpiresAt.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (DateTime.UtcNow > EmailVerificationTokenExpiresAt)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var tokenHash = ComputeSha256Hash(plainTextToken);
|
|
return EmailVerificationToken == tokenHash;
|
|
}
|
|
|
|
public void VerifyEmailWithToken(string plainTextToken)
|
|
{
|
|
if (!IsEmailVerificationTokenValid(plainTextToken))
|
|
{
|
|
throw new InvalidOperationException("Invalid or expired verification token");
|
|
}
|
|
|
|
VerifyEmail(); // Call existing method
|
|
}
|
|
|
|
private static string ComputeSha256Hash(string input)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var bytes = Encoding.UTF8.GetBytes(input);
|
|
var hash = sha256.ComputeHash(bytes);
|
|
return Convert.ToBase64String(hash);
|
|
}
|
|
```
|
|
|
|
### 3.5 Application Layer
|
|
|
|
#### 3.5.1 Commands
|
|
|
|
**File**: `Application/Commands/VerifyEmail/VerifyEmailCommand.cs`
|
|
|
|
```csharp
|
|
public record VerifyEmailCommand(string Token, string TenantSlug) : IRequest<bool>;
|
|
|
|
public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, bool>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly ITenantRepository _tenantRepository;
|
|
private readonly IEmailService _emailService;
|
|
private readonly ILogger<VerifyEmailCommandHandler> _logger;
|
|
|
|
public async Task<bool> Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
// 1. Get tenant
|
|
var tenant = await _tenantRepository.GetBySlugAsync(request.TenantSlug, cancellationToken);
|
|
if (tenant == null)
|
|
{
|
|
_logger.LogWarning("Verification failed: tenant {Slug} not found", request.TenantSlug);
|
|
return false;
|
|
}
|
|
|
|
// 2. Find user by token hash
|
|
var tokenHash = ComputeSha256Hash(request.Token);
|
|
var user = await _userRepository.GetByEmailVerificationTokenAsync(
|
|
tokenHash,
|
|
tenant.Id,
|
|
cancellationToken);
|
|
|
|
if (user == null)
|
|
{
|
|
_logger.LogWarning("Verification failed: token not found");
|
|
return false;
|
|
}
|
|
|
|
// 3. Verify token and update user
|
|
user.VerifyEmailWithToken(request.Token);
|
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
|
|
|
_logger.LogInformation("Email verified for user {UserId}", user.Id);
|
|
|
|
// 4. Send welcome email (optional)
|
|
try
|
|
{
|
|
await _emailService.SendWelcomeEmailAsync(
|
|
user.Email.Value,
|
|
user.FullName.Value,
|
|
tenant.Name.Value,
|
|
cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Don't fail verification if welcome email fails
|
|
_logger.LogWarning(ex, "Failed to send welcome email");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Email verification failed");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string ComputeSha256Hash(string input)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var bytes = Encoding.UTF8.GetBytes(input);
|
|
var hash = sha256.ComputeHash(bytes);
|
|
return Convert.ToBase64String(hash);
|
|
}
|
|
}
|
|
```
|
|
|
|
**File**: `Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs`
|
|
|
|
```csharp
|
|
public record ResendVerificationEmailCommand(
|
|
string Email,
|
|
string TenantSlug
|
|
) : IRequest<bool>;
|
|
|
|
public class ResendVerificationEmailCommandHandler
|
|
: IRequestHandler<ResendVerificationEmailCommand, bool>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly ITenantRepository _tenantRepository;
|
|
private readonly IEmailService _emailService;
|
|
private readonly IEmailRateLimiter _rateLimiter;
|
|
private readonly ILogger<ResendVerificationEmailCommandHandler> _logger;
|
|
|
|
public async Task<bool> Handle(
|
|
ResendVerificationEmailCommand request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 1. Find tenant
|
|
var tenant = await _tenantRepository.GetBySlugAsync(request.TenantSlug, cancellationToken);
|
|
if (tenant == null)
|
|
{
|
|
// Always return true to prevent tenant enumeration
|
|
_logger.LogWarning("Resend verification: tenant {Slug} not found", request.TenantSlug);
|
|
return true;
|
|
}
|
|
|
|
// 2. Find user
|
|
var email = Email.From(request.Email);
|
|
var user = await _userRepository.GetByEmailAsync(email, tenant.Id, cancellationToken);
|
|
|
|
if (user == null)
|
|
{
|
|
// Always return true to prevent email enumeration
|
|
_logger.LogWarning("Resend verification: user {Email} not found", request.Email);
|
|
return true;
|
|
}
|
|
|
|
// 3. Check if already verified
|
|
if (user.EmailVerifiedAt.HasValue)
|
|
{
|
|
_logger.LogInformation("User {UserId} already verified", user.Id);
|
|
return true;
|
|
}
|
|
|
|
// 4. Check rate limit (1 email per minute per email address)
|
|
if (!await _rateLimiter.AllowEmailOperationAsync(
|
|
request.Email,
|
|
tenant.Id,
|
|
"verification",
|
|
TimeSpan.FromMinutes(1),
|
|
cancellationToken))
|
|
{
|
|
_logger.LogWarning(
|
|
"Rate limit exceeded for email {Email}",
|
|
request.Email);
|
|
|
|
// Return true to not reveal rate limiting to potential attackers
|
|
return true;
|
|
}
|
|
|
|
// 5. Generate new token
|
|
var token = GenerateUrlSafeToken();
|
|
var expiresAt = DateTime.UtcNow.AddHours(24);
|
|
user.SetEmailVerificationToken(token, expiresAt);
|
|
|
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
|
|
|
// 6. Send email
|
|
try
|
|
{
|
|
await _emailService.SendEmailVerificationAsync(
|
|
user.Email.Value,
|
|
user.FullName.Value,
|
|
token,
|
|
request.TenantSlug,
|
|
cancellationToken);
|
|
|
|
_logger.LogInformation("Resent verification email to user {UserId}", user.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send verification email");
|
|
// Don't throw - token is already saved, user can try again
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string GenerateUrlSafeToken()
|
|
{
|
|
var tokenBytes = new byte[32];
|
|
using var rng = RandomNumberGenerator.Create();
|
|
rng.GetBytes(tokenBytes);
|
|
return Convert.ToBase64String(tokenBytes)
|
|
.Replace("+", "-")
|
|
.Replace("/", "_")
|
|
.TrimEnd('=');
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3.6 Rate Limiting Service
|
|
|
|
**File**: `Application/Services/IEmailRateLimiter.cs`
|
|
|
|
```csharp
|
|
public interface IEmailRateLimiter
|
|
{
|
|
Task<bool> AllowEmailOperationAsync(
|
|
string email,
|
|
Guid tenantId,
|
|
string operationType,
|
|
TimeSpan minInterval,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
```
|
|
|
|
**Implementation**: `Infrastructure/Services/EmailRateLimiter.cs`
|
|
|
|
```csharp
|
|
public class EmailRateLimiter : IEmailRateLimiter
|
|
{
|
|
private readonly IdentityDbContext _context;
|
|
private readonly ILogger<EmailRateLimiter> _logger;
|
|
|
|
public async Task<bool> AllowEmailOperationAsync(
|
|
string email,
|
|
Guid tenantId,
|
|
string operationType,
|
|
TimeSpan minInterval,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var emailLower = email.ToLower();
|
|
|
|
// Try to find existing rate limit record
|
|
var rateLimit = await _context.EmailRateLimits
|
|
.FirstOrDefaultAsync(
|
|
r => r.Email == emailLower &&
|
|
r.TenantId == tenantId &&
|
|
r.OperationType == operationType,
|
|
cancellationToken);
|
|
|
|
if (rateLimit == null)
|
|
{
|
|
// First time - allow and create record
|
|
_context.EmailRateLimits.Add(new EmailRateLimit
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Email = emailLower,
|
|
TenantId = tenantId,
|
|
OperationType = operationType,
|
|
LastSentAt = now,
|
|
AttemptsCount = 1
|
|
});
|
|
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
// Check if enough time has passed
|
|
var timeSinceLastSend = now - rateLimit.LastSentAt;
|
|
|
|
if (timeSinceLastSend < minInterval)
|
|
{
|
|
// Rate limit exceeded
|
|
rateLimit.AttemptsCount++;
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
|
|
_logger.LogWarning(
|
|
"Rate limit exceeded for {Email}, operation: {Operation}, attempts: {Attempts}",
|
|
email, operationType, rateLimit.AttemptsCount);
|
|
|
|
return false;
|
|
}
|
|
|
|
// Allow operation and update record
|
|
rateLimit.LastSentAt = now;
|
|
rateLimit.AttemptsCount = 1;
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3.7 API Layer
|
|
|
|
**Update `AuthController.cs`**:
|
|
|
|
```csharp
|
|
// Add to existing AuthController
|
|
|
|
/// <summary>
|
|
/// Verify email address
|
|
/// </summary>
|
|
[HttpGet("verify-email")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(302)] // Redirect
|
|
public async Task<IActionResult> VerifyEmail(
|
|
[FromQuery] string token,
|
|
[FromQuery] string tenant)
|
|
{
|
|
if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(tenant))
|
|
{
|
|
return Redirect($"{_configuration["App:FrontendUrl"]}/email-verification-failed");
|
|
}
|
|
|
|
var command = new VerifyEmailCommand(token, tenant);
|
|
var result = await _mediator.Send(command);
|
|
|
|
if (result)
|
|
{
|
|
return Redirect($"{_configuration["App:FrontendUrl"]}/{tenant}/email-verified");
|
|
}
|
|
else
|
|
{
|
|
return Redirect($"{_configuration["App:FrontendUrl"]}/{tenant}/email-verification-failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resend verification email
|
|
/// </summary>
|
|
[HttpPost("resend-verification")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(200)]
|
|
public async Task<IActionResult> ResendVerification(
|
|
[FromBody] ResendVerificationRequest request)
|
|
{
|
|
var command = new ResendVerificationEmailCommand(request.Email, request.TenantSlug);
|
|
await _mediator.Send(command);
|
|
|
|
// Always return success to prevent email enumeration
|
|
return Ok(new
|
|
{
|
|
message = "If the email exists, a verification link has been sent.",
|
|
success = true
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if email is verified
|
|
/// </summary>
|
|
[HttpGet("email-status")]
|
|
[Authorize]
|
|
[ProducesResponseType(typeof(EmailStatusDto), 200)]
|
|
public async Task<ActionResult<EmailStatusDto>> GetEmailStatus()
|
|
{
|
|
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
|
|
var user = await _userRepository.GetByIdAsync(userId);
|
|
|
|
if (user == null)
|
|
return NotFound();
|
|
|
|
return Ok(new EmailStatusDto
|
|
{
|
|
Email = user.Email.Value,
|
|
IsVerified = user.EmailVerifiedAt.HasValue,
|
|
VerifiedAt = user.EmailVerifiedAt
|
|
});
|
|
}
|
|
|
|
// DTOs
|
|
public record ResendVerificationRequest(string Email, string TenantSlug);
|
|
public record EmailStatusDto(string Email, bool IsVerified, DateTime? VerifiedAt);
|
|
```
|
|
|
|
### 3.8 Update Registration Flow
|
|
|
|
**Update `RegisterTenantCommandHandler.cs`**:
|
|
|
|
```csharp
|
|
public async Task<TenantDto> Handle(
|
|
RegisterTenantCommand request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// ... existing validation and tenant creation ...
|
|
|
|
// Create admin user
|
|
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
|
|
var adminUser = User.CreateLocal(tenantId, email, hashedPassword, fullName);
|
|
|
|
// Generate email verification token
|
|
var verificationToken = GenerateUrlSafeToken();
|
|
var tokenExpiresAt = DateTime.UtcNow.AddHours(24);
|
|
adminUser.SetEmailVerificationToken(verificationToken, tokenExpiresAt);
|
|
|
|
await _userRepository.AddAsync(adminUser, cancellationToken);
|
|
|
|
// Assign TenantOwner role
|
|
var tenantRole = UserTenantRole.Create(
|
|
UserId.From(adminUser.Id),
|
|
tenantId,
|
|
TenantRole.TenantOwner);
|
|
|
|
await _roleRepository.AddAsync(tenantRole, cancellationToken);
|
|
|
|
// Generate JWT (user can login even if email not verified)
|
|
var token = _jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
|
|
|
|
// Generate refresh token
|
|
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
|
adminUser,
|
|
ipAddress: null,
|
|
userAgent: null,
|
|
cancellationToken);
|
|
|
|
// Send verification email (don't fail registration if email fails)
|
|
try
|
|
{
|
|
await _emailService.SendEmailVerificationAsync(
|
|
adminUser.Email.Value,
|
|
adminUser.FullName.Value,
|
|
verificationToken,
|
|
request.TenantSlug,
|
|
cancellationToken);
|
|
|
|
_logger.LogInformation(
|
|
"Sent verification email to {Email}",
|
|
adminUser.Email.Value);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send verification email during registration");
|
|
// Continue - user can resend later
|
|
}
|
|
|
|
return new TenantDto
|
|
{
|
|
TenantId = tenant.Id,
|
|
TenantName = tenant.Name.Value,
|
|
TenantSlug = tenant.Slug.Value,
|
|
Plan = tenant.Plan.ToString(),
|
|
AccessToken = token,
|
|
RefreshToken = refreshToken.PlainTextToken,
|
|
ExpiresIn = 3600,
|
|
AdminUser = new UserDto
|
|
{
|
|
UserId = adminUser.Id,
|
|
Email = adminUser.Email.Value,
|
|
FullName = adminUser.FullName.Value,
|
|
EmailVerified = false,
|
|
Role = TenantRole.TenantOwner.ToString()
|
|
}
|
|
};
|
|
}
|
|
|
|
private static string GenerateUrlSafeToken()
|
|
{
|
|
var tokenBytes = new byte[32];
|
|
using var rng = RandomNumberGenerator.Create();
|
|
rng.GetBytes(tokenBytes);
|
|
return Convert.ToBase64String(tokenBytes)
|
|
.Replace("+", "-")
|
|
.Replace("/", "_")
|
|
.TrimEnd('=');
|
|
}
|
|
```
|
|
|
|
### 3.9 Configuration
|
|
|
|
**Update `appsettings.Development.json`**:
|
|
|
|
```json
|
|
{
|
|
"SendGrid": {
|
|
"ApiKey": "${SENDGRID_API_KEY}",
|
|
"FromEmail": "noreply@colaflow.local",
|
|
"FromName": "ColaFlow"
|
|
},
|
|
"Smtp": {
|
|
"Host": "localhost",
|
|
"Port": "1025",
|
|
"Username": "",
|
|
"Password": "",
|
|
"UseSsl": false
|
|
},
|
|
"App": {
|
|
"BaseUrl": "http://localhost:5167",
|
|
"FrontendUrl": "http://localhost:3000"
|
|
},
|
|
"EmailVerification": {
|
|
"TokenExpirationHours": "24",
|
|
"RequireVerification": "false",
|
|
"RateLimitMinutes": "1"
|
|
},
|
|
"EmailProvider": "Smtp"
|
|
}
|
|
```
|
|
|
|
**Update `appsettings.Production.json`**:
|
|
|
|
```json
|
|
{
|
|
"SendGrid": {
|
|
"ApiKey": "${SENDGRID_API_KEY}",
|
|
"FromEmail": "noreply@colaflow.com",
|
|
"FromName": "ColaFlow"
|
|
},
|
|
"App": {
|
|
"BaseUrl": "https://api.colaflow.com",
|
|
"FrontendUrl": "https://app.colaflow.com"
|
|
},
|
|
"EmailVerification": {
|
|
"TokenExpirationHours": "24",
|
|
"RequireVerification": "true",
|
|
"RateLimitMinutes": "1"
|
|
},
|
|
"EmailProvider": "SendGrid"
|
|
}
|
|
```
|
|
|
|
### 3.10 Dependency Injection
|
|
|
|
**Update `Infrastructure/DependencyInjection.cs`**:
|
|
|
|
```csharp
|
|
public static IServiceCollection AddIdentityInfrastructure(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
// ... existing services ...
|
|
|
|
// Email service based on configuration
|
|
var emailProvider = configuration["EmailProvider"];
|
|
|
|
if (emailProvider == "SendGrid")
|
|
{
|
|
services.AddScoped<IEmailService, SendGridEmailService>();
|
|
}
|
|
else if (emailProvider == "Smtp")
|
|
{
|
|
services.AddScoped<IEmailService, SmtpEmailService>();
|
|
}
|
|
else
|
|
{
|
|
// Default to SMTP for development
|
|
services.AddScoped<IEmailService, SmtpEmailService>();
|
|
}
|
|
|
|
// Rate limiter
|
|
services.AddScoped<IEmailRateLimiter, EmailRateLimiter>();
|
|
|
|
return services;
|
|
}
|
|
```
|
|
|
|
### 3.11 Security Mechanisms
|
|
|
|
**Anti-Abuse Mechanisms**:
|
|
|
|
1. **Rate Limiting**:
|
|
- 1 email per minute per email address
|
|
- Tracked in database (persistent across restarts)
|
|
- Configurable via `EmailVerification:RateLimitMinutes`
|
|
|
|
2. **Email Enumeration Prevention**:
|
|
- Always return success for resend verification (don't reveal if email exists)
|
|
- Generic error messages
|
|
|
|
3. **Token Security**:
|
|
- 32-byte cryptographically secure random tokens
|
|
- SHA-256 hash stored in database
|
|
- URL-safe base64 encoding
|
|
- 24-hour expiration
|
|
- One-time use only (cleared after verification)
|
|
|
|
4. **Verification Status Check**:
|
|
- Only authenticated users can check their own email status
|
|
- No endpoint to check other users' email verification status
|
|
|
|
### 3.12 Complexity & Time Estimate
|
|
|
|
| Task | Complexity | Time |
|
|
|------|-----------|------|
|
|
| Email service interface & SendGrid impl | Medium | 2.5 hours |
|
|
| SMTP fallback implementation | Low | 1 hour |
|
|
| VerifyEmail command & handler | Medium | 1.5 hours |
|
|
| ResendVerification command & handler | Medium | 1.5 hours |
|
|
| Rate limiter service | Medium | 1.5 hours |
|
|
| Update registration flow | Low | 1 hour |
|
|
| API endpoints & DTOs | Low | 1 hour |
|
|
| Configuration & DI | Low | 0.5 hours |
|
|
| Integration tests | Medium | 2 hours |
|
|
| **Total** | - | **12.5 hours** |
|
|
|
|
---
|
|
|
|
## 4. Scenario C: Combined Implementation
|
|
|
|
### 4.1 Task Dependencies
|
|
|
|
```
|
|
Day 6 Combined Implementation:
|
|
|
|
Phase 1: Role Management API (Priority 1)
|
|
├── Step 1: Database migration (add index)
|
|
├── Step 2: Repository methods
|
|
├── Step 3: Commands (Assign/Update/Remove)
|
|
├── Step 4: Queries (List users)
|
|
├── Step 5: Controller & DTOs
|
|
└── Step 6: Integration tests
|
|
|
|
Phase 2: Email Verification (Priority 2)
|
|
├── Step 1: Database migration (add expiration column)
|
|
├── Step 2: Email service (SendGrid + SMTP)
|
|
├── Step 3: Rate limiter service
|
|
├── Step 4: Commands (Verify/Resend)
|
|
├── Step 5: Update registration flow
|
|
├── Step 6: API endpoints
|
|
└── Step 7: Integration tests
|
|
|
|
No blocking dependencies between phases - can be developed in parallel
|
|
```
|
|
|
|
### 4.2 Database Migration Strategy
|
|
|
|
**Single migration file** for Day 6:
|
|
|
|
```csharp
|
|
public partial class Day6RoleManagementAndEmailVerification : Migration
|
|
{
|
|
protected override void Up(MigrationBuilder migrationBuilder)
|
|
{
|
|
// Role Management optimizations
|
|
migrationBuilder.Sql(@"
|
|
CREATE INDEX IF NOT EXISTS idx_user_tenant_roles_tenant_role
|
|
ON identity.user_tenant_roles(tenant_id, role);
|
|
");
|
|
|
|
// Email Verification updates
|
|
migrationBuilder.AddColumn<DateTime>(
|
|
name: "email_verification_token_expires_at",
|
|
schema: "identity",
|
|
table: "users",
|
|
type: "timestamp without time zone",
|
|
nullable: true);
|
|
|
|
migrationBuilder.Sql(@"
|
|
CREATE INDEX IF NOT EXISTS idx_users_email_verification_token
|
|
ON identity.users(email_verification_token)
|
|
WHERE email_verification_token IS NOT NULL;
|
|
");
|
|
|
|
// Email rate limiting table
|
|
migrationBuilder.CreateTable(
|
|
name: "email_rate_limits",
|
|
schema: "identity",
|
|
columns: table => new
|
|
{
|
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
email = table.Column<string>(type: "character varying(255)", nullable: false),
|
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
operation_type = table.Column<string>(type: "character varying(50)", nullable: false),
|
|
last_sent_at = table.Column<DateTime>(type: "timestamp without time zone", nullable: false),
|
|
attempts_count = table.Column<int>(type: "integer", nullable: false, defaultValue: 1)
|
|
},
|
|
constraints: table =>
|
|
{
|
|
table.PrimaryKey("pk_email_rate_limits", x => x.id);
|
|
table.UniqueConstraint("uq_email_rate_limit", x => new { x.email, x.tenant_id, x.operation_type });
|
|
});
|
|
|
|
migrationBuilder.CreateIndex(
|
|
name: "idx_email_rate_limits_email",
|
|
schema: "identity",
|
|
table: "email_rate_limits",
|
|
columns: new[] { "email", "tenant_id" });
|
|
|
|
migrationBuilder.CreateIndex(
|
|
name: "idx_email_rate_limits_cleanup",
|
|
schema: "identity",
|
|
table: "email_rate_limits",
|
|
column: "last_sent_at");
|
|
}
|
|
|
|
protected override void Down(MigrationBuilder migrationBuilder)
|
|
{
|
|
migrationBuilder.DropTable(
|
|
name: "email_rate_limits",
|
|
schema: "identity");
|
|
|
|
migrationBuilder.DropIndex(
|
|
name: "idx_users_email_verification_token",
|
|
schema: "identity",
|
|
table: "users");
|
|
|
|
migrationBuilder.DropColumn(
|
|
name: "email_verification_token_expires_at",
|
|
schema: "identity",
|
|
table: "users");
|
|
|
|
migrationBuilder.DropIndex(
|
|
name: "idx_user_tenant_roles_tenant_role",
|
|
schema: "identity",
|
|
table: "user_tenant_roles");
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.3 Implementation Order
|
|
|
|
**Recommended order for combined implementation**:
|
|
|
|
1. **Morning (4 hours)**: Role Management API
|
|
- Database migration
|
|
- Repository methods
|
|
- Commands & queries
|
|
- Controller
|
|
|
|
2. **Afternoon (4 hours)**: Email Service Core
|
|
- Email service interfaces
|
|
- SendGrid implementation
|
|
- SMTP fallback
|
|
- Rate limiter
|
|
|
|
3. **Next Day Morning (4 hours)**: Email Verification Flow
|
|
- Commands (Verify/Resend)
|
|
- Update registration flow
|
|
- API endpoints
|
|
- Configuration
|
|
|
|
4. **Next Day Afternoon (3 hours)**: Testing & Polish
|
|
- Integration tests for role management
|
|
- Integration tests for email verification
|
|
- End-to-end testing
|
|
- Documentation
|
|
|
|
**Total: 15 hours (2 days)**
|
|
|
|
### 4.4 Testing Strategy
|
|
|
|
**Integration Tests Checklist**:
|
|
|
|
**Role Management**:
|
|
- ✅ TenantOwner can assign role to user
|
|
- ✅ TenantAdmin cannot assign roles
|
|
- ✅ Cannot assign AIAgent role manually
|
|
- ✅ Cannot remove last TenantOwner
|
|
- ✅ Cannot self-demote from TenantOwner
|
|
- ✅ List users returns correct pagination
|
|
- ✅ Removing user revokes their refresh tokens
|
|
|
|
**Email Verification**:
|
|
- ✅ Registration sends verification email
|
|
- ✅ Verification token works and marks email as verified
|
|
- ✅ Expired token is rejected
|
|
- ✅ Invalid token is rejected
|
|
- ✅ Resend verification works
|
|
- ✅ Rate limiting prevents spam
|
|
- ✅ Already verified users can login without re-verification
|
|
|
|
### 4.5 NuGet Packages Required
|
|
|
|
```xml
|
|
<!-- Add to Identity.Infrastructure.csproj -->
|
|
<PackageReference Include="SendGrid" Version="9.29.3" />
|
|
<PackageReference Include="MailKit" Version="4.3.0" />
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Implementation Roadmap
|
|
|
|
### 5.1 Day 6 Detailed Schedule
|
|
|
|
#### Morning Session (8:00 - 12:00) - Role Management API
|
|
|
|
**8:00 - 9:30**: Database & Domain Layer
|
|
- Create migration for Day 6
|
|
- Add repository methods (`CountByTenantAndRoleAsync`, `GetByIdsAsync`)
|
|
- Add validation logic to `UserTenantRole`
|
|
|
|
**9:30 - 11:00**: Application Layer
|
|
- Implement `AssignUserRoleCommand` & handler
|
|
- Implement `UpdateUserRoleCommand` & handler
|
|
- Implement `RemoveUserFromTenantCommand` & handler
|
|
- Implement `ListTenantUsersQuery` & handler
|
|
|
|
**11:00 - 12:00**: API Layer
|
|
- Create `TenantUsersController`
|
|
- Add DTOs (`UserWithRoleDto`, `PagedResult<T>`)
|
|
- Test endpoints manually
|
|
|
|
#### Afternoon Session (13:00 - 17:00) - Email Verification
|
|
|
|
**13:00 - 14:30**: Email Service
|
|
- Implement `IEmailService` interface
|
|
- Implement `SendGridEmailService`
|
|
- Implement `SmtpEmailService`
|
|
- Test email sending locally (SMTP)
|
|
|
|
**14:30 - 16:00**: Verification Flow
|
|
- Implement `VerifyEmailCommand` & handler
|
|
- Implement `ResendVerificationEmailCommand` & handler
|
|
- Implement `EmailRateLimiter`
|
|
- Update `User` entity with token validation
|
|
|
|
**16:00 - 17:00**: Integration
|
|
- Update `RegisterTenantCommandHandler` to send verification email
|
|
- Add API endpoints to `AuthController`
|
|
- Configure SendGrid/SMTP in appsettings
|
|
- Test end-to-end flow
|
|
|
|
#### Day 7 Morning (8:00 - 11:00) - Testing & Documentation
|
|
|
|
**8:00 - 10:00**: Integration Tests
|
|
- Write tests for role management (8 tests)
|
|
- Write tests for email verification (6 tests)
|
|
- Run all tests, ensure 100% pass rate
|
|
|
|
**10:00 - 11:00**: Documentation & Cleanup
|
|
- Update API documentation (Swagger)
|
|
- Update README with new features
|
|
- Create Day 6 implementation summary
|
|
- Commit and push changes
|
|
|
|
### 5.2 Files to Create
|
|
|
|
**Application Layer** (10 files):
|
|
- `Commands/AssignUserRole/AssignUserRoleCommand.cs`
|
|
- `Commands/AssignUserRole/AssignUserRoleCommandHandler.cs`
|
|
- `Commands/UpdateUserRole/UpdateUserRoleCommand.cs`
|
|
- `Commands/UpdateUserRole/UpdateUserRoleCommandHandler.cs`
|
|
- `Commands/RemoveUserFromTenant/RemoveUserFromTenantCommand.cs`
|
|
- `Commands/RemoveUserFromTenant/RemoveUserFromTenantCommandHandler.cs`
|
|
- `Commands/VerifyEmail/VerifyEmailCommand.cs`
|
|
- `Commands/VerifyEmail/VerifyEmailCommandHandler.cs`
|
|
- `Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs`
|
|
- `Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs`
|
|
- `Queries/ListTenantUsers/ListTenantUsersQuery.cs`
|
|
- `Queries/ListTenantUsers/ListTenantUsersQueryHandler.cs`
|
|
- `Services/IEmailService.cs`
|
|
- `Services/IEmailRateLimiter.cs`
|
|
- `Dtos/UserWithRoleDto.cs`
|
|
- `Dtos/PagedResult.cs`
|
|
|
|
**Infrastructure Layer** (5 files):
|
|
- `Services/SendGridEmailService.cs`
|
|
- `Services/SmtpEmailService.cs`
|
|
- `Services/EmailRateLimiter.cs`
|
|
- `Persistence/Configurations/EmailRateLimitConfiguration.cs`
|
|
- `Persistence/Migrations/XXXXXX_Day6RoleManagementAndEmailVerification.cs`
|
|
|
|
**API Layer** (1 file):
|
|
- `Controllers/TenantUsersController.cs`
|
|
|
|
**Tests** (2 files):
|
|
- `IntegrationTests/RoleManagementTests.cs`
|
|
- `IntegrationTests/EmailVerificationTests.cs`
|
|
|
|
### 5.3 Files to Modify
|
|
|
|
- `Domain/Aggregates/Users/User.cs` (add token validation)
|
|
- `Domain/Repositories/IUserRepository.cs` (add `GetByIdsAsync`, `GetByEmailVerificationTokenAsync`)
|
|
- `Domain/Repositories/IUserTenantRoleRepository.cs` (add `CountByTenantAndRoleAsync`)
|
|
- `Infrastructure/Persistence/Repositories/UserRepository.cs` (implement new methods)
|
|
- `Infrastructure/Persistence/Repositories/UserTenantRoleRepository.cs` (implement new methods)
|
|
- `Infrastructure/DependencyInjection.cs` (register email services)
|
|
- `Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs` (add email sending)
|
|
- `API/Controllers/AuthController.cs` (add verification endpoints)
|
|
- `API/appsettings.Development.json` (add email configuration)
|
|
- `API/appsettings.Production.json` (add email configuration)
|
|
|
|
---
|
|
|
|
## 6. Risk Assessment
|
|
|
|
### 6.1 Technical Risks
|
|
|
|
| Risk | Impact | Probability | Mitigation |
|
|
|------|--------|-------------|------------|
|
|
| **SendGrid account setup delays** | Medium | Medium | Use SMTP fallback for local development, SendGrid setup can be done later |
|
|
| **Rate limiting database contention** | Low | Low | Use in-memory cache for rate limiting if needed (MemoryCache instead of database) |
|
|
| **Email deliverability issues** | Medium | Medium | Use reputable provider (SendGrid), configure SPF/DKIM records |
|
|
| **Last owner deletion bug** | High | Low | Comprehensive validation logic, integration tests |
|
|
| **Token collision** | Low | Very Low | 32-byte cryptographic random tokens have negligible collision probability |
|
|
| **Migration conflicts** | Low | Low | Single migration file, test on clean database first |
|
|
|
|
### 6.2 Security Risks
|
|
|
|
| Risk | Impact | Mitigation |
|
|
|------|--------|------------|
|
|
| **Email enumeration** | Medium | Always return success for resend, generic error messages |
|
|
| **Token brute force** | Low | 32-byte tokens = 2^256 combinations, 24-hour expiration |
|
|
| **Rate limit bypass** | Medium | Persistent database tracking, multiple checks (IP + email) |
|
|
| **Privilege escalation** | High | Strict authorization checks, cannot self-demote, cannot remove last owner |
|
|
| **CSRF on email verification** | Low | GET endpoint with long random token, no sensitive actions |
|
|
| **Email injection** | Low | Use email library (SendGrid SDK, MailKit), no raw SMTP |
|
|
|
|
### 6.3 Operational Risks
|
|
|
|
| Risk | Impact | Mitigation |
|
|
|------|--------|------------|
|
|
| **SendGrid free tier limits** | Medium | Monitor usage, upgrade plan if needed, use batch sending |
|
|
| **Email spam folder** | Medium | Configure SPF/DKIM, warm up IP, use reputable sender |
|
|
| **Failed email delivery** | Medium | Log failures, allow resend, queue-based retry (future) |
|
|
| **Database growth (rate limits)** | Low | Scheduled cleanup job, delete records older than 7 days |
|
|
|
|
### 6.4 Complexity Assessment
|
|
|
|
| Component | Complexity | Risk Level | Notes |
|
|
|-----------|-----------|------------|-------|
|
|
| **Role Management API** | Medium | Low | Well-defined patterns, clear validation rules |
|
|
| **Email Service** | Medium | Medium | External dependency (SendGrid), deliverability concerns |
|
|
| **Rate Limiting** | Medium | Low | Database-backed, straightforward logic |
|
|
| **Email Verification Flow** | Low-Medium | Low | Standard OAuth-like flow |
|
|
| **Combined Implementation** | Medium | Medium | No blocking dependencies, but requires careful coordination |
|
|
|
|
**Total Estimated Time**: 22.5 hours (10 hours role mgmt + 12.5 hours email verification)
|
|
**Realistic Time (with buffer)**: 3 working days
|
|
|
|
---
|
|
|
|
## 7. Testing Strategy
|
|
|
|
### 7.1 Unit Tests
|
|
|
|
**Role Management**:
|
|
```csharp
|
|
public class UserTenantRoleTests
|
|
{
|
|
[Fact]
|
|
public void UpdateRole_ShouldUpdateRole_WhenValid()
|
|
{
|
|
// Arrange
|
|
var role = UserTenantRole.Create(
|
|
UserId.From(Guid.NewGuid()),
|
|
TenantId.From(Guid.NewGuid()),
|
|
TenantRole.TenantMember);
|
|
|
|
var updaterId = Guid.NewGuid();
|
|
|
|
// Act
|
|
role.UpdateRole(TenantRole.TenantAdmin, updaterId);
|
|
|
|
// Assert
|
|
Assert.Equal(TenantRole.TenantAdmin, role.Role);
|
|
Assert.Equal(updaterId, role.AssignedByUserId);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Email Verification**:
|
|
```csharp
|
|
public class UserEmailVerificationTests
|
|
{
|
|
[Fact]
|
|
public void IsEmailVerificationTokenValid_ShouldReturnTrue_WhenTokenMatches()
|
|
{
|
|
// Arrange
|
|
var user = CreateTestUser();
|
|
var token = "test-token-123";
|
|
var expiresAt = DateTime.UtcNow.AddHours(24);
|
|
|
|
user.SetEmailVerificationToken(token, expiresAt);
|
|
|
|
// Act
|
|
var isValid = user.IsEmailVerificationTokenValid(token);
|
|
|
|
// Assert
|
|
Assert.True(isValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void IsEmailVerificationTokenValid_ShouldReturnFalse_WhenExpired()
|
|
{
|
|
// Arrange
|
|
var user = CreateTestUser();
|
|
var token = "test-token-123";
|
|
var expiresAt = DateTime.UtcNow.AddHours(-1); // Expired
|
|
|
|
user.SetEmailVerificationToken(token, expiresAt);
|
|
|
|
// Act
|
|
var isValid = user.IsEmailVerificationTokenValid(token);
|
|
|
|
// Assert
|
|
Assert.False(isValid);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7.2 Integration Tests
|
|
|
|
**File**: `tests/IntegrationTests/RoleManagementIntegrationTests.cs`
|
|
|
|
```csharp
|
|
public class RoleManagementIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
|
{
|
|
[Fact]
|
|
public async Task AssignRole_ShouldSucceed_WhenTenantOwner()
|
|
{
|
|
// Arrange
|
|
var (tenant, owner) = await CreateTenantWithOwner();
|
|
var member = await CreateUser(tenant.Id, "member@test.com");
|
|
var ownerToken = await LoginUser(owner);
|
|
|
|
var request = new AssignRoleRequest { Role = TenantRole.TenantAdmin };
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync(
|
|
$"/api/tenants/{tenant.Id}/users/{member.Id}/role",
|
|
request,
|
|
ownerToken);
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<UserWithRoleDto>();
|
|
Assert.Equal(TenantRole.TenantAdmin, result.Role);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemoveUser_ShouldFail_WhenLastOwner()
|
|
{
|
|
// Arrange
|
|
var (tenant, owner) = await CreateTenantWithOwner();
|
|
var ownerToken = await LoginUser(owner);
|
|
|
|
// Act
|
|
var response = await _client.DeleteAsync(
|
|
$"/api/tenants/{tenant.Id}/users/{owner.Id}/role",
|
|
ownerToken);
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateRole_ShouldFail_WhenSelfDemote()
|
|
{
|
|
// Arrange
|
|
var (tenant, owner) = await CreateTenantWithOwner();
|
|
var ownerToken = await LoginUser(owner);
|
|
|
|
var request = new AssignRoleRequest { Role = TenantRole.TenantMember };
|
|
|
|
// Act
|
|
var response = await _client.PutAsJsonAsync(
|
|
$"/api/tenants/{tenant.Id}/users/{owner.Id}/role",
|
|
request,
|
|
ownerToken);
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
|
}
|
|
}
|
|
```
|
|
|
|
**File**: `tests/IntegrationTests/EmailVerificationIntegrationTests.cs`
|
|
|
|
```csharp
|
|
public class EmailVerificationIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
|
{
|
|
[Fact]
|
|
public async Task RegisterTenant_ShouldSendVerificationEmail()
|
|
{
|
|
// Arrange
|
|
var emailService = _factory.Services.GetRequiredService<IEmailService>();
|
|
var emailSpy = new EmailServiceSpy(emailService);
|
|
|
|
var request = new RegisterTenantCommand(
|
|
"Test Corp",
|
|
"test-corp",
|
|
SubscriptionPlan.Professional,
|
|
"admin@test.com",
|
|
"Admin@1234",
|
|
"Test Admin");
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync("/api/tenants/register", request);
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Single(emailSpy.SentEmails);
|
|
Assert.Equal("admin@test.com", emailSpy.SentEmails[0].Recipient);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyEmail_ShouldSucceed_WithValidToken()
|
|
{
|
|
// Arrange
|
|
var (tenant, user, token) = await CreateUserWithVerificationToken();
|
|
|
|
// Act
|
|
var response = await _client.GetAsync(
|
|
$"/api/auth/verify-email?token={token}&tenant={tenant.Slug}");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
|
Assert.Contains("email-verified", response.Headers.Location.ToString());
|
|
|
|
// Verify in database
|
|
var updatedUser = await GetUser(user.Id);
|
|
Assert.NotNull(updatedUser.EmailVerifiedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResendVerification_ShouldRespectRateLimit()
|
|
{
|
|
// Arrange
|
|
var (tenant, user) = await CreateUnverifiedUser();
|
|
|
|
var request = new ResendVerificationRequest(user.Email, tenant.Slug);
|
|
|
|
// Act - First request succeeds
|
|
var response1 = await _client.PostAsJsonAsync("/api/auth/resend-verification", request);
|
|
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
|
|
|
|
// Act - Second request within 1 minute
|
|
var response2 = await _client.PostAsJsonAsync("/api/auth/resend-verification", request);
|
|
|
|
// Assert - Still returns 200 (to prevent enumeration), but email not sent
|
|
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
|
|
|
|
// Verify only one email sent
|
|
var emailSpy = _factory.Services.GetRequiredService<EmailServiceSpy>();
|
|
Assert.Single(emailSpy.SentEmails);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7.3 Manual Testing Checklist
|
|
|
|
**Role Management**:
|
|
- [ ] TenantOwner can list all users
|
|
- [ ] TenantAdmin can list all users
|
|
- [ ] TenantMember cannot list users (403)
|
|
- [ ] TenantOwner can assign TenantAdmin role
|
|
- [ ] TenantOwner can update user from Member to Admin
|
|
- [ ] Cannot assign AIAgent role (400)
|
|
- [ ] Cannot remove last TenantOwner (409)
|
|
- [ ] Cannot self-demote (409)
|
|
- [ ] Pagination works correctly
|
|
- [ ] Search by email/name works
|
|
|
|
**Email Verification**:
|
|
- [ ] Registration sends verification email
|
|
- [ ] Verification link marks email as verified
|
|
- [ ] Expired token shows error page
|
|
- [ ] Invalid token shows error page
|
|
- [ ] Already verified user shows success
|
|
- [ ] Resend verification works
|
|
- [ ] Rate limiting prevents spam (test with 2 quick requests)
|
|
- [ ] Email status endpoint shows correct status
|
|
- [ ] Can login before email verification
|
|
- [ ] Welcome email sent after verification (if implemented)
|
|
|
|
---
|
|
|
|
## 8. MCP Integration Considerations
|
|
|
|
### 8.1 Role Management for AI Agents
|
|
|
|
When implementing MCP Server (future), role management will need to support:
|
|
|
|
**AI Agent Role Assignment**:
|
|
```csharp
|
|
// Future MCP endpoint
|
|
[HttpPost("api/mcp/register-agent")]
|
|
[Authorize(Roles = "TenantOwner")]
|
|
public async Task<ActionResult<AgentCredentials>> RegisterAIAgent(
|
|
[FromBody] RegisterAgentRequest request)
|
|
{
|
|
// 1. Create AIAgent role for MCP access
|
|
var agentRole = UserTenantRole.Create(
|
|
UserId.From(request.AgentId),
|
|
TenantId.From(request.TenantId),
|
|
TenantRole.AIAgent,
|
|
assignedByUserId: GetCurrentUserId());
|
|
|
|
await _roleRepository.AddAsync(agentRole);
|
|
|
|
// 2. Generate API key for MCP authentication
|
|
var apiKey = GenerateApiKey();
|
|
await _mcpKeyRepository.AddAsync(new McpApiKey
|
|
{
|
|
KeyHash = ComputeSha256Hash(apiKey),
|
|
UserId = request.AgentId,
|
|
TenantId = request.TenantId,
|
|
Permissions = McpPermissions.Read | McpPermissions.WriteWithApproval
|
|
});
|
|
|
|
return Ok(new AgentCredentials
|
|
{
|
|
AgentId = request.AgentId,
|
|
ApiKey = apiKey,
|
|
Permissions = new[] { "read_projects", "write_preview" }
|
|
});
|
|
}
|
|
```
|
|
|
|
**Permission Mapping**:
|
|
```csharp
|
|
public class McpPermissionResolver
|
|
{
|
|
public bool HasPermission(TenantRole role, string mcpOperation)
|
|
{
|
|
return role switch
|
|
{
|
|
TenantRole.TenantOwner => true, // All permissions
|
|
TenantRole.TenantAdmin => IsSafeOperation(mcpOperation),
|
|
TenantRole.AIAgent when mcpOperation.StartsWith("read_") => true,
|
|
TenantRole.AIAgent when mcpOperation == "write_preview" => true,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private bool IsSafeOperation(string operation)
|
|
{
|
|
var safeOps = new[] { "read_projects", "read_issues", "write_preview" };
|
|
return safeOps.Contains(operation);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.2 Email Verification for Security
|
|
|
|
**MCP operations requiring verified email**:
|
|
```csharp
|
|
public class McpAuthorizationHandler : AuthorizationHandler<McpRequirement>
|
|
{
|
|
protected override Task HandleRequirementAsync(
|
|
AuthorizationHandlerContext context,
|
|
McpRequirement requirement)
|
|
{
|
|
var emailVerified = context.User.HasClaim("email_verified", "true");
|
|
|
|
if (!emailVerified && requirement.RequiresVerifiedEmail)
|
|
{
|
|
context.Fail(new AuthorizationFailureReason(
|
|
this,
|
|
"Email verification required for this MCP operation"));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
context.Succeed(requirement);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Future enhancement**: Add `email_verified` claim to JWT:
|
|
```csharp
|
|
// Update JwtService.GenerateToken()
|
|
claims.Add(new Claim("email_verified", user.EmailVerifiedAt.HasValue.ToString().ToLower()));
|
|
```
|
|
|
|
### 8.3 Audit Logging for MCP
|
|
|
|
All role changes and email operations should be logged for MCP compliance:
|
|
|
|
```csharp
|
|
public record AuditLog
|
|
{
|
|
public Guid Id { get; init; }
|
|
public Guid TenantId { get; init; }
|
|
public Guid ActorUserId { get; init; }
|
|
public string ActorRole { get; init; } = string.Empty;
|
|
public string Action { get; init; } = string.Empty; // "assign_role", "verify_email"
|
|
public string ResourceType { get; init; } = string.Empty; // "user_role", "email"
|
|
public Guid? ResourceId { get; init; }
|
|
public string Details { get; init; } = string.Empty; // JSON
|
|
public DateTime Timestamp { get; init; }
|
|
public string? IpAddress { get; init; }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Success Criteria
|
|
|
|
### 9.1 Role Management API
|
|
|
|
- [ ] **Endpoints Functional**:
|
|
- GET `/api/tenants/{id}/users` returns paginated user list
|
|
- POST `/api/tenants/{id}/users/{userId}/role` assigns role
|
|
- PUT `/api/tenants/{id}/users/{userId}/role` updates role
|
|
- DELETE `/api/tenants/{id}/users/{userId}/role` removes user
|
|
|
|
- [ ] **Authorization Correct**:
|
|
- Only TenantOwner can assign/update/remove roles
|
|
- TenantAdmin can list users
|
|
- Users in different tenants cannot access each other
|
|
|
|
- [ ] **Validation Enforced**:
|
|
- Cannot remove last TenantOwner
|
|
- Cannot self-demote from TenantOwner
|
|
- Cannot assign AIAgent role manually
|
|
- User status validation (cannot assign role to inactive user)
|
|
|
|
- [ ] **Data Integrity**:
|
|
- Role assignments are atomic (database transactions)
|
|
- Removing user revokes their refresh tokens
|
|
- Audit trail maintained (who assigned role, when)
|
|
|
|
### 9.2 Email Verification
|
|
|
|
- [ ] **Email Sending Works**:
|
|
- Verification email sent on registration
|
|
- Email contains valid verification link
|
|
- Email deliverability confirmed (check spam folder)
|
|
|
|
- [ ] **Verification Flow**:
|
|
- Clicking link verifies email
|
|
- Expired tokens rejected with user-friendly message
|
|
- Invalid tokens rejected
|
|
- Already verified users handled gracefully
|
|
|
|
- [ ] **Resend Verification**:
|
|
- Resend endpoint works
|
|
- Rate limiting prevents spam (1 email/minute)
|
|
- Always returns success (no email enumeration)
|
|
|
|
- [ ] **Security**:
|
|
- Tokens are cryptographically secure (32 bytes)
|
|
- Tokens stored as SHA-256 hash
|
|
- Token expiration enforced (24 hours)
|
|
- One-time use enforced (token cleared after verification)
|
|
|
|
### 9.3 Testing & Quality
|
|
|
|
- [ ] **Integration Tests**:
|
|
- All role management scenarios tested
|
|
- All email verification scenarios tested
|
|
- Rate limiting tested
|
|
- Security edge cases covered
|
|
|
|
- [ ] **Code Quality**:
|
|
- Clean Architecture principles followed
|
|
- SOLID principles applied
|
|
- No compiler warnings
|
|
- Code reviewed and approved
|
|
|
|
- [ ] **Documentation**:
|
|
- API documentation updated (Swagger)
|
|
- Architecture document complete
|
|
- Implementation summary created
|
|
- Configuration guide written
|
|
|
|
---
|
|
|
|
## 10. Rollback Plan
|
|
|
|
### 10.1 Database Rollback
|
|
|
|
```bash
|
|
# Rollback Day 6 migration
|
|
dotnet ef migrations remove --context IdentityDbContext --project src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure
|
|
```
|
|
|
|
### 10.2 Feature Flags
|
|
|
|
Add feature flags for gradual rollout:
|
|
|
|
```json
|
|
{
|
|
"Features": {
|
|
"RoleManagementApi": true,
|
|
"EmailVerification": true,
|
|
"EmailProvider": "Smtp" // Can switch to "SendGrid" when ready
|
|
}
|
|
}
|
|
```
|
|
|
|
### 10.3 Emergency Procedures
|
|
|
|
**If email sending fails**:
|
|
1. Switch to SMTP fallback in configuration
|
|
2. Disable email requirement (`EmailVerification:RequireVerification: false`)
|
|
3. Allow manual email verification via database update
|
|
|
|
**If role management has bugs**:
|
|
1. Disable TenantUsersController endpoints
|
|
2. Use database scripts for emergency role changes
|
|
3. Rollback to Day 5 state
|
|
|
|
---
|
|
|
|
## 11. Documentation Deliverables
|
|
|
|
### 11.1 API Documentation (Swagger)
|
|
|
|
Update Swagger annotations:
|
|
```csharp
|
|
/// <summary>
|
|
/// List all users in tenant with their assigned roles
|
|
/// </summary>
|
|
/// <param name="tenantId">Tenant ID</param>
|
|
/// <param name="query">Filter and pagination options</param>
|
|
/// <response code="200">Returns paginated list of users with roles</response>
|
|
/// <response code="403">User does not have permission to list users</response>
|
|
[HttpGet]
|
|
[Authorize(Roles = "TenantOwner,TenantAdmin")]
|
|
[ProducesResponseType(typeof(PagedResult<UserWithRoleDto>), 200)]
|
|
[ProducesResponseType(403)]
|
|
public async Task<ActionResult<PagedResult<UserWithRoleDto>>> ListUsers(
|
|
Guid tenantId,
|
|
[FromQuery] ListUsersQuery query)
|
|
{
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### 11.2 Configuration Guide
|
|
|
|
**Setup SendGrid**:
|
|
```bash
|
|
# 1. Create SendGrid account (free tier: 100 emails/day)
|
|
# https://signup.sendgrid.com/
|
|
|
|
# 2. Create API key with Mail Send permission
|
|
# https://app.sendgrid.com/settings/api_keys
|
|
|
|
# 3. Set environment variable or appsettings
|
|
export SENDGRID_API_KEY="SG.xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
|
|
# 4. Configure sender email (must be verified in SendGrid)
|
|
# Update appsettings.json:
|
|
{
|
|
"SendGrid": {
|
|
"ApiKey": "${SENDGRID_API_KEY}",
|
|
"FromEmail": "noreply@colaflow.com"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Development SMTP Setup (MailHog)**:
|
|
```bash
|
|
# Install MailHog for local email testing
|
|
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
|
|
|
|
# Update appsettings.Development.json
|
|
{
|
|
"EmailProvider": "Smtp",
|
|
"Smtp": {
|
|
"Host": "localhost",
|
|
"Port": 1025
|
|
}
|
|
}
|
|
|
|
# View emails at http://localhost:8025
|
|
```
|
|
|
|
### 11.3 Implementation Summary Template
|
|
|
|
```markdown
|
|
# Day 6 Implementation Summary
|
|
|
|
## Date: 2025-11-XX
|
|
|
|
## Overview
|
|
✅ Role Management API
|
|
✅ Email Verification Flow
|
|
✅ Integration Tests (XX tests, 100% pass)
|
|
|
|
## Features Implemented
|
|
1. Role Management API
|
|
- List users with roles
|
|
- Assign roles
|
|
- Update roles
|
|
- Remove users from tenant
|
|
|
|
2. Email Verification
|
|
- SendGrid integration
|
|
- SMTP fallback
|
|
- Verification flow
|
|
- Resend verification
|
|
- Rate limiting
|
|
|
|
## Files Created
|
|
- [List files]
|
|
|
|
## Files Modified
|
|
- [List files]
|
|
|
|
## Testing Results
|
|
- Unit Tests: XX passed
|
|
- Integration Tests: XX passed
|
|
- Manual Testing: ✅ Passed
|
|
|
|
## Configuration Changes
|
|
- Added SendGrid configuration
|
|
- Added SMTP fallback configuration
|
|
- Added email rate limiting settings
|
|
|
|
## Known Issues
|
|
- [List any known issues]
|
|
|
|
## Next Steps (Day 7)
|
|
- Password reset flow
|
|
- User profile management
|
|
- Tenant settings API
|
|
```
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
This Day 6 architecture design provides:
|
|
|
|
1. **Complete Role Management API** with proper authorization, validation, and audit trails
|
|
2. **Production-ready Email Verification** with SendGrid integration, rate limiting, and security
|
|
3. **Clear implementation roadmap** with detailed tasks and time estimates
|
|
4. **Comprehensive testing strategy** covering unit, integration, and manual testing
|
|
5. **MCP integration considerations** for future AI agent role management
|
|
6. **Risk assessment and mitigation** for all identified technical and security risks
|
|
|
|
**Key Design Decisions**:
|
|
- Use existing Day 5 infrastructure (no new major tables)
|
|
- SendGrid for email with SMTP fallback for development
|
|
- Database-backed rate limiting for persistence
|
|
- Policy-based authorization for role management
|
|
- Generic error messages to prevent enumeration
|
|
- Comprehensive validation to prevent privilege escalation
|
|
|
|
**Estimated Implementation Time**: 2-3 working days (22.5 hours + buffer)
|
|
|
|
**Ready for Implementation**: ✅ Yes - All technical decisions made, no blocking questions
|
|
|
|
---
|
|
|
|
**Document Version**: 1.0
|
|
**Last Updated**: 2025-11-03
|
|
**Status**: Ready for Product Manager Review & Backend Implementation
|
|
|
|
---
|
|
|
|
## Appendix: Quick Reference
|
|
|
|
### API Endpoints Summary
|
|
|
|
**Role Management**:
|
|
```
|
|
GET /api/tenants/{tenantId}/users - List users (TenantAdmin+)
|
|
POST /api/tenants/{tenantId}/users/{userId}/role - Assign role (TenantOwner)
|
|
PUT /api/tenants/{tenantId}/users/{userId}/role - Update role (TenantOwner)
|
|
DELETE /api/tenants/{tenantId}/users/{userId}/role - Remove user (TenantOwner)
|
|
```
|
|
|
|
**Email Verification**:
|
|
```
|
|
GET /api/auth/verify-email?token=xxx&tenant=yyy - Verify email (Anonymous)
|
|
POST /api/auth/resend-verification - Resend verification (Anonymous)
|
|
GET /api/auth/email-status - Check email status (Authenticated)
|
|
```
|
|
|
|
### Role Hierarchy
|
|
|
|
```
|
|
TenantOwner (1) - Full control
|
|
├── TenantAdmin (2) - User management
|
|
├── TenantMember (3) - Default role
|
|
├── TenantGuest (4) - Read-only
|
|
└── AIAgent (5) - MCP integration (not manually assignable)
|
|
```
|
|
|
|
### Configuration Quick Reference
|
|
|
|
```json
|
|
{
|
|
"SendGrid": {
|
|
"ApiKey": "${SENDGRID_API_KEY}",
|
|
"FromEmail": "noreply@colaflow.com"
|
|
},
|
|
"Smtp": {
|
|
"Host": "localhost",
|
|
"Port": 1025
|
|
},
|
|
"EmailVerification": {
|
|
"TokenExpirationHours": 24,
|
|
"RequireVerification": false,
|
|
"RateLimitMinutes": 1
|
|
},
|
|
"EmailProvider": "Smtp"
|
|
}
|
|
```
|