Implemented all 3 critical fixes identified in Day 6 Architecture Gap Analysis:
**Fix 1: UpdateUserRole Feature (RESTful PUT endpoint)**
- Created UpdateUserRoleCommand and UpdateUserRoleCommandHandler
- Added PUT /api/tenants/{tenantId}/users/{userId}/role endpoint
- Implements self-demotion prevention (cannot demote self from TenantOwner)
- Implements last owner protection (cannot remove last TenantOwner)
- Returns UserWithRoleDto with updated role information
- Follows RESTful best practices (PUT for updates)
**Fix 2: Last TenantOwner Deletion Prevention (Security)**
- Verified CountByTenantAndRoleAsync repository method exists
- Verified IsLastTenantOwnerAsync validation in RemoveUserFromTenantCommandHandler
- UpdateUserRoleCommandHandler now prevents:
* Self-demotion from TenantOwner role
* Removing the last TenantOwner from tenant
- SECURITY: Prevents tenant from becoming ownerless (critical vulnerability fix)
**Fix 3: Database-Backed Rate Limiting (Security & Reliability)**
- Created EmailRateLimit entity with proper domain logic
- Added EmailRateLimitConfiguration for EF Core
- Implemented DatabaseEmailRateLimiter service (replaces MemoryRateLimitService)
- Updated DependencyInjection to use database-backed implementation
- Created database migration: AddEmailRateLimitsTable
- Added composite unique index on (email, tenant_id, operation_type)
- SECURITY: Rate limit state persists across server restarts (prevents email bombing)
- Implements cleanup logic for expired rate limit records
**Testing:**
- Added 9 comprehensive integration tests in Day8GapFixesTests.cs
- Fix 1: 3 tests (valid update, self-demote prevention, idempotency)
- Fix 2: 3 tests (remove last owner fails, update last owner fails, remove 2nd-to-last succeeds)
- Fix 3: 3 tests (persists across requests, expiry after window, prevents bulk emails)
- 6 tests passing, 3 skipped (long-running/environment-specific tests)
**Files Changed:**
- 6 new files created
- 6 existing files modified
- 1 database migration added
- All existing tests still pass (no regressions)
**Verification:**
- Build succeeds with no errors
- All critical business logic tests pass
- Database migration generated successfully
- Security vulnerabilities addressed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
529 lines
21 KiB
C#
529 lines
21 KiB
C#
// <auto-generated />
|
|
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("20251103221054_AddEmailRateLimitsTable")]
|
|
partial class AddEmailRateLimitsTable
|
|
{
|
|
/// <inheritdoc />
|
|
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<Guid>("Id")
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("id");
|
|
|
|
b.Property<DateTime?>("AcceptedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("accepted_at");
|
|
|
|
b.Property<DateTime>("CreatedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("created_at");
|
|
|
|
b.Property<string>("Email")
|
|
.IsRequired()
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("email");
|
|
|
|
b.Property<DateTime>("ExpiresAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("expires_at");
|
|
|
|
b.Property<Guid>("InvitedBy")
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("invited_by");
|
|
|
|
b.Property<string>("Role")
|
|
.IsRequired()
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("role");
|
|
|
|
b.Property<Guid>("TenantId")
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("tenant_id");
|
|
|
|
b.Property<string>("TokenHash")
|
|
.IsRequired()
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("token_hash");
|
|
|
|
b.Property<DateTime>("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<Guid>("Id")
|
|
.ValueGeneratedOnAdd()
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("id");
|
|
|
|
b.Property<DateTime>("CreatedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("created_at");
|
|
|
|
b.Property<int>("MaxProjects")
|
|
.HasColumnType("integer")
|
|
.HasColumnName("max_projects");
|
|
|
|
b.Property<int>("MaxStorageGB")
|
|
.HasColumnType("integer")
|
|
.HasColumnName("max_storage_gb");
|
|
|
|
b.Property<int>("MaxUsers")
|
|
.HasColumnType("integer")
|
|
.HasColumnName("max_users");
|
|
|
|
b.Property<string>("Name")
|
|
.IsRequired()
|
|
.HasMaxLength(100)
|
|
.HasColumnType("character varying(100)")
|
|
.HasColumnName("name");
|
|
|
|
b.Property<string>("Plan")
|
|
.IsRequired()
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("plan");
|
|
|
|
b.Property<string>("Slug")
|
|
.IsRequired()
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("slug");
|
|
|
|
b.Property<string>("SsoConfig")
|
|
.HasColumnType("jsonb")
|
|
.HasColumnName("sso_config");
|
|
|
|
b.Property<string>("Status")
|
|
.IsRequired()
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("status");
|
|
|
|
b.Property<DateTime?>("SuspendedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("suspended_at");
|
|
|
|
b.Property<string>("SuspensionReason")
|
|
.HasMaxLength(500)
|
|
.HasColumnType("character varying(500)")
|
|
.HasColumnName("suspension_reason");
|
|
|
|
b.Property<DateTime?>("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<Guid>("Id")
|
|
.ValueGeneratedOnAdd()
|
|
.HasColumnType("uuid");
|
|
|
|
b.Property<DateTime>("CreatedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("created_at");
|
|
|
|
b.Property<string>("DeviceInfo")
|
|
.HasMaxLength(500)
|
|
.HasColumnType("character varying(500)")
|
|
.HasColumnName("device_info");
|
|
|
|
b.Property<DateTime>("ExpiresAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("expires_at");
|
|
|
|
b.Property<string>("IpAddress")
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("ip_address");
|
|
|
|
b.Property<string>("ReplacedByToken")
|
|
.HasMaxLength(500)
|
|
.HasColumnType("character varying(500)")
|
|
.HasColumnName("replaced_by_token");
|
|
|
|
b.Property<DateTime?>("RevokedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("revoked_at");
|
|
|
|
b.Property<string>("RevokedReason")
|
|
.HasMaxLength(500)
|
|
.HasColumnType("character varying(500)")
|
|
.HasColumnName("revoked_reason");
|
|
|
|
b.Property<Guid>("TenantId")
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("tenant_id");
|
|
|
|
b.Property<string>("TokenHash")
|
|
.IsRequired()
|
|
.HasMaxLength(500)
|
|
.HasColumnType("character varying(500)")
|
|
.HasColumnName("token_hash");
|
|
|
|
b.Property<string>("UserAgent")
|
|
.HasMaxLength(500)
|
|
.HasColumnType("character varying(500)")
|
|
.HasColumnName("user_agent");
|
|
|
|
b.Property<Guid>("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<Guid>("Id")
|
|
.ValueGeneratedOnAdd()
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("id");
|
|
|
|
b.Property<string>("AuthProvider")
|
|
.IsRequired()
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("auth_provider");
|
|
|
|
b.Property<string>("AvatarUrl")
|
|
.HasMaxLength(500)
|
|
.HasColumnType("character varying(500)")
|
|
.HasColumnName("avatar_url");
|
|
|
|
b.Property<DateTime>("CreatedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("created_at");
|
|
|
|
b.Property<string>("Email")
|
|
.IsRequired()
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("email");
|
|
|
|
b.Property<string>("EmailVerificationToken")
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("email_verification_token");
|
|
|
|
b.Property<DateTime?>("EmailVerifiedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("email_verified_at");
|
|
|
|
b.Property<string>("ExternalEmail")
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("external_email");
|
|
|
|
b.Property<string>("ExternalUserId")
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("external_user_id");
|
|
|
|
b.Property<string>("FullName")
|
|
.IsRequired()
|
|
.HasMaxLength(100)
|
|
.HasColumnType("character varying(100)")
|
|
.HasColumnName("full_name");
|
|
|
|
b.Property<string>("JobTitle")
|
|
.HasMaxLength(100)
|
|
.HasColumnType("character varying(100)")
|
|
.HasColumnName("job_title");
|
|
|
|
b.Property<DateTime?>("LastLoginAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("last_login_at");
|
|
|
|
b.Property<string>("PasswordHash")
|
|
.IsRequired()
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("password_hash");
|
|
|
|
b.Property<string>("PasswordResetToken")
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("password_reset_token");
|
|
|
|
b.Property<DateTime?>("PasswordResetTokenExpiresAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("password_reset_token_expires_at");
|
|
|
|
b.Property<string>("PhoneNumber")
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("phone_number");
|
|
|
|
b.Property<string>("Status")
|
|
.IsRequired()
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("status");
|
|
|
|
b.Property<Guid>("TenantId")
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("tenant_id");
|
|
|
|
b.Property<DateTime?>("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<Guid>("Id")
|
|
.ValueGeneratedOnAdd()
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("id");
|
|
|
|
b.Property<DateTime>("AssignedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("assigned_at");
|
|
|
|
b.Property<Guid?>("AssignedByUserId")
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("assigned_by_user_id");
|
|
|
|
b.Property<string>("Role")
|
|
.IsRequired()
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("role");
|
|
|
|
b.Property<Guid>("TenantId")
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("tenant_id");
|
|
|
|
b.Property<Guid>("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("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<Guid>("Id")
|
|
.ValueGeneratedOnAdd()
|
|
.HasColumnType("uuid");
|
|
|
|
b.Property<int>("AttemptsCount")
|
|
.HasColumnType("integer")
|
|
.HasColumnName("attempts_count");
|
|
|
|
b.Property<string>("Email")
|
|
.IsRequired()
|
|
.HasMaxLength(255)
|
|
.HasColumnType("character varying(255)")
|
|
.HasColumnName("email");
|
|
|
|
b.Property<DateTime>("LastSentAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("last_sent_at");
|
|
|
|
b.Property<string>("OperationType")
|
|
.IsRequired()
|
|
.HasMaxLength(50)
|
|
.HasColumnType("character varying(50)")
|
|
.HasColumnName("operation_type");
|
|
|
|
b.Property<Guid>("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<Guid>("Id")
|
|
.ValueGeneratedOnAdd()
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("id");
|
|
|
|
b.Property<DateTime>("CreatedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("created_at");
|
|
|
|
b.Property<DateTime>("ExpiresAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("expires_at");
|
|
|
|
b.Property<string>("TokenHash")
|
|
.IsRequired()
|
|
.HasMaxLength(64)
|
|
.HasColumnType("character varying(64)")
|
|
.HasColumnName("token_hash");
|
|
|
|
b.Property<Guid>("UserId")
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("user_id");
|
|
|
|
b.Property<DateTime?>("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<Guid>("Id")
|
|
.ValueGeneratedOnAdd()
|
|
.HasColumnType("uuid")
|
|
.HasColumnName("id");
|
|
|
|
b.Property<DateTime>("CreatedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("created_at");
|
|
|
|
b.Property<DateTime>("ExpiresAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("expires_at");
|
|
|
|
b.Property<string>("IpAddress")
|
|
.HasMaxLength(45)
|
|
.HasColumnType("character varying(45)")
|
|
.HasColumnName("ip_address");
|
|
|
|
b.Property<string>("TokenHash")
|
|
.IsRequired()
|
|
.HasMaxLength(64)
|
|
.HasColumnType("character varying(64)")
|
|
.HasColumnName("token_hash");
|
|
|
|
b.Property<DateTime?>("UsedAt")
|
|
.HasColumnType("timestamp with time zone")
|
|
.HasColumnName("used_at");
|
|
|
|
b.Property<string>("UserAgent")
|
|
.HasMaxLength(500)
|
|
.HasColumnType("character varying(500)")
|
|
.HasColumnName("user_agent");
|
|
|
|
b.Property<Guid>("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
|
|
}
|
|
}
|
|
}
|