# 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 { public IReadOnlyList Items { get; init; } = Array.Empty(); 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; public class AssignUserRoleCommandHandler : IRequestHandler { private readonly IUserRepository _userRepository; private readonly IUserTenantRoleRepository _roleRepository; private readonly ITenantRepository _tenantRepository; private readonly ILogger _logger; public async Task 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; public class UpdateUserRoleCommandHandler : IRequestHandler { private readonly IUserRepository _userRepository; private readonly IUserTenantRoleRepository _roleRepository; private readonly ILogger _logger; public async Task 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; public class RemoveUserFromTenantCommandHandler : IRequestHandler { private readonly IUserRepository _userRepository; private readonly IUserTenantRoleRepository _roleRepository; private readonly IRefreshTokenRepository _refreshTokenRepository; private readonly ILogger _logger; public async Task 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>; public class ListTenantUsersQueryHandler : IRequestHandler> { private readonly IUserRepository _userRepository; private readonly IUserTenantRoleRepository _roleRepository; public async Task> 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 { 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 CountByTenantAndRoleAsync( Guid tenantId, TenantRole role, CancellationToken cancellationToken = default); ``` **Implementation**: `UserTenantRoleRepository.cs` ```csharp // Add to existing repository public async Task 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> GetByIdsAsync( IEnumerable 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 _logger; public TenantUsersController(IMediator mediator, ILogger logger) { _mediator = mediator; _logger = logger; } /// /// List all users in tenant /// [HttpGet] [Authorize(Roles = "TenantOwner,TenantAdmin")] [ProducesResponseType(typeof(PagedResult), 200)] public async Task>> 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); } /// /// Assign role to user (creates new role assignment) /// [HttpPost("{userId:guid}/role")] [Authorize(Roles = "TenantOwner")] [ProducesResponseType(typeof(UserWithRoleDto), 200)] [ProducesResponseType(400)] [ProducesResponseType(403)] [ProducesResponseType(409)] public async Task> 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 }); } } /// /// Update user's role /// [HttpPut("{userId:guid}/role")] [Authorize(Roles = "TenantOwner")] [ProducesResponseType(typeof(UserWithRoleDto), 200)] public async Task> 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 }); } } /// /// Remove user from tenant (deletes role assignment) /// [HttpDelete("{userId:guid}/role")] [Authorize(Roles = "TenantOwner")] [ProducesResponseType(204)] public async Task 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 { /// /// Send email verification email /// Task SendEmailVerificationAsync( string recipientEmail, string recipientName, string verificationToken, string tenantSlug, CancellationToken cancellationToken = default); /// /// Send password reset email /// Task SendPasswordResetAsync( string recipientEmail, string recipientName, string resetToken, string tenantSlug, CancellationToken cancellationToken = default); /// /// Send welcome email after verification /// 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 _logger; private readonly SendGridClient _client; public SendGridEmailService( IConfiguration configuration, ILogger 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 = $@"

Welcome to ColaFlow!

Hello {recipientName},

Thank you for registering with ColaFlow. Please verify your email address to complete your registration.

Verify Email Address

Or copy and paste this link into your browser:

{verificationUrl}

This link expires in 24 hours.

If you didn't create this account, please ignore this email.

© 2025 ColaFlow. All rights reserved.

"; 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 _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 = $"

Please verify your email:

Verify Email

" }; message.Body = bodyBuilder.ToMessageBody(); using var client = new SmtpClient(); await client.ConnectAsync( _configuration["Smtp:Host"] ?? "localhost", _configuration.GetValue("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; public class VerifyEmailCommandHandler : IRequestHandler { private readonly IUserRepository _userRepository; private readonly ITenantRepository _tenantRepository; private readonly IEmailService _emailService; private readonly ILogger _logger; public async Task 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; public class ResendVerificationEmailCommandHandler : IRequestHandler { private readonly IUserRepository _userRepository; private readonly ITenantRepository _tenantRepository; private readonly IEmailService _emailService; private readonly IEmailRateLimiter _rateLimiter; private readonly ILogger _logger; public async Task 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 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 _logger; public async Task 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 /// /// Verify email address /// [HttpGet("verify-email")] [AllowAnonymous] [ProducesResponseType(302)] // Redirect public async Task 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"); } } /// /// Resend verification email /// [HttpPost("resend-verification")] [AllowAnonymous] [ProducesResponseType(200)] public async Task 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 }); } /// /// Check if email is verified /// [HttpGet("email-status")] [Authorize] [ProducesResponseType(typeof(EmailStatusDto), 200)] public async Task> 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 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(); } else if (emailProvider == "Smtp") { services.AddScoped(); } else { // Default to SMTP for development services.AddScoped(); } // Rate limiter services.AddScoped(); 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( 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(type: "uuid", nullable: false), email = table.Column(type: "character varying(255)", nullable: false), tenant_id = table.Column(type: "uuid", nullable: false), operation_type = table.Column(type: "character varying(50)", nullable: false), last_sent_at = table.Column(type: "timestamp without time zone", nullable: false), attempts_count = table.Column(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 ``` --- ## 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`) - 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> { [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(); 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> { [Fact] public async Task RegisterTenant_ShouldSendVerificationEmail() { // Arrange var emailService = _factory.Services.GetRequiredService(); 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(); 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> 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 { 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 /// /// List all users in tenant with their assigned roles /// /// Tenant ID /// Filter and pagination options /// Returns paginated list of users with roles /// User does not have permission to list users [HttpGet] [Authorize(Roles = "TenantOwner,TenantAdmin")] [ProducesResponseType(typeof(PagedResult), 200)] [ProducesResponseType(403)] public async Task>> 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" } ```