# Day 7 Technical Architecture
# Email Service & User Management
**Version**: 1.0
**Date**: 2025-11-03
**Sprint**: M1 Sprint 2 - Day 7
**Author**: Architecture Team
**Status**: Ready for Implementation
**Related PRD**: [DAY7-PRD.md](./DAY7-PRD.md)
---
## Table of Contents
1. [Overview](#1-overview)
2. [Technology Stack Decisions](#2-technology-stack-decisions)
3. [Core Architecture Components](#3-core-architecture-components)
4. [Database Schema Design](#4-database-schema-design)
5. [Security Architecture (ADRs)](#5-security-architecture-adrs)
6. [Integration Architecture](#6-integration-architecture)
7. [Implementation Phases](#7-implementation-phases)
8. [Deployment Considerations](#8-deployment-considerations)
---
## 1. Overview
### 1.1 Scope
Day 7 implements the foundation for secure user communication and team collaboration:
| Component | Purpose | Priority |
|-----------|---------|----------|
| **Email Service** | Send transactional emails (verification, reset, invitations) | P0 |
| **Email Verification** | Validate user email ownership | P0 |
| **Password Reset** | Self-service password recovery | P0 |
| **User Invitations** | Team member onboarding | P0 |
### 1.2 Architecture Principles
1. **Abstraction First**: Email provider is pluggable (SendGrid, SMTP, Mock)
2. **Security by Design**: All tokens hashed (SHA-256), short expiration windows
3. **Fail-Safe Operations**: Email delivery failures don't block user actions
4. **Domain-Driven Design**: Token entities are first-class domain objects
5. **Modular Boundaries**: Email service is infrastructure concern, separated from domain logic
### 1.3 System Context
```
┌──────────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Register │ │ ForgotPassword│ │ InviteUser │ │
│ │ Tenant │ │ Command │ │ Command │ │
│ └──────┬──────┘ └──────┬───────┘ └────────┬───────┘ │
└─────────┼─────────────────┼──────────────────┼──────────┘
│ │ │
└─────────────────┼──────────────────┘
↓
┌──────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ ┌───────────────────────────────────────────────────┐ │
│ │ IEmailService (Abstraction) │ │
│ │ + SendEmailAsync(EmailMessage message) │ │
│ └───────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ↓ ↓ ↓ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ │SendGrid │ │ SMTP │ │ Mock │ │
│ │ Service │ │ Service │ │ Service │ │
│ └─────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ IEmailTemplateRenderer │ │
│ │ + RenderAsync(templateName, data) │ │
│ └───────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────┐
│ Domain Layer │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ EmailVerification│ │ PasswordReset │ │
│ │ Token (Entity) │ │ Token (Entity) │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ Invitation │ │
│ │ (Entity) │ │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
---
## 2. Technology Stack Decisions
### 2.1 Email Service Provider
**Decision**: Hybrid approach with **SendGrid as primary, SMTP as fallback**
| Provider | Use Case | Pros | Cons |
|----------|----------|------|------|
| **SendGrid** | Production | • 99.9% delivery rate
• Built-in analytics
• Bounce handling
• 100 emails/day free tier | • External dependency
• Requires API key |
| **SMTP** | Development & Self-hosted | • No external dependencies
• Air-gapped support
• Works with MailHog/Papercut | • Lower delivery rate
• Manual spam management |
| **Mock** | Testing | • No actual sends
• Fast tests
• File logging | • Not for production |
**Implementation Strategy**:
```csharp
// Configuration-driven selection
services.AddScoped(provider =>
{
var config = provider.GetRequiredService();
return config.Provider switch
{
"SendGrid" => new SendGridEmailService(config.SendGrid),
"Smtp" => new SmtpEmailService(config.Smtp),
"Mock" => new MockEmailService(config.MockSettings),
_ => throw new InvalidOperationException($"Unknown email provider: {config.Provider}")
};
});
```
**Rationale**:
- **Production**: SendGrid ensures high deliverability for critical emails (password resets, verifications)
- **Development**: SMTP with MailHog allows offline development without external dependencies
- **Testing**: Mock service enables fast, deterministic unit/integration tests
### 2.2 Email Template Engine
**Decision**: Use **C# String Interpolation + Razor Templates** (Hybrid)
| Option | When to Use | Pros | Cons |
|--------|-------------|------|------|
| **C# String Interpolation** | Simple templates (text-only) | • No dependencies
• Fast rendering
• Type-safe | • Limited HTML support
• No layouts |
| **Razor Templates** | Complex HTML templates | • Full HTML support
• Layout inheritance
• IntelliSense | • RazorLight dependency
• Slower rendering |
**Implementation**:
```csharp
// Simple text emails: String interpolation
public string RenderSimple(string userName, string link) =>
$"Hi {userName},\n\nClick here to verify: {link}";
// Complex HTML emails: Razor
public async Task RenderHtmlAsync(string templateName, object model)
{
var engine = new RazorLightEngineBuilder()
.UseFileSystemProject(Path.Combine(AppContext.BaseDirectory, "EmailTemplates"))
.UseMemoryCachingProvider()
.Build();
return await engine.CompileRenderAsync(templateName, model);
}
```
**Day 7 Recommendation**: Start with **String Interpolation** for simplicity. Upgrade to Razor if HTML complexity grows.
### 2.3 Token Storage & Hashing
**Decision**: Database storage with **SHA-256 hashing**
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| **Storage** | PostgreSQL tables (3 new tables) | • ACID guarantees
• Existing infrastructure
• Easy expiration queries |
| **Hashing Algorithm** | SHA-256 | • Fast (token lookup performance)
• Sufficient for time-limited tokens
• Industry standard |
| **Token Format** | Base64URL-encoded random 256-bit | • URL-safe
• Cryptographically secure
• Collision-resistant |
**Alternative Rejected**: Redis for token storage
- **Reason**: Adds operational complexity for marginal performance gain. PostgreSQL with indexing is sufficient for Day 7 scale.
- **Future**: Revisit for 10M+ tokens/day scenarios.
---
## 3. Core Architecture Components
### 3.1 Email Service Abstraction
#### Interface Design
```csharp
namespace ColaFlow.Modules.Identity.Application.Services;
///
/// Abstraction for sending transactional emails.
/// Implementations: SendGrid, SMTP, Mock (for testing).
///
public interface IEmailService
{
///
/// Send an email asynchronously.
///
/// Email message details
/// Cancellation token
/// EmailSendResult with success status and metadata
Task SendEmailAsync(
EmailMessage message,
CancellationToken cancellationToken = default);
}
///
/// Email message data transfer object.
///
public record EmailMessage(
string ToAddress,
string ToName,
string Subject,
string TextBody,
string? HtmlBody = null,
string? FromAddress = null,
string? FromName = null);
///
/// Result of email send operation.
///
public record EmailSendResult(
bool Success,
string? MessageId = null,
string? ErrorMessage = null);
```
#### Implementation: SendGrid
```csharp
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
public class SendGridEmailService : IEmailService
{
private readonly SendGridClient _client;
private readonly string _defaultFromAddress;
private readonly string _defaultFromName;
private readonly ILogger _logger;
public SendGridEmailService(
SendGridSettings settings,
ILogger logger)
{
_client = new SendGridClient(settings.ApiKey);
_defaultFromAddress = settings.FromAddress;
_defaultFromName = settings.FromName;
_logger = logger;
}
public async Task SendEmailAsync(
EmailMessage message,
CancellationToken cancellationToken = default)
{
try
{
var from = new EmailAddress(
message.FromAddress ?? _defaultFromAddress,
message.FromName ?? _defaultFromName);
var to = new EmailAddress(message.ToAddress, message.ToName);
var msg = MailHelper.CreateSingleEmail(
from,
to,
message.Subject,
message.TextBody,
message.HtmlBody);
var response = await _client.SendEmailAsync(msg, cancellationToken);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation(
"Email sent successfully to {Email}. Subject: {Subject}",
message.ToAddress,
message.Subject);
return new EmailSendResult(
Success: true,
MessageId: response.Headers.GetValues("X-Message-Id").FirstOrDefault());
}
var body = await response.Body.ReadAsStringAsync(cancellationToken);
_logger.LogWarning(
"Email send failed. StatusCode: {StatusCode}, Body: {Body}",
response.StatusCode,
body);
return new EmailSendResult(
Success: false,
ErrorMessage: $"SendGrid returned {response.StatusCode}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception sending email to {Email}", message.ToAddress);
return new EmailSendResult(
Success: false,
ErrorMessage: ex.Message);
}
}
}
```
**Key Design Decisions**:
1. **Non-blocking**: Email failures return error result but don't throw exceptions
2. **Logging**: All sends logged for audit trail
3. **Graceful Degradation**: Commands continue even if email fails (see Integration section)
#### Implementation: SMTP (Simplified)
```csharp
public class SmtpEmailService : IEmailService
{
private readonly SmtpSettings _settings;
private readonly ILogger _logger;
public async Task SendEmailAsync(
EmailMessage message,
CancellationToken cancellationToken = default)
{
try
{
using var smtpClient = new SmtpClient(_settings.Host, _settings.Port)
{
Credentials = new NetworkCredential(_settings.Username, _settings.Password),
EnableSsl = _settings.EnableSsl
};
var mailMessage = new MailMessage(
from: new MailAddress(_settings.FromAddress, _settings.FromName),
to: new MailAddress(message.ToAddress, message.ToName))
{
Subject = message.Subject,
Body = message.HtmlBody ?? message.TextBody,
IsBodyHtml = message.HtmlBody != null
};
await smtpClient.SendMailAsync(mailMessage, cancellationToken);
_logger.LogInformation("Email sent via SMTP to {Email}", message.ToAddress);
return new EmailSendResult(Success: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "SMTP send failed to {Email}", message.ToAddress);
return new EmailSendResult(Success: false, ErrorMessage: ex.Message);
}
}
}
```
### 3.2 Security Token Base Class
All security tokens (verification, reset, invitation) share common characteristics. Use an **abstract base class** for consistency.
```csharp
namespace ColaFlow.Modules.Identity.Domain.Aggregates.SecurityTokens;
///
/// Abstract base class for all time-limited security tokens.
/// Enforces consistent token hashing and expiration logic.
///
public abstract class SecurityToken : Entity
{
///
/// SHA-256 hash of the actual token (never store plaintext token).
///
public string TokenHash { get; protected set; } = string.Empty;
///
/// Token expiration timestamp (UTC).
///
public DateTime ExpiresAt { get; protected set; }
///
/// Timestamp when token was created (UTC).
///
public DateTime CreatedAt { get; protected set; }
///
/// Timestamp when token was used/consumed (UTC). Null if not used.
///
public DateTime? UsedAt { get; protected set; }
///
/// User ID associated with this token.
///
public UserId UserId { get; protected set; } = null!;
///
/// Check if token is expired.
///
public bool IsExpired => DateTime.UtcNow > ExpiresAt;
///
/// Check if token has been used.
///
public bool IsUsed => UsedAt.HasValue;
///
/// Check if token is valid (not expired and not used).
///
public bool IsValid => !IsExpired && !IsUsed;
///
/// Mark token as used (prevents reuse).
///
public void MarkAsUsed()
{
if (IsUsed)
throw new InvalidOperationException("Token has already been used");
if (IsExpired)
throw new InvalidOperationException("Cannot use expired token");
UsedAt = DateTime.UtcNow;
}
///
/// Validate provided token against stored hash.
///
/// The plaintext token to validate
/// True if token matches hash
public bool ValidateToken(string providedToken)
{
if (string.IsNullOrWhiteSpace(providedToken))
return false;
var providedHash = HashToken(providedToken);
return TokenHash == providedHash;
}
///
/// Hash a token using SHA-256.
///
protected static string HashToken(string token)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(token);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
///
/// Generate a cryptographically secure random token (256-bit, base64url-encoded).
///
protected static string GenerateToken()
{
var randomBytes = new byte[32]; // 256 bits
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
// Base64URL encoding (URL-safe, no padding)
return Convert.ToBase64String(randomBytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
}
```
**Design Rationale**:
- **Inheritance over Duplication**: 3 token types share 80% of logic
- **Immutability**: Protected setters prevent external modification
- **Validation**: Centralized expiration/usage checks
- **Security**: Token generation and hashing encapsulated
### 3.3 Domain Entities
#### EmailVerificationToken
```csharp
namespace ColaFlow.Modules.Identity.Domain.Aggregates.SecurityTokens;
///
/// Email verification token entity.
/// Lifetime: 24 hours.
///
public sealed class EmailVerificationToken : SecurityToken
{
///
/// Email address being verified.
///
public Email Email { get; private set; } = null!;
///
/// Tenant ID for multi-tenant isolation.
///
public TenantId TenantId { get; private set; } = null!;
// EF Core constructor
private EmailVerificationToken() : base() { }
///
/// Factory method to create new verification token.
///
/// Tuple of (entity, plaintext token for email)
public static (EmailVerificationToken entity, string plaintextToken) Create(
UserId userId,
Email email,
TenantId tenantId)
{
var plaintextToken = GenerateToken();
var tokenHash = HashToken(plaintextToken);
var entity = new EmailVerificationToken
{
Id = Guid.NewGuid(),
UserId = userId,
Email = email,
TenantId = tenantId,
TokenHash = tokenHash,
ExpiresAt = DateTime.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow
};
return (entity, plaintextToken);
}
}
```
#### PasswordResetToken
```csharp
///
/// Password reset token entity.
/// Lifetime: 1 hour.
///
public sealed class PasswordResetToken : SecurityToken
{
public Email Email { get; private set; } = null!;
public TenantId TenantId { get; private set; } = null!;
// EF Core constructor
private PasswordResetToken() : base() { }
public static (PasswordResetToken entity, string plaintextToken) Create(
UserId userId,
Email email,
TenantId tenantId)
{
var plaintextToken = GenerateToken();
var tokenHash = HashToken(plaintextToken);
var entity = new PasswordResetToken
{
Id = Guid.NewGuid(),
UserId = userId,
Email = email,
TenantId = tenantId,
TokenHash = tokenHash,
ExpiresAt = DateTime.UtcNow.AddHours(1), // Shorter for security
CreatedAt = DateTime.UtcNow
};
return (entity, plaintextToken);
}
}
```
#### Invitation
```csharp
///
/// User invitation entity.
/// Lifetime: 7 days.
///
public sealed class Invitation : SecurityToken
{
public Email InviteeEmail { get; private set; } = null!;
public TenantId TenantId { get; private set; } = null!;
public TenantRole AssignedRole { get; private set; }
public UserId InvitedBy { get; private set; } = null!;
public InvitationStatus Status { get; private set; }
// EF Core constructor
private Invitation() : base() { }
public static (Invitation entity, string plaintextToken) Create(
Email inviteeEmail,
TenantId tenantId,
TenantRole assignedRole,
UserId invitedBy)
{
// Validate role (cannot invite as TenantOwner or AIAgent)
if (assignedRole == TenantRole.TenantOwner || assignedRole == TenantRole.AIAgent)
throw new ArgumentException($"Cannot invite user with role {assignedRole}");
var plaintextToken = GenerateToken();
var tokenHash = HashToken(plaintextToken);
var entity = new Invitation
{
Id = Guid.NewGuid(),
UserId = UserId.Empty, // Will be set when accepted
InviteeEmail = inviteeEmail,
TenantId = tenantId,
AssignedRole = assignedRole,
InvitedBy = invitedBy,
TokenHash = tokenHash,
Status = InvitationStatus.Pending,
ExpiresAt = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow
};
return (entity, plaintextToken);
}
public void Accept(UserId userId)
{
if (Status != InvitationStatus.Pending)
throw new InvalidOperationException($"Invitation is {Status}, cannot accept");
MarkAsUsed();
UserId = userId;
Status = InvitationStatus.Accepted;
}
public void Cancel()
{
if (Status != InvitationStatus.Pending)
throw new InvalidOperationException($"Invitation is {Status}, cannot cancel");
Status = InvitationStatus.Canceled;
}
}
public enum InvitationStatus
{
Pending = 0,
Accepted = 1,
Canceled = 2,
Expired = 3
}
```
### 3.4 Repository Interfaces
```csharp
namespace ColaFlow.Modules.Identity.Domain.Repositories;
public interface IEmailVerificationTokenRepository
{
Task GetByTokenHashAsync(
string tokenHash,
CancellationToken cancellationToken = default);
Task GetActiveByUserIdAsync(
UserId userId,
CancellationToken cancellationToken = default);
Task AddAsync(
EmailVerificationToken token,
CancellationToken cancellationToken = default);
Task UpdateAsync(
EmailVerificationToken token,
CancellationToken cancellationToken = default);
}
public interface IPasswordResetTokenRepository
{
Task GetByTokenHashAsync(
string tokenHash,
CancellationToken cancellationToken = default);
Task InvalidateAllByUserIdAsync(
UserId userId,
CancellationToken cancellationToken = default);
Task AddAsync(
PasswordResetToken token,
CancellationToken cancellationToken = default);
Task UpdateAsync(
PasswordResetToken token,
CancellationToken cancellationToken = default);
}
public interface IInvitationRepository
{
Task GetByTokenHashAsync(
string tokenHash,
CancellationToken cancellationToken = default);
Task GetByIdAsync(
Guid invitationId,
CancellationToken cancellationToken = default);
Task> GetAllByTenantAsync(
TenantId tenantId,
int pageNumber,
int pageSize,
InvitationStatus? status = null,
CancellationToken cancellationToken = default);
Task CountByTenantAsync(
TenantId tenantId,
InvitationStatus? status = null,
CancellationToken cancellationToken = default);
Task AddAsync(
Invitation invitation,
CancellationToken cancellationToken = default);
Task UpdateAsync(
Invitation invitation,
CancellationToken cancellationToken = default);
}
```
### 3.5 Command/Query Structure
#### Commands
```csharp
// Email Verification
public record VerifyEmailCommand(string Token) : IRequest;
public record ResendVerificationEmailCommand(string TenantSlug, string Email) : IRequest;
// Password Reset
public record ForgotPasswordCommand(string TenantSlug, string Email) : IRequest;
public record ResetPasswordCommand(string Token, string NewPassword) : IRequest;
// User Invitation
public record InviteUserCommand(
Guid TenantId,
string Email,
TenantRole Role) : IRequest>;
public record AcceptInvitationCommand(
string Token,
string FullName,
string Password) : IRequest>;
public record CancelInvitationCommand(
Guid TenantId,
Guid InvitationId) : IRequest;
```
#### Queries
```csharp
public record GetTenantInvitationsQuery(
Guid TenantId,
int PageNumber = 1,
int PageSize = 20,
InvitationStatus? Status = null) : IRequest>>;
public record GetInvitationByTokenQuery(
string Token) : IRequest>;
```
---
## 4. Database Schema Design
### 4.1 New Tables
#### email_verification_tokens
```sql
CREATE TABLE email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
token_hash VARCHAR(64) NOT NULL, -- SHA-256 base64
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
used_at TIMESTAMP NULL,
CONSTRAINT fk_email_verification_tokens_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_email_verification_tokens_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- Indexes for performance
CREATE INDEX idx_email_verification_tokens_token_hash
ON email_verification_tokens(token_hash);
CREATE INDEX idx_email_verification_tokens_user_id
ON email_verification_tokens(user_id)
WHERE used_at IS NULL AND expires_at > NOW();
CREATE INDEX idx_email_verification_tokens_expires_at
ON email_verification_tokens(expires_at)
WHERE used_at IS NULL;
```
**Design Notes**:
- **token_hash**: Indexed for O(1) lookup during verification
- **Partial index on user_id**: Only active tokens (performance optimization)
- **Cascade delete**: Remove tokens when user/tenant deleted
- **Expiration index**: Efficient cleanup of expired tokens
#### password_reset_tokens
```sql
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
used_at TIMESTAMP NULL,
CONSTRAINT fk_password_reset_tokens_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_password_reset_tokens_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- Indexes
CREATE INDEX idx_password_reset_tokens_token_hash
ON password_reset_tokens(token_hash);
CREATE INDEX idx_password_reset_tokens_user_id
ON password_reset_tokens(user_id)
WHERE used_at IS NULL AND expires_at > NOW();
CREATE INDEX idx_password_reset_tokens_expires_at
ON password_reset_tokens(expires_at)
WHERE used_at IS NULL;
```
#### invitations
```sql
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
invitee_email VARCHAR(255) NOT NULL,
assigned_role VARCHAR(50) NOT NULL, -- TenantAdmin, Developer, Guest
invited_by_user_id UUID NOT NULL,
token_hash VARCHAR(64) NOT NULL,
status INT NOT NULL DEFAULT 0, -- 0=Pending, 1=Accepted, 2=Canceled, 3=Expired
user_id UUID NULL, -- Set when accepted
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
used_at TIMESTAMP NULL,
CONSTRAINT fk_invitations_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
CONSTRAINT fk_invitations_invited_by
FOREIGN KEY (invited_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_invitations_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT ck_invitations_role
CHECK (assigned_role IN ('TenantAdmin', 'Developer', 'Guest'))
);
-- Indexes
CREATE INDEX idx_invitations_token_hash
ON invitations(token_hash);
CREATE INDEX idx_invitations_tenant_id_status
ON invitations(tenant_id, status);
CREATE UNIQUE INDEX idx_invitations_unique_pending
ON invitations(tenant_id, invitee_email)
WHERE status = 0 AND expires_at > NOW();
CREATE INDEX idx_invitations_expires_at
ON invitations(expires_at)
WHERE status = 0;
```
**Design Notes**:
- **Unique constraint**: Prevent duplicate pending invitations for same email
- **RESTRICT on invited_by**: Prevent deletion of user who sent invitations
- **CHECK constraint**: Enforce valid roles at database level
### 4.2 Schema Changes to Existing Tables
#### users table modifications
```sql
-- Add email verification timestamp (if not already exists)
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP NULL;
-- Remove deprecated columns (if they exist on User entity)
ALTER TABLE users
DROP COLUMN IF EXISTS email_verification_token,
DROP COLUMN IF EXISTS password_reset_token,
DROP COLUMN IF EXISTS password_reset_token_expires_at;
```
**Migration Strategy**:
1. Check existing User entity for deprecated columns
2. Create migration to drop them (data loss acceptable for Day 7, as these were never used)
3. Add `email_verified_at` if missing
### 4.3 EF Core Entity Configurations
#### EmailVerificationTokenConfiguration
```csharp
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
public class EmailVerificationTokenConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
{
builder.ToTable("email_verification_tokens");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id");
builder.Property(t => t.TokenHash)
.HasColumnName("token_hash")
.HasMaxLength(64)
.IsRequired();
builder.Property(t => t.ExpiresAt)
.HasColumnName("expires_at")
.IsRequired();
builder.Property(t => t.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
builder.Property(t => t.UsedAt)
.HasColumnName("used_at");
// Value Objects
builder.Property(t => t.UserId)
.HasColumnName("user_id")
.HasConversion(
id => id.Value,
value => UserId.Create(value))
.IsRequired();
builder.Property(t => t.TenantId)
.HasColumnName("tenant_id")
.HasConversion(
id => id.Value,
value => TenantId.Create(value))
.IsRequired();
builder.Property(t => t.Email)
.HasColumnName("email")
.HasMaxLength(255)
.HasConversion(
email => email.Value,
value => Email.Create(value).Value)
.IsRequired();
// Relationships
builder.HasOne()
.WithMany()
.HasForeignKey(t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne()
.WithMany()
.HasForeignKey(t => t.TenantId)
.OnDelete(DeleteBehavior.Cascade);
// Indexes
builder.HasIndex(t => t.TokenHash)
.HasDatabaseName("idx_email_verification_tokens_token_hash");
builder.HasIndex(t => t.UserId)
.HasDatabaseName("idx_email_verification_tokens_user_id")
.HasFilter("used_at IS NULL AND expires_at > NOW()");
}
}
```
**Note**: Repeat similar configurations for `PasswordResetTokenConfiguration` and `InvitationConfiguration`.
### 4.4 Migration Approach
```bash
# Create migration
cd src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure
dotnet ef migrations add Day7_EmailAndInvitations --context IdentityDbContext --output-dir Persistence/Migrations
# Review generated migration
# Ensure it includes:
# - CREATE TABLE for 3 new tables
# - CREATE INDEX for all specified indexes
# - ALTER TABLE users DROP COLUMN (deprecated token fields)
# - ALTER TABLE users ADD COLUMN email_verified_at
# Apply migration
dotnet ef database update --context IdentityDbContext
```
---
## 5. Security Architecture (ADRs)
### ADR-013: Token Hashing with SHA-256
**Status**: Accepted
**Date**: 2025-11-03
**Context**: We need to securely store security tokens (email verification, password reset, invitations) in the database.
**Decision**: Use SHA-256 for hashing tokens before database storage.
**Rationale**:
| Aspect | SHA-256 | BCrypt | PBKDF2 | Decision |
|--------|---------|--------|--------|----------|
| **Purpose** | Token hashing | Password hashing | Password hashing | ✅ SHA-256 |
| **Speed** | Very fast (~500K ops/sec) | Slow (12 rounds = ~50 ops/sec) | Medium | Fast lookup needed |
| **Collision Resistance** | Excellent (256-bit) | N/A | N/A | Tokens are random |
| **Rainbow Table Resistance** | Not applicable (tokens are random, not user-chosen) | Excellent | Excellent | Not a concern |
| **Use Case Fit** | ✅ Time-limited random tokens | ❌ User passwords | ❌ User passwords | ✅ |
**Why NOT BCrypt?**
- BCrypt is designed for **slow** hashing (defense against brute force)
- Tokens are **already cryptographically random** (256-bit entropy)
- Token expiration provides time-bound security
- Performance: Token lookups happen on every verification/reset request
**Implementation**:
```csharp
protected static string HashToken(string token)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(token);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash); // 44 characters
}
```
**Security Properties**:
- **One-way**: Cannot reverse hash to plaintext token
- **Deterministic**: Same token always produces same hash (lookup possible)
- **Fast**: O(1) database lookup with indexed `token_hash` column
- **Collision-resistant**: 2^256 possible hashes
**Consequences**:
- ✅ High-performance token validation
- ✅ Database breach doesn't expose plaintext tokens
- ✅ Simple implementation (no salt management)
- ⚠️ Tokens must have high entropy (256-bit random)
- ⚠️ Must enforce short expiration windows
---
### ADR-014: Email Enumeration Prevention
**Status**: Accepted
**Date**: 2025-11-03
**Context**: Password reset and email verification endpoints could reveal if an email exists in the system.
**Decision**: Always return success (200 OK) regardless of whether the email exists.
**Vulnerability Scenario**:
```
❌ Bad Implementation:
POST /api/auth/forgot-password { "email": "admin@victim.com" }
→ 404 Not Found: "Email not found"
Attacker learns: admin@victim.com is NOT a registered user
✅ Good Implementation:
POST /api/auth/forgot-password { "email": "admin@victim.com" }
→ 200 OK: "If an account exists, a reset link has been sent"
Attacker learns: Nothing
```
**Implementation Pattern**:
```csharp
public async Task Handle(ForgotPasswordCommand command, CancellationToken ct)
{
// 1. Look up user (internal logic)
var user = await _userRepository.GetByEmailAsync(tenantId, email, ct);
// 2. If user exists, send email (internal)
if (user is not null)
{
var (token, plaintextToken) = PasswordResetToken.Create(user.Id, email, tenantId);
await _tokenRepository.AddAsync(token, ct);
await _emailService.SendPasswordResetEmailAsync(email, plaintextToken, ct);
}
// 3. ALWAYS return success (same response for exists/not-exists)
return Result.Success("If an account exists, a reset link has been sent.");
}
```
**Mitigation Checklist**:
- ✅ Same HTTP status code (200) for exists/not-exists
- ✅ Same response message (generic)
- ✅ Same response time (no timing attacks)
- ✅ Rate limiting (prevent mass enumeration)
- ✅ Audit logging (detect enumeration attempts)
**Trade-offs**:
- ✅ Prevents user enumeration attacks
- ✅ Improves privacy (attackers can't harvest email lists)
- ⚠️ Slightly worse UX (user doesn't know if email was wrong)
- ⚠️ Support burden (users may not realize they used wrong email)
**Related Security Measures**:
- Rate limiting: Max 3 requests per email per hour
- Honeypot emails: Log suspicious patterns (e.g., 100 different emails from same IP)
---
### ADR-015: Rate Limiting Strategy
**Status**: Accepted
**Date**: 2025-11-03
**Context**: Email endpoints are vulnerable to abuse (spam, enumeration, DoS).
**Decision**: Implement multi-layer rate limiting with different strategies per endpoint.
#### Rate Limiting Tiers
| Endpoint | Limit | Window | Scope | Rationale |
|----------|-------|--------|-------|-----------|
| **Verification Email** | 3 requests | 1 hour | Per email | Prevent spam, normal user needs 1-2 max |
| **Password Reset** | 3 requests | 1 hour | Per email | Prevent enumeration, balance security vs UX |
| **User Invitation** | 20 invitations | 1 hour | Per tenant | Prevent bulk spam, allow team onboarding |
| **Accept Invitation** | 5 attempts | 15 minutes | Per token | Prevent brute force (token is 256-bit, near impossible) |
#### Implementation: In-Memory + Redis Hybrid
**Phase 1 (Day 7)**: In-memory rate limiting with `MemoryCache`
```csharp
public class RateLimitingService
{
private readonly IMemoryCache _cache;
public async Task IsAllowedAsync(string key, int maxRequests, TimeSpan window)
{
var cacheKey = $"ratelimit:{key}";
if (_cache.TryGetValue(cacheKey, out int count))
{
if (count >= maxRequests)
return false;
_cache.Set(cacheKey, count + 1, window);
return true;
}
_cache.Set(cacheKey, 1, window);
return true;
}
}
```
**Phase 2 (Post-Day 7)**: Redis-based distributed rate limiting
```csharp
// Use Redis INCR with expiration
public async Task IsAllowedAsync(string key, int maxRequests, TimeSpan window)
{
var redisKey = $"ratelimit:{key}";
var count = await _redis.StringIncrementAsync(redisKey);
if (count == 1)
await _redis.KeyExpireAsync(redisKey, window);
return count <= maxRequests;
}
```
#### Integration with Commands
```csharp
public class ForgotPasswordCommandHandler : IRequestHandler
{
private readonly IRateLimitingService _rateLimiter;
public async Task Handle(ForgotPasswordCommand command, CancellationToken ct)
{
// Rate limit check
var rateLimitKey = $"forgot-password:{command.Email}";
if (!await _rateLimiter.IsAllowedAsync(rateLimitKey, maxRequests: 3, TimeSpan.FromHours(1)))
{
return Result.Failure("Too many password reset requests. Please try again later.");
}
// ... rest of handler logic
}
}
```
**Consequences**:
- ✅ Prevents abuse (spam, DoS, enumeration)
- ✅ Low latency (in-memory for Day 7)
- ✅ Scalable (Redis for distributed systems)
- ⚠️ In-memory limits don't work across multiple API instances (acceptable for Day 7)
- ⚠️ Requires Redis for production multi-instance deployments
---
## 6. Integration Architecture
### 6.1 Email Service Integration Points
#### RegisterTenantCommandHandler (Modified)
```csharp
public class RegisterTenantCommandHandler : IRequestHandler>
{
private readonly IEmailService _emailService;
private readonly IEmailVerificationTokenRepository _tokenRepository;
// ... other dependencies
public async Task> Handle(
RegisterTenantCommand command,
CancellationToken ct)
{
// 1. Create tenant and admin user (existing logic)
var tenant = Tenant.Create(...);
var adminUser = User.CreateLocal(...);
await _tenantRepository.AddAsync(tenant, ct);
await _userRepository.AddAsync(adminUser, ct);
// 2. Generate verification token (NEW)
var (verificationToken, plaintextToken) = EmailVerificationToken.Create(
adminUser.Id,
adminUser.Email,
tenant.Id);
await _tokenRepository.AddAsync(verificationToken, ct);
// 3. Send verification email (NEW - non-blocking)
var emailResult = await _emailService.SendEmailAsync(new EmailMessage(
ToAddress: adminUser.Email.Value,
ToName: adminUser.FullName.Value,
Subject: "Verify your email - ColaFlow",
TextBody: $"Click here to verify: https://app.colaflow.io/verify-email?token={plaintextToken}",
HtmlBody: RenderVerificationEmailHtml(adminUser.FullName.Value, plaintextToken)
), ct);
// 4. Log email failure but don't block registration
if (!emailResult.Success)
{
_logger.LogWarning(
"Failed to send verification email to {Email}: {Error}",
adminUser.Email.Value,
emailResult.ErrorMessage);
}
// 5. Return success (even if email failed)
return Result.Success(MapToDto(tenant, adminUser, jwtToken));
}
}
```
**Key Design Decision**: **Email failure is non-blocking**
- ✅ User registration succeeds even if SendGrid is down
- ✅ User can still login (verification is optional for Day 7)
- ✅ User can request resend later
#### ForgotPasswordCommandHandler (New)
```csharp
public class ForgotPasswordCommandHandler : IRequestHandler
{
public async Task Handle(ForgotPasswordCommand command, CancellationToken ct)
{
// 1. Rate limiting
if (!await _rateLimiter.IsAllowedAsync($"forgot-password:{command.Email}", 3, TimeSpan.FromHours(1)))
return Result.Failure("Too many requests. Try again in 1 hour.");
// 2. Lookup tenant and user
var tenant = await _tenantRepository.GetBySlugAsync(command.TenantSlug, ct);
if (tenant is null)
return Result.Success("If an account exists, a reset link has been sent."); // Enumerate protection
var email = Email.Create(command.Email).Value;
var user = await _userRepository.GetByEmailAsync(tenant.Id, email, ct);
if (user is null)
return Result.Success("If an account exists, a reset link has been sent."); // Enumerate protection
// 3. Invalidate old reset tokens
await _resetTokenRepository.InvalidateAllByUserIdAsync(user.Id, ct);
// 4. Create new reset token
var (resetToken, plaintextToken) = PasswordResetToken.Create(user.Id, email, tenant.Id);
await _resetTokenRepository.AddAsync(resetToken, ct);
// 5. Send reset email
await _emailService.SendEmailAsync(new EmailMessage(
ToAddress: email.Value,
ToName: user.FullName.Value,
Subject: "Reset your password - ColaFlow",
TextBody: $"Reset link: https://app.colaflow.io/reset-password?token={plaintextToken}",
HtmlBody: RenderPasswordResetEmailHtml(user.FullName.Value, plaintextToken)
), ct);
// 6. Always return success (enumerate protection)
return Result.Success("If an account exists, a reset link has been sent.");
}
}
```
#### InviteUserCommandHandler (New)
```csharp
public class InviteUserCommandHandler : IRequestHandler>
{
public async Task> Handle(InviteUserCommand command, CancellationToken ct)
{
// 1. Authorization check (must be TenantOwner or TenantAdmin)
var currentUser = await _currentUserService.GetCurrentUserAsync(ct);
var role = await _roleRepository.GetUserRoleAsync(currentUser.Id, command.TenantId, ct);
if (role != TenantRole.TenantOwner && role != TenantRole.TenantAdmin)
return Result.Failure("Insufficient permissions");
// 2. Validate tenant ownership
if (currentUser.TenantId != command.TenantId)
return Result.Failure("Cross-tenant invitation not allowed");
// 3. Validate email not already member
var email = Email.Create(command.Email).Value;
if (await _userRepository.ExistsByEmailAsync(command.TenantId, email, ct))
return Result.Failure("User already exists in this tenant");
// 4. Check for existing pending invitation
var existingInvitations = await _invitationRepository.GetAllByTenantAsync(
command.TenantId, 1, 100, InvitationStatus.Pending, ct);
if (existingInvitations.Any(i => i.InviteeEmail == email && i.IsValid))
return Result.Failure("A pending invitation already exists for this email");
// 5. Create invitation
var (invitation, plaintextToken) = Invitation.Create(
email,
command.TenantId,
command.Role,
currentUser.Id);
await _invitationRepository.AddAsync(invitation, ct);
// 6. Send invitation email
var tenant = await _tenantRepository.GetByIdAsync(command.TenantId, ct);
await _emailService.SendEmailAsync(new EmailMessage(
ToAddress: email.Value,
ToName: email.Value, // We don't know their name yet
Subject: $"You're invited to join {tenant.Name} on ColaFlow",
TextBody: $"Accept invitation: https://app.colaflow.io/accept-invitation?token={plaintextToken}",
HtmlBody: RenderInvitationEmailHtml(tenant.Name, currentUser.FullName.Value, command.Role, plaintextToken)
), ct);
// 7. Return invitation details
return Result.Success(MapToDto(invitation));
}
}
```
### 6.2 Domain Events Integration
Day 7 introduces new domain events for audit logging and future integrations:
```csharp
// New Events
public record EmailVerifiedEvent(UserId UserId, Email Email, TenantId TenantId, DateTime VerifiedAt);
public record PasswordResetRequestedEvent(UserId UserId, Email Email, TenantId TenantId, string IpAddress);
public record PasswordResetCompletedEvent(UserId UserId, TenantId TenantId);
public record UserInvitedEvent(Guid InvitationId, Email InviteeEmail, TenantId TenantId, UserId InvitedBy, TenantRole Role);
public record InvitationAcceptedEvent(Guid InvitationId, UserId NewUserId, TenantId TenantId);
```
**Event Handlers** (for audit logging):
```csharp
public class EmailVerifiedEventHandler : INotificationHandler
{
private readonly IAuditLogService _auditLog;
public async Task Handle(EmailVerifiedEvent @event, CancellationToken ct)
{
await _auditLog.LogAsync(new AuditLogEntry(
EntityType: "User",
EntityId: @event.UserId.Value,
Action: "EmailVerified",
Details: $"Email {@event.Email.Value} verified",
TenantId: @event.TenantId.Value,
Timestamp: @event.VerifiedAt
), ct);
}
}
```
### 6.3 API Endpoint Integration
New endpoints to add to `AuthController`:
```csharp
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
// Email Verification
[HttpPost("verify-email")]
[AllowAnonymous]
public async Task VerifyEmail(
[FromBody] VerifyEmailRequest request,
CancellationToken ct)
{
var command = new VerifyEmailCommand(request.Token);
var result = await _mediator.Send(command, ct);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
}
[HttpPost("resend-verification")]
[AllowAnonymous]
public async Task ResendVerification(
[FromBody] ResendVerificationRequest request,
CancellationToken ct)
{
var command = new ResendVerificationEmailCommand(request.TenantSlug, request.Email);
var result = await _mediator.Send(command, ct);
return Ok(new { message = "If an account exists, a verification email has been sent." });
}
// Password Reset
[HttpPost("forgot-password")]
[AllowAnonymous]
public async Task ForgotPassword(
[FromBody] ForgotPasswordRequest request,
CancellationToken ct)
{
var command = new ForgotPasswordCommand(request.TenantSlug, request.Email);
var result = await _mediator.Send(command, ct);
return Ok(new { message = "If an account exists, a reset link has been sent." });
}
[HttpPost("reset-password")]
[AllowAnonymous]
public async Task ResetPassword(
[FromBody] ResetPasswordRequest request,
CancellationToken ct)
{
var command = new ResetPasswordCommand(request.Token, request.NewPassword);
var result = await _mediator.Send(command, ct);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
}
}
```
New controller for invitations:
```csharp
[ApiController]
[Route("api/tenants/{tenantId}/invitations")]
[Authorize]
public class InvitationsController : ControllerBase
{
[HttpPost]
[RequireTenantOwner] // Custom authorization policy
public async Task InviteUser(
[FromRoute] Guid tenantId,
[FromBody] InviteUserRequest request,
CancellationToken ct)
{
var command = new InviteUserCommand(tenantId, request.Email, request.Role);
var result = await _mediator.Send(command, ct);
return result.IsSuccess ? CreatedAtAction(nameof(GetInvitation), new { id = result.Value.Id }, result.Value) : BadRequest(result.Error);
}
[HttpGet]
public async Task GetInvitations(
[FromRoute] Guid tenantId,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] InvitationStatus? status = null,
CancellationToken ct)
{
var query = new GetTenantInvitationsQuery(tenantId, pageNumber, pageSize, status);
var result = await _mediator.Send(query, ct);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
}
[HttpDelete("{invitationId}")]
public async Task CancelInvitation(
[FromRoute] Guid tenantId,
[FromRoute] Guid invitationId,
CancellationToken ct)
{
var command = new CancelInvitationCommand(tenantId, invitationId);
var result = await _mediator.Send(command, ct);
return result.IsSuccess ? NoContent() : BadRequest(result.Error);
}
}
[ApiController]
[Route("api/invitations")]
public class PublicInvitationsController : ControllerBase
{
[HttpPost("accept")]
[AllowAnonymous]
public async Task AcceptInvitation(
[FromBody] AcceptInvitationRequest request,
CancellationToken ct)
{
var command = new AcceptInvitationCommand(request.Token, request.FullName, request.Password);
var result = await _mediator.Send(command, ct);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
}
}
```
---
## 7. Implementation Phases
### Phase 1: Email Infrastructure (Priority P0)
**Duration**: 4-6 hours
**Goal**: Get email sending working end-to-end
**Tasks**:
1. ✅ Create `IEmailService` interface
2. ✅ Implement `SendGridEmailService`
3. ✅ Implement `SmtpEmailService`
4. ✅ Implement `MockEmailService` (for tests)
5. ✅ Add email configuration to `appsettings.json`
6. ✅ Create email templates (simple string interpolation for Day 7)
7. ✅ Register services in DI container
8. ✅ Write unit tests for email services
**Acceptance Criteria**:
- [ ] Email service can send test email via SendGrid
- [ ] Email service can send test email via SMTP (MailHog)
- [ ] Mock service logs emails to file in development
- [ ] Configuration is environment-specific
**Testing**:
```bash
# Start MailHog (Docker)
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
# Test SMTP send
curl -X POST http://localhost:5167/api/test/send-email \
-H "Content-Type: application/json" \
-d '{"to": "test@example.com", "subject": "Test", "body": "Hello"}'
# Check MailHog UI: http://localhost:8025
```
---
### Phase 2: Email Verification Flow (Priority P0)
**Duration**: 6-8 hours
**Goal**: Users can verify their email addresses
**Tasks**:
1. ✅ Create `SecurityToken` base class
2. ✅ Create `EmailVerificationToken` entity
3. ✅ Create `IEmailVerificationTokenRepository` interface and implementation
4. ✅ Create EF Core entity configuration
5. ✅ Generate and apply database migration
6. ✅ Create `VerifyEmailCommand` and handler
7. ✅ Create `ResendVerificationEmailCommand` and handler
8. ✅ Modify `RegisterTenantCommandHandler` to send verification email
9. ✅ Add `/api/auth/verify-email` endpoint
10. ✅ Add `/api/auth/resend-verification` endpoint
11. ✅ Write integration tests
**Acceptance Criteria**:
- [ ] Registration sends verification email
- [ ] User can verify email with token
- [ ] Invalid/expired tokens return 400
- [ ] Resend works and invalidates old token
- [ ] Email enumeration is prevented
**Testing**:
```powershell
# 1. Register tenant
$regResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/register" `
-Method Post -ContentType "application/json" `
-Body '{"tenantName":"Test","tenantSlug":"test","adminEmail":"admin@test.com","adminPassword":"Admin@123","adminFullName":"Admin"}'
# 2. Extract token from email logs (in development mode)
$token = "...token-from-log..."
# 3. Verify email
Invoke-RestMethod -Uri "http://localhost:5167/api/auth/verify-email" `
-Method Post -ContentType "application/json" `
-Body "{`"token`":`"$token`"}"
# 4. Test resend
Invoke-RestMethod -Uri "http://localhost:5167/api/auth/resend-verification" `
-Method Post -ContentType "application/json" `
-Body '{"tenantSlug":"test","email":"admin@test.com"}'
```
---
### Phase 3: Password Reset Flow (Priority P0)
**Duration**: 6-8 hours
**Goal**: Users can reset forgotten passwords
**Tasks**:
1. ✅ Create `PasswordResetToken` entity
2. ✅ Create `IPasswordResetTokenRepository` interface and implementation
3. ✅ Create EF Core entity configuration
4. ✅ Generate and apply database migration
5. ✅ Create `ForgotPasswordCommand` and handler
6. ✅ Create `ResetPasswordCommand` and handler
7. ✅ Implement password complexity validation
8. ✅ Implement refresh token revocation on password reset
9. ✅ Add `/api/auth/forgot-password` endpoint
10. ✅ Add `/api/auth/reset-password` endpoint
11. ✅ Write integration tests
**Acceptance Criteria**:
- [ ] User can request password reset
- [ ] Reset email is sent with valid token
- [ ] User can reset password with token
- [ ] Password complexity is enforced
- [ ] All refresh tokens are invalidated on reset
- [ ] Used tokens cannot be reused
- [ ] Email enumeration is prevented
**Testing**:
```powershell
# 1. Request password reset
Invoke-RestMethod -Uri "http://localhost:5167/api/auth/forgot-password" `
-Method Post -ContentType "application/json" `
-Body '{"tenantSlug":"test","email":"admin@test.com"}'
# 2. Extract token from email
$resetToken = "...token-from-email..."
# 3. Reset password
Invoke-RestMethod -Uri "http://localhost:5167/api/auth/reset-password" `
-Method Post -ContentType "application/json" `
-Body "{`"token`":`"$resetToken`",`"newPassword`":`"NewPassword@123`"}"
# 4. Login with new password
Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" `
-Method Post -ContentType "application/json" `
-Body '{"tenantSlug":"test","email":"admin@test.com","password":"NewPassword@123"}'
```
---
### Phase 4: User Invitation System (Priority P0)
**Duration**: 8-10 hours
**Goal**: Tenant owners can invite team members
**Tasks**:
1. ✅ Create `Invitation` entity with `InvitationStatus` enum
2. ✅ Create `IInvitationRepository` interface and implementation
3. ✅ Create EF Core entity configuration
4. ✅ Generate and apply database migration
5. ✅ Create `InviteUserCommand` and handler
6. ✅ Create `AcceptInvitationCommand` and handler
7. ✅ Create `CancelInvitationCommand` and handler
8. ✅ Create `GetTenantInvitationsQuery` and handler
9. ✅ Create authorization policies (`RequireTenantOwner`, `RequireTenantAdmin`)
10. ✅ Add `/api/tenants/{id}/invitations` endpoints (POST, GET, DELETE)
11. ✅ Add `/api/invitations/accept` endpoint
12. ✅ Write integration tests (unblock 3 skipped tests)
**Acceptance Criteria**:
- [ ] Tenant owner can invite user with role
- [ ] Invitation email is sent
- [ ] User can accept invitation and create account
- [ ] Accepting invitation auto-logs user in
- [ ] Cannot invite with TenantOwner or AIAgent role
- [ ] Cannot invite existing members
- [ ] Tenant owner can view pending invitations
- [ ] Tenant owner can cancel pending invitations
- [ ] 3 previously skipped integration tests now pass
**Testing**:
```powershell
# 1. Login as tenant owner
$loginResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" `
-Method Post -ContentType "application/json" `
-Body '{"tenantSlug":"test","email":"admin@test.com","password":"Admin@123"}'
$token = $loginResponse.accessToken
# 2. Invite user
$inviteResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/{tenantId}/invitations" `
-Method Post -ContentType "application/json" `
-Headers @{ Authorization = "Bearer $token" } `
-Body '{"email":"developer@test.com","role":"Developer"}'
# 3. List invitations
$invitations = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/{tenantId}/invitations" `
-Method Get -Headers @{ Authorization = "Bearer $token" }
# 4. Extract token from email
$inviteToken = "...token-from-email..."
# 5. Accept invitation
$acceptResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/invitations/accept" `
-Method Post -ContentType "application/json" `
-Body "{`"token`":`"$inviteToken`",`"fullName`":`"John Developer`",`"password`":`"Dev@123`"}"
# 6. Verify new user can login
Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" `
-Method Post -ContentType "application/json" `
-Body '{"tenantSlug":"test","email":"developer@test.com","password":"Dev@123"}'
```
---
### Phase 5: Polish & Testing (Priority P1)
**Duration**: 4-6 hours
**Goal**: Production readiness
**Tasks**:
1. ✅ Add rate limiting service
2. ✅ Add comprehensive logging
3. ✅ Write email template HTML (upgrade from string interpolation)
4. ✅ Add password complexity validation
5. ✅ Write unit tests for all commands/queries
6. ✅ Write integration tests for all endpoints
7. ✅ Update API documentation (Swagger)
8. ✅ Test edge cases (expired tokens, invalid tokens, etc.)
9. ✅ Load test email sending (ensure SendGrid quota)
10. ✅ Update Day 7 implementation summary
**Acceptance Criteria**:
- [ ] All tests passing (unit + integration)
- [ ] Code coverage >80%
- [ ] Rate limiting working
- [ ] Swagger docs updated
- [ ] Email templates production-ready
- [ ] No compiler warnings
---
## 8. Deployment Considerations
### 8.1 Configuration Management
**appsettings.Development.json**:
```json
{
"EmailSettings": {
"Provider": "Smtp",
"FromAddress": "noreply@colaflow.local",
"FromName": "ColaFlow Development",
"SaveEmailsToFile": true,
"EmailOutputPath": "temp/emails",
"Smtp": {
"Host": "localhost",
"Port": 1025,
"EnableSsl": false
}
}
}
```
**appsettings.Production.json**:
```json
{
"EmailSettings": {
"Provider": "SendGrid",
"FromAddress": "noreply@colaflow.io",
"FromName": "ColaFlow",
"SendGrid": {
"ApiKey": "${SENDGRID_API_KEY}" // Injected from environment variable
}
}
}
```
**Environment Variables** (Production):
```bash
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxx
COLAFLOW_EMAIL_FROM=noreply@colaflow.io
ASPNETCORE_ENVIRONMENT=Production
```
### 8.2 Database Migration Strategy
**Deployment Steps**:
```bash
# 1. Backup production database
pg_dump colaflow_prod > backup_before_day7.sql
# 2. Apply migration (zero-downtime)
dotnet ef database update --context IdentityDbContext --connection "Host=prod-db;Database=colaflow_prod"
# 3. Verify migration
psql colaflow_prod -c "\d email_verification_tokens"
psql colaflow_prod -c "\d password_reset_tokens"
psql colaflow_prod -c "\d invitations"
# 4. Run smoke tests
curl -X POST https://api.colaflow.io/api/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"tenantSlug":"test","email":"test@example.com"}'
```
**Rollback Plan** (if issues occur):
```bash
# 1. Revert database migration
dotnet ef migrations remove --context IdentityDbContext
# 2. Restore from backup
psql colaflow_prod < backup_before_day7.sql
# 3. Redeploy previous API version
```
### 8.3 Monitoring & Alerting
**Metrics to Monitor**:
| Metric | Threshold | Alert |
|--------|-----------|-------|
| Email send failure rate | >5% | Warning |
| Email send failure rate | >20% | Critical |
| Token validation failure rate | >10% | Warning |
| Password reset requests | >1000/hour | Suspicious activity |
| Invitation acceptance rate | <30% | Poor UX indicator |
| Token expiration before use | >50% | Expiration too short |
**Logging Strategy**:
```csharp
// INFO level
_logger.LogInformation("Email sent to {Email} for {Purpose}", email, "EmailVerification");
// WARNING level
_logger.LogWarning("Email send failed to {Email}: {Error}", email, error);
// ERROR level (only for unexpected exceptions)
_logger.LogError(ex, "Unexpected error in email service");
// AUDIT level (security events)
_auditLogger.Log("PASSWORD_RESET_REQUESTED", userId, tenantId, ipAddress);
```
### 8.4 Security Checklist
**Pre-Production**:
- [ ] SendGrid API key stored in Azure Key Vault (not appsettings)
- [ ] HTTPS enforced for all email links
- [ ] Rate limiting enabled (Redis-based for multi-instance)
- [ ] Email enumeration protection verified
- [ ] Token expiration windows validated
- [ ] SQL injection prevention verified (parameterized queries)
- [ ] XSS prevention in email templates (HTML encoding)
- [ ] CORS configured correctly
- [ ] Audit logging enabled for all security events
**Post-Deployment**:
- [ ] Monitor for unusual password reset patterns
- [ ] Monitor for email bounce rates
- [ ] Monitor for failed token validations
- [ ] Review security logs weekly
---
## Summary
### Key Deliverables
| Component | Status | Priority |
|-----------|--------|----------|
| Email Service Abstraction | ✅ Designed | P0 |
| SendGrid Integration | ✅ Designed | P0 |
| Email Verification Flow | ✅ Designed | P0 |
| Password Reset Flow | ✅ Designed | P0 |
| User Invitation System | ✅ Designed | P0 |
| Database Schema (3 tables) | ✅ Designed | P0 |
| Security ADRs (3 ADRs) | ✅ Written | P0 |
| Integration Architecture | ✅ Defined | P0 |
| Implementation Phases | ✅ Planned | P0 |
### Architecture Highlights
1. **Abstraction Layer**: Email provider is pluggable (SendGrid/SMTP/Mock)
2. **Domain-Driven Design**: Security tokens are first-class domain entities with business logic
3. **Security First**: SHA-256 token hashing, email enumeration prevention, rate limiting
4. **Fail-Safe**: Email failures don't block user actions
5. **Scalability**: In-memory rate limiting for Day 7, Redis-ready for production
### Next Steps
1. **Backend Team**: Implement Phase 1-4 in order
2. **Frontend Team**: Build email verification/reset UI (Day 8)
3. **QA Team**: Prepare test scenarios for all 4 features
4. **DevOps Team**: Provision SendGrid account and configure secrets
### Estimated Timeline
- **Phase 1** (Email Infrastructure): 4-6 hours
- **Phase 2** (Email Verification): 6-8 hours
- **Phase 3** (Password Reset): 6-8 hours
- **Phase 4** (Invitations): 8-10 hours
- **Phase 5** (Polish): 4-6 hours
**Total**: 28-38 hours (~4-5 working days for 1 backend engineer)
---
**Document Status**: ✅ Ready for Implementation
**Last Updated**: 2025-11-03
**Review Required**: Backend Lead, Security Team, Product Manager