diff --git a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs
index fcb6002..e97458f 100644
--- a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs
+++ b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs
@@ -3,6 +3,7 @@ using ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
using ColaFlow.Modules.Identity.Application.Commands.Login;
using ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
using ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
+using ColaFlow.Modules.Identity.Application.Commands.ResendVerificationEmail;
using ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation;
using ColaFlow.Modules.Identity.Application.Services;
using MediatR;
@@ -169,6 +170,30 @@ public class AuthController(
return Ok(new { message = "Email verified successfully" });
}
+ ///
+ /// Resend email verification link
+ /// Always returns success to prevent email enumeration attacks
+ ///
+ [HttpPost("resend-verification")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ResendVerificationResponse), 200)]
+ public async Task ResendVerification([FromBody] ResendVerificationRequest request)
+ {
+ var baseUrl = $"{Request.Scheme}://{Request.Host}";
+
+ var command = new ResendVerificationEmailCommand(
+ request.Email,
+ request.TenantId,
+ baseUrl);
+
+ await mediator.Send(command);
+
+ // Always return success to prevent email enumeration
+ return Ok(new ResendVerificationResponse(
+ Message: "If the email exists, a verification link has been sent.",
+ Success: true));
+ }
+
///
/// Initiate password reset flow (sends email with reset link)
/// Always returns success to prevent email enumeration attacks
@@ -252,6 +277,10 @@ public record LoginRequest(
public record VerifyEmailRequest(string Token);
+public record ResendVerificationRequest(string Email, Guid TenantId);
+
+public record ResendVerificationResponse(string Message, bool Success);
+
public record ForgotPasswordRequest(string Email, string TenantSlug);
public record ResetPasswordRequest(string Token, string NewPassword);
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs
new file mode 100644
index 0000000..135ffbd
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs
@@ -0,0 +1,12 @@
+using MediatR;
+
+namespace ColaFlow.Modules.Identity.Application.Commands.ResendVerificationEmail;
+
+///
+/// Command to resend email verification link
+///
+public sealed record ResendVerificationEmailCommand(
+ string Email,
+ Guid TenantId,
+ string BaseUrl
+) : IRequest;
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs
new file mode 100644
index 0000000..3995744
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs
@@ -0,0 +1,139 @@
+using ColaFlow.Modules.Identity.Application.Services;
+using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
+using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
+using ColaFlow.Modules.Identity.Domain.Entities;
+using ColaFlow.Modules.Identity.Domain.Repositories;
+using ColaFlow.Modules.Identity.Domain.Services;
+using MediatR;
+using Microsoft.Extensions.Logging;
+
+namespace ColaFlow.Modules.Identity.Application.Commands.ResendVerificationEmail;
+
+///
+/// Handler for resending email verification link
+/// Implements security best practices:
+/// - Email enumeration prevention (always returns true)
+/// - Rate limiting (1 email per minute)
+/// - Token rotation (invalidate old token)
+///
+public class ResendVerificationEmailCommandHandler : IRequestHandler
+{
+ private readonly IUserRepository _userRepository;
+ private readonly IEmailVerificationTokenRepository _tokenRepository;
+ private readonly ISecurityTokenService _tokenService;
+ private readonly IEmailService _emailService;
+ private readonly IEmailTemplateService _templateService;
+ private readonly IRateLimitService _rateLimitService;
+ private readonly ILogger _logger;
+
+ public ResendVerificationEmailCommandHandler(
+ IUserRepository userRepository,
+ IEmailVerificationTokenRepository tokenRepository,
+ ISecurityTokenService tokenService,
+ IEmailService emailService,
+ IEmailTemplateService templateService,
+ IRateLimitService rateLimitService,
+ ILogger logger)
+ {
+ _userRepository = userRepository;
+ _tokenRepository = tokenRepository;
+ _tokenService = tokenService;
+ _emailService = emailService;
+ _templateService = templateService;
+ _rateLimitService = rateLimitService;
+ _logger = logger;
+ }
+
+ public async Task Handle(ResendVerificationEmailCommand request, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // 1. Find user by email and tenant (no enumeration - don't reveal if user exists)
+ var email = Email.Create(request.Email);
+ var tenantId = TenantId.Create(request.TenantId);
+ var user = await _userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
+
+ if (user == null)
+ {
+ // Email enumeration prevention: Don't reveal user doesn't exist
+ _logger.LogWarning("Resend verification requested for non-existent email: {Email}", request.Email);
+ return true; // Always return success
+ }
+
+ // 2. Check if already verified (success if so)
+ if (user.IsEmailVerified)
+ {
+ _logger.LogInformation("Email already verified for user {UserId}", user.Id);
+ return true; // Already verified - success
+ }
+
+ // 3. Check rate limit (1 email per minute per address)
+ var rateLimitKey = $"resend-verification:{request.Email}:{request.TenantId}";
+ var isAllowed = await _rateLimitService.IsAllowedAsync(
+ rateLimitKey,
+ maxAttempts: 1,
+ window: TimeSpan.FromMinutes(1),
+ cancellationToken);
+
+ if (!isAllowed)
+ {
+ _logger.LogWarning(
+ "Rate limit exceeded for resend verification: {Email}",
+ request.Email);
+ return true; // Still return success to prevent enumeration
+ }
+
+ // 4. Generate new verification token with SHA-256 hashing
+ var token = _tokenService.GenerateToken();
+ var tokenHash = _tokenService.HashToken(token);
+
+ // 5. Invalidate old tokens by creating new one (token rotation)
+ var verificationToken = EmailVerificationToken.Create(
+ UserId.Create(user.Id),
+ tokenHash,
+ DateTime.UtcNow.AddHours(24)); // 24 hours expiration
+
+ await _tokenRepository.AddAsync(verificationToken, cancellationToken);
+
+ // 6. Send verification email
+ var verificationLink = $"{request.BaseUrl}/verify-email?token={token}";
+ var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
+
+ var emailMessage = new EmailMessage(
+ To: request.Email,
+ Subject: "Verify your email address - ColaFlow",
+ HtmlBody: htmlBody,
+ PlainTextBody: $"Click the link to verify your email: {verificationLink}");
+
+ var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
+
+ if (!success)
+ {
+ _logger.LogWarning(
+ "Failed to send verification email to {Email} for user {UserId}",
+ request.Email,
+ user.Id);
+ }
+ else
+ {
+ _logger.LogInformation(
+ "Verification email resent to {Email} for user {UserId}",
+ request.Email,
+ user.Id);
+ }
+
+ // 7. Always return success (prevent email enumeration)
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "Error resending verification email for {Email}",
+ request.Email);
+
+ // Return true even on error to prevent enumeration
+ return true;
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Dtos/PagedResultDto.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Dtos/PagedResultDto.cs
index b9a1927..052d595 100644
--- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Dtos/PagedResultDto.cs
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Dtos/PagedResultDto.cs
@@ -5,4 +5,8 @@ public record PagedResultDto(
int TotalCount,
int PageNumber,
int PageSize,
- int TotalPages);
+ int TotalPages)
+{
+ public bool HasPreviousPage => PageNumber > 1;
+ public bool HasNextPage => PageNumber < TotalPages;
+};
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/UserTenantRoleConfiguration.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/UserTenantRoleConfiguration.cs
index 2a3d13e..039698f 100644
--- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/UserTenantRoleConfiguration.cs
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/UserTenantRoleConfiguration.cs
@@ -63,6 +63,10 @@ public class UserTenantRoleConfiguration : IEntityTypeConfiguration utr.Role)
.HasDatabaseName("ix_user_tenant_roles_role");
+ // Performance index for tenant + role queries
+ builder.HasIndex("TenantId", "Role")
+ .HasDatabaseName("ix_user_tenant_roles_tenant_role");
+
// Unique constraint
builder.HasIndex("UserId", "TenantId")
.IsUnique()
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103222250_AddUserTenantRolesPerformanceIndex.Designer.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103222250_AddUserTenantRolesPerformanceIndex.Designer.cs
new file mode 100644
index 0000000..45a7410
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103222250_AddUserTenantRolesPerformanceIndex.Designer.cs
@@ -0,0 +1,531 @@
+//
+using System;
+using ColaFlow.Modules.Identity.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(IdentityDbContext))]
+ [Migration("20251103222250_AddUserTenantRolesPerformanceIndex")]
+ partial class AddUserTenantRolesPerformanceIndex
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Invitation", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AcceptedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("accepted_at");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("email");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("InvitedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("invited_by");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("role");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("token_hash");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique()
+ .HasDatabaseName("ix_invitations_token_hash");
+
+ b.HasIndex("TenantId", "Email")
+ .HasDatabaseName("ix_invitations_tenant_id_email");
+
+ b.HasIndex("TenantId", "AcceptedAt", "ExpiresAt")
+ .HasDatabaseName("ix_invitations_tenant_id_status");
+
+ b.ToTable("invitations", (string)null);
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("MaxProjects")
+ .HasColumnType("integer")
+ .HasColumnName("max_projects");
+
+ b.Property("MaxStorageGB")
+ .HasColumnType("integer")
+ .HasColumnName("max_storage_gb");
+
+ b.Property("MaxUsers")
+ .HasColumnType("integer")
+ .HasColumnName("max_users");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("plan");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("slug");
+
+ b.Property("SsoConfig")
+ .HasColumnType("jsonb")
+ .HasColumnName("sso_config");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("status");
+
+ b.Property("SuspendedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("suspended_at");
+
+ b.Property("SuspensionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("suspension_reason");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_tenants_slug");
+
+ b.ToTable("tenants", (string)null);
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DeviceInfo")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("device_info");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("IpAddress")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("ip_address");
+
+ b.Property("ReplacedByToken")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("replaced_by_token");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("RevokedReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("revoked_reason");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserAgent")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("user_agent");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_refresh_tokens_expires_at");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_refresh_tokens_tenant_id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique()
+ .HasDatabaseName("ix_refresh_tokens_token_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_refresh_tokens_user_id");
+
+ b.ToTable("refresh_tokens", "identity");
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthProvider")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("auth_provider");
+
+ b.Property("AvatarUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("avatar_url");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("email");
+
+ b.Property("EmailVerificationToken")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("email_verification_token");
+
+ b.Property("EmailVerifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("email_verified_at");
+
+ b.Property("ExternalEmail")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("external_email");
+
+ b.Property("ExternalUserId")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("external_user_id");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("JobTitle")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("job_title");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("password_hash");
+
+ b.Property("PasswordResetToken")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("password_reset_token");
+
+ b.Property("PasswordResetTokenExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("password_reset_token_expires_at");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("phone_number");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Email")
+ .IsUnique()
+ .HasDatabaseName("ix_users_tenant_id_email");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AssignedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("assigned_at");
+
+ b.Property("AssignedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("assigned_by_user_id");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("role");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Role")
+ .HasDatabaseName("ix_user_tenant_roles_role");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_user_tenant_roles_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_tenant_roles_user_id");
+
+ b.HasIndex("TenantId", "Role")
+ .HasDatabaseName("ix_user_tenant_roles_tenant_role");
+
+ b.HasIndex("UserId", "TenantId")
+ .IsUnique()
+ .HasDatabaseName("uq_user_tenant_roles_user_tenant");
+
+ b.ToTable("user_tenant_roles", "identity");
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.EmailRateLimit", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AttemptsCount")
+ .HasColumnType("integer")
+ .HasColumnName("attempts_count");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("email");
+
+ b.Property("LastSentAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_sent_at");
+
+ b.Property("OperationType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("operation_type");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LastSentAt")
+ .HasDatabaseName("ix_email_rate_limits_last_sent_at");
+
+ b.HasIndex("Email", "TenantId", "OperationType")
+ .IsUnique()
+ .HasDatabaseName("ix_email_rate_limits_email_tenant_operation");
+
+ b.ToTable("email_rate_limits", "identity");
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.EmailVerificationToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("VerifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("verified_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .HasDatabaseName("ix_email_verification_tokens_token_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_email_verification_tokens_user_id");
+
+ b.ToTable("email_verification_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.PasswordResetToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("IpAddress")
+ .HasMaxLength(45)
+ .HasColumnType("character varying(45)")
+ .HasColumnName("ip_address");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property("UserAgent")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("user_agent");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .HasDatabaseName("ix_password_reset_tokens_token_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_password_reset_tokens_user_id");
+
+ b.HasIndex("UserId", "ExpiresAt", "UsedAt")
+ .HasDatabaseName("ix_password_reset_tokens_user_active");
+
+ b.ToTable("password_reset_tokens", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103222250_AddUserTenantRolesPerformanceIndex.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103222250_AddUserTenantRolesPerformanceIndex.cs
new file mode 100644
index 0000000..e20235b
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103222250_AddUserTenantRolesPerformanceIndex.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddUserTenantRolesPerformanceIndex : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateIndex(
+ name: "ix_user_tenant_roles_tenant_role",
+ schema: "identity",
+ table: "user_tenant_roles",
+ columns: new[] { "tenant_id", "role" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "ix_user_tenant_roles_tenant_role",
+ schema: "identity",
+ table: "user_tenant_roles");
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
index 43b00dd..956a017 100644
--- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
@@ -378,6 +378,9 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_user_tenant_roles_user_id");
+ b.HasIndex("TenantId", "Role")
+ .HasDatabaseName("ix_user_tenant_roles_tenant_role");
+
b.HasIndex("UserId", "TenantId")
.IsUnique()
.HasDatabaseName("uq_user_tenant_roles_user_tenant");