Files
ColaFlow/colaflow-api/DAY6-ARCHITECTURE-DESIGN.md
Yaojia Wang 32a25b3b35 In progress
2025-11-03 20:02:41 +01:00

83 KiB

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.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:

-- 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:

-- 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:

// 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:

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:

// 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

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

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

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

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

// Add to existing interface
Task<int> CountByTenantAndRoleAsync(
    Guid tenantId,
    TenantRole role,
    CancellationToken cancellationToken = default);

Implementation: UserTenantRoleRepository.cs

// 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

// Add to existing interface
Task<IReadOnlyList<User>> GetByIdsAsync(
    IEnumerable<Guid> userIds,
    CancellationToken cancellationToken = default);

2.7 API Layer

New Controller: API/Controllers/TenantUsersController.cs

[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):

// 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:

-- 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):

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

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

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>&copy; 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

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:

// 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

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

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

public interface IEmailRateLimiter
{
    Task<bool> AllowEmailOperationAsync(
        string email,
        Guid tenantId,
        string operationType,
        TimeSpan minInterval,
        CancellationToken cancellationToken = default);
}

Implementation: Infrastructure/Services/EmailRateLimiter.cs

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:

// 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:

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:

{
  "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:

{
  "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:

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:

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

<!-- 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:

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:

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

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

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:

// 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:

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:

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:

// 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:

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

# 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:

{
  "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:

/// <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:

# 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):

# 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

# 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

{
  "SendGrid": {
    "ApiKey": "${SENDGRID_API_KEY}",
    "FromEmail": "noreply@colaflow.com"
  },
  "Smtp": {
    "Host": "localhost",
    "Port": 1025
  },
  "EmailVerification": {
    "TokenExpirationHours": 24,
    "RequireVerification": false,
    "RateLimitMinutes": 1
  },
  "EmailProvider": "Smtp"
}