# 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