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:
- Role Management API (Priority 1) - Enable tenant owners to manage user roles
- 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
- 2. Scenario A: Role Management API
- 3. Scenario B: Email Verification
- 4. Scenario C: Combined Implementation
- 5. Implementation Roadmap
- 6. Risk Assessment
- 7. Testing Strategy
- 8. MCP Integration Considerations
1. Day 5 Recap: What's Already Built
1.1 Existing Infrastructure
Day 5 successfully implemented:
✅ Refresh Token Mechanism
RefreshTokenentity with token family trackingRefreshTokenServicewith rotation and revocation/api/auth/refresh,/api/auth/logout,/api/auth/logout-allendpoints
✅ RBAC System
- 5 tenant-level roles:
TenantOwner,TenantAdmin,TenantMember,TenantGuest,AIAgent UserTenantRoleentity with role assignment tracking- JWT claims include
tenant_rolefor 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:
- Only
TenantOwnercan assign/update/remove roles TenantAdmincan view user list- Users must be in the same tenant as the target user
- Cannot self-demote from
TenantOwner - Cannot remove last
TenantOwner AIAgentrole 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>© 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:
-
Rate Limiting:
- 1 email per minute per email address
- Tracked in database (persistent across restarts)
- Configurable via
EmailVerification:RateLimitMinutes
-
Email Enumeration Prevention:
- Always return success for resend verification (don't reveal if email exists)
- Generic error messages
-
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)
-
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:
-
Morning (4 hours): Role Management API
- Database migration
- Repository methods
- Commands & queries
- Controller
-
Afternoon (4 hours): Email Service Core
- Email service interfaces
- SendGrid implementation
- SMTP fallback
- Rate limiter
-
Next Day Morning (4 hours): Email Verification Flow
- Commands (Verify/Resend)
- Update registration flow
- API endpoints
- Configuration
-
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
IEmailServiceinterface - Implement
SendGridEmailService - Implement
SmtpEmailService - Test email sending locally (SMTP)
14:30 - 16:00: Verification Flow
- Implement
VerifyEmailCommand& handler - Implement
ResendVerificationEmailCommand& handler - Implement
EmailRateLimiter - Update
Userentity with token validation
16:00 - 17:00: Integration
- Update
RegisterTenantCommandHandlerto 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.csCommands/AssignUserRole/AssignUserRoleCommandHandler.csCommands/UpdateUserRole/UpdateUserRoleCommand.csCommands/UpdateUserRole/UpdateUserRoleCommandHandler.csCommands/RemoveUserFromTenant/RemoveUserFromTenantCommand.csCommands/RemoveUserFromTenant/RemoveUserFromTenantCommandHandler.csCommands/VerifyEmail/VerifyEmailCommand.csCommands/VerifyEmail/VerifyEmailCommandHandler.csCommands/ResendVerificationEmail/ResendVerificationEmailCommand.csCommands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.csQueries/ListTenantUsers/ListTenantUsersQuery.csQueries/ListTenantUsers/ListTenantUsersQueryHandler.csServices/IEmailService.csServices/IEmailRateLimiter.csDtos/UserWithRoleDto.csDtos/PagedResult.cs
Infrastructure Layer (5 files):
Services/SendGridEmailService.csServices/SmtpEmailService.csServices/EmailRateLimiter.csPersistence/Configurations/EmailRateLimitConfiguration.csPersistence/Migrations/XXXXXX_Day6RoleManagementAndEmailVerification.cs
API Layer (1 file):
Controllers/TenantUsersController.cs
Tests (2 files):
IntegrationTests/RoleManagementTests.csIntegrationTests/EmailVerificationTests.cs
5.3 Files to Modify
Domain/Aggregates/Users/User.cs(add token validation)Domain/Repositories/IUserRepository.cs(addGetByIdsAsync,GetByEmailVerificationTokenAsync)Domain/Repositories/IUserTenantRoleRepository.cs(addCountByTenantAndRoleAsync)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}/usersreturns paginated user list - POST
/api/tenants/{id}/users/{userId}/roleassigns role - PUT
/api/tenants/{id}/users/{userId}/roleupdates role - DELETE
/api/tenants/{id}/users/{userId}/roleremoves user
- GET
-
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:
- Switch to SMTP fallback in configuration
- Disable email requirement (
EmailVerification:RequireVerification: false) - Allow manual email verification via database update
If role management has bugs:
- Disable TenantUsersController endpoints
- Use database scripts for emergency role changes
- 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:
- Complete Role Management API with proper authorization, validation, and audit trails
- Production-ready Email Verification with SendGrid integration, rate limiting, and security
- Clear implementation roadmap with detailed tasks and time estimates
- Comprehensive testing strategy covering unit, integration, and manual testing
- MCP integration considerations for future AI agent role management
- 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"
}