diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20261103170127_AddRoleClaimsUniqueIndex.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20261103170127_AddRoleClaimsUniqueIndex.Designer.cs new file mode 100644 index 0000000000..78872edc34 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20261103170127_AddRoleClaimsUniqueIndex.Designer.cs @@ -0,0 +1,776 @@ +// +using System; +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20261103170127_AddRoleClaimsUniqueIndex")] + partial class AddRoleClaimsUniqueIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName", "TenantId") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "ClaimType", "ClaimValue") + .IsUnique() + .HasDatabaseName("IX_RoleClaims_RoleId_ClaimType_ClaimValue"); + + b.ToTable("RoleClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Locale") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ObjectId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName", "TenantId") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("CreatedOnUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsSystemGroup") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("ModifiedBy"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("ModifiedAt"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Name"); + + b.ToTable("Groups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("GroupId", "RoleId"); + + b.HasIndex("GroupId"); + + b.HasIndex("RoleId"); + + b.ToTable("GroupRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.ImpersonationGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClientId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImpersonatedTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImpersonatedUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImpersonatedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokeReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedByUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.HasIndex("ActorUserId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ActorUserId_StartedAtUtc"); + + b.HasIndex("ImpersonatedTenantId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ImpersonatedTenantId_StartedAtUtc"); + + b.ToTable("ImpersonationGrants", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("AddedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BrowserVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OsVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("GroupRoles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("UserGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Navigation("GroupRoles"); + + b.Navigation("UserGroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20261103170127_AddRoleClaimsUniqueIndex.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20261103170127_AddRoleClaimsUniqueIndex.cs new file mode 100644 index 0000000000..eb696da1f6 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20261103170127_AddRoleClaimsUniqueIndex.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Identity +{ + /// + public partial class AddRoleClaimsUniqueIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Remove the duplicate grants concurrent permission writers left behind, keeping the lowest Id + // per (RoleId, ClaimType, ClaimValue). Must run before the unique index below is created. + // Plain equality matches the index: rows with a NULL ClaimType or ClaimValue never conflict. + migrationBuilder.Sql( + """ + DELETE FROM "identity"."RoleClaims" AS dup + USING "identity"."RoleClaims" AS kept + WHERE dup."RoleId" = kept."RoleId" + AND dup."ClaimType" = kept."ClaimType" + AND dup."ClaimValue" = kept."ClaimValue" + AND dup."Id" > kept."Id"; + """); + + migrationBuilder.DropIndex( + name: "IX_RoleClaims_RoleId", + schema: "identity", + table: "RoleClaims"); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId_ClaimType_ClaimValue", + schema: "identity", + table: "RoleClaims", + columns: new[] { "RoleId", "ClaimType", "ClaimValue" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_RoleClaims_RoleId_ClaimType_ClaimValue", + schema: "identity", + table: "RoleClaims"); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId", + schema: "identity", + table: "RoleClaims", + column: "RoleId"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs index 89d3e50615..4abe26a185 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("ProductVersion", "10.0.13") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -87,7 +87,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("RoleId"); + b.HasIndex("RoleId", "ClaimType", "ClaimValue") + .IsUnique() + .HasDatabaseName("IX_RoleClaims_RoleId_ClaimType_ClaimValue"); b.ToTable("RoleClaims", "identity"); diff --git a/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncer.cs b/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncer.cs index 4db8947b1e..22014bfb6e 100644 --- a/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncer.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncer.cs @@ -90,17 +90,21 @@ internal async Task SyncRoleAsync(string roleName, IReadOnlyList 0 && logger.IsEnabled(LogLevel.Warning)) @@ -127,23 +131,35 @@ internal async Task SyncRoleAsync(string roleName, IReadOnlyList - /// Saves the pending claim changes. Returns false when another instance starting at the same - /// time deleted the same rows first (a delete matched 0 rows): its write already reached the same - /// end state, so this is not an error. + /// Saves the pending claim changes. Another instance starting at the same time can get there first: + /// it deleted the same rows (a delete matched 0 rows) or inserted the same grant (the unique index on + /// role claims rejected ours). Its write already reached the same end state, so neither is an error. /// - private async Task TrySaveAsync(CancellationToken cancellationToken) + private async Task TrySaveAsync(CancellationToken cancellationToken) { try { await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return true; + return SaveOutcome.Saved; } catch (DbUpdateConcurrencyException) { // Drop the failed entries, or the next role's save in this scope would retry them. context.ChangeTracker.Clear(); - return false; + return SaveOutcome.LostDeleteRace; + } + catch (DbUpdateException ex) when (RoleClaimUniqueness.IsDuplicateRoleClaim(ex)) + { + context.ChangeTracker.Clear(); + return SaveOutcome.AlreadyInserted; } } diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs index 7dc1105f5e..eba6ea0cc2 100644 --- a/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs @@ -51,6 +51,13 @@ public void Configure(EntityTypeBuilder builder) builder .ToTable("RoleClaims", IdentityModuleConstants.SchemaName) .IsMultiTenant(); + + // One row per grant. ClaimValue is nullable and PostgreSQL treats NULLs as distinct here, + // which is fine: a null-valued claim grants nothing and the permission sync removes it. + builder + .HasIndex(rc => new { rc.RoleId, rc.ClaimType, rc.ClaimValue }) + .IsUnique() + .HasDatabaseName(RoleClaimUniqueness.IndexName); } } diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs index 5c8623612c..8a5d1c5eb2 100644 --- a/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs @@ -92,12 +92,24 @@ private async Task AssignPermissionsToRoleAsync(IdentityDbContext dbContext, IRe await dbContext.RoleClaims.AddAsync(claim, cancellationToken); } - // Save changes to the database context - if (newClaims.Count != 0) + if (newClaims.Count == 0) + { + return; + } + + try { await dbContext.SaveChangesAsync(cancellationToken); + return; + } + catch (DbUpdateException ex) when (RoleClaimUniqueness.IsDuplicateRoleClaim(ex)) + { + // Another writer (the startup permission sync or a second instance) inserted the same grant + // first, so the role already holds it. Drop the rejected entries so later saves don't retry them. + dbContext.ChangeTracker.Clear(); } + logger.LogRoleClaimsAlreadyInserted(role.Name, multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id); } private async Task SeedSystemGroupsAsync(CancellationToken cancellationToken = default) diff --git a/src/Modules/Identity/Modules.Identity/Data/RoleClaimUniqueness.cs b/src/Modules/Identity/Modules.Identity/Data/RoleClaimUniqueness.cs new file mode 100644 index 0000000000..6c756e17d8 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/RoleClaimUniqueness.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace FSH.Modules.Identity.Data; + +/// +/// The unique index on identity.RoleClaims (RoleId, ClaimType, ClaimValue) and how the permission +/// writers recognise a conflict on it: when two writers (two instances starting at once, or the seeder and +/// the startup sync) insert the same grant, the second insert is rejected and the row already exists. +/// +internal static partial class RoleClaimUniqueness +{ + internal const string IndexName = "IX_RoleClaims_RoleId_ClaimType_ClaimValue"; + + /// True when the save failed only because another writer already inserted the same role claim. + internal static bool IsDuplicateRoleClaim(DbUpdateException exception) => + exception.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: IndexName, + }; + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Permission claims for '{Role}' in tenant '{Tenant}' were already inserted by another writer; skipping")] + internal static partial void LogRoleClaimsAlreadyInserted(this ILogger logger, string? role, string? tenant); +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs index 793ecca4f8..bfef284c6b 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs @@ -224,6 +224,7 @@ private async Task AddNewPermissionsAsync(FshRole role, IList !string.IsNullOrEmpty(p) && !currentClaims.Any(c => c.Value == p)) + .Distinct(StringComparer.Ordinal) .ToList(); foreach (string permission in newPermissions) diff --git a/src/Tests/Integration.Tests/Infrastructure/Dtos.cs b/src/Tests/Integration.Tests/Infrastructure/Dtos.cs index 515ee0e933..4e3c42b6fc 100644 --- a/src/Tests/Integration.Tests/Infrastructure/Dtos.cs +++ b/src/Tests/Integration.Tests/Infrastructure/Dtos.cs @@ -39,6 +39,7 @@ public sealed class RoleDto public string Id { get; set; } = default!; public string Name { get; set; } = default!; public string? Description { get; set; } + public IReadOnlyCollection? Permissions { get; set; } } public sealed class GroupDto diff --git a/src/Tests/Integration.Tests/Tests/Catalog/RolePermissionSyncerTests.cs b/src/Tests/Integration.Tests/Tests/Catalog/RolePermissionSyncerTests.cs index cc5e84565c..51ebb8f1b5 100644 --- a/src/Tests/Integration.Tests/Tests/Catalog/RolePermissionSyncerTests.cs +++ b/src/Tests/Integration.Tests/Tests/Catalog/RolePermissionSyncerTests.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Npgsql; namespace Integration.Tests.Tests.Catalog; @@ -217,17 +218,9 @@ public async Task SyncRoleAsync_Should_Treat_A_Lost_Delete_Race_As_Done_And_Keep // Stands in for a second instance starting at the same time: just before this instance's // first save, it deletes the same row from its own connection. - var otherInstance = new DeleteBeforeFirstSaveInterceptor( + var otherInstance = new CompetingWriteBeforeFirstSaveInterceptor( () => DeleteClaimAsync(rootTenant, racedRole, RetiredPermission)); - var options = new DbContextOptionsBuilder( - sp.GetRequiredService>()) - .AddInterceptors(otherInstance) - .Options; - await using var db = new IdentityDbContext( - sp.GetRequiredService>(), - options, - sp.GetRequiredService>(), - sp.GetRequiredService()); + await using var db = CreateIdentityDbContext(sp, otherInstance); var syncer = ActivatorUtilities.CreateInstance( sp, db, Options.Create(new PermissionSyncOptions { RemoveRevoked = true })); @@ -247,8 +240,99 @@ public async Task SyncRoleAsync_Should_Treat_A_Lost_Delete_Race_As_Done_And_Keep } } + [Fact] + public async Task RoleClaims_Should_Reject_The_Same_Permission_Twice_On_A_Role() + { + var rootTenant = await GetRootTenantAsync(); + string roleName = await CreateProbeRoleAsync(rootTenant, RetiredPermission); + + try + { + var ex = await Should.ThrowAsync( + () => SeedClaimAsync(rootTenant, roleName, ClaimConstants.Permission, RetiredPermission)); + + var pg = ex.InnerException.ShouldBeOfType(); + pg.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation); + pg.ConstraintName.ShouldBe("IX_RoleClaims_RoleId_ClaimType_ClaimValue"); + + var count = await CountClaimRowsAsync(rootTenant, roleName, RetiredPermission); + count.ShouldBe(1, "The database must hold one row per grant."); + } + finally + { + await DeleteRoleAsync(rootTenant, roleName); + } + } + + [Fact] + public async Task SyncRoleAsync_Should_Treat_A_Lost_Insert_Race_As_Done_And_Keep_Syncing() + { + var rootTenant = await GetRootTenantAsync(); + string racedRole = await CreateProbeRoleAsync(rootTenant, RetiredPermission); + string nextRole = await CreateProbeRoleAsync(rootTenant, RetiredPermission); + + try + { + using var scope = _factory.Services.CreateScope(); + var sp = scope.ServiceProvider; + sp.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(rootTenant); + + // Stands in for a second instance starting at the same time: just before this instance's + // first save, it inserts the same missing permission from its own connection. + var otherInstance = new CompetingWriteBeforeFirstSaveInterceptor( + () => SeedClaimAsync(rootTenant, racedRole, ClaimConstants.Permission, ProbePermission.Name)); + await using var db = CreateIdentityDbContext(sp, otherInstance); + var syncer = ActivatorUtilities.CreateInstance( + sp, db, Options.Create(new PermissionSyncOptions { RemoveRevoked = false })); + + int racedChanged = await syncer.SyncRoleAsync(racedRole, [ProbePermission], CancellationToken.None); + int nextChanged = await syncer.SyncRoleAsync(nextRole, [ProbePermission], CancellationToken.None); + + otherInstance.Fired.ShouldBeTrue("Pre-condition: the competing insert must have run."); + racedChanged.ShouldBe(0, "A lost insert race is reported as no change from this instance."); + (await CountClaimRowsAsync(rootTenant, racedRole, ProbePermission.Name)) + .ShouldBe(1, "The other writer's row is the only one; ours must not be retried."); + nextChanged.ShouldBe(1, "The next role in the same scope must still sync."); + var next = await GetClaimsAsync(rootTenant, nextRole, ClaimConstants.Permission); + next.ShouldBe([RetiredPermission, ProbePermission.Name], ignoreOrder: true); + } + finally + { + await DeleteRoleAsync(rootTenant, racedRole); + await DeleteRoleAsync(rootTenant, nextRole); + } + } + // ─── helpers ───────────────────────────────────────────────────── + private static IdentityDbContext CreateIdentityDbContext(IServiceProvider sp, IInterceptor interceptor) + { + var options = new DbContextOptionsBuilder( + sp.GetRequiredService>()) + .AddInterceptors(interceptor) + .Options; + return new IdentityDbContext( + sp.GetRequiredService>(), + options, + sp.GetRequiredService>(), + sp.GetRequiredService()); + } + + private async Task CountClaimRowsAsync(AppTenantInfo tenant, string roleName, string claimValue) + { + using var scope = _factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(tenant); + + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var role = await roleManager.Roles.SingleAsync(r => r.Name == roleName); + + var db = scope.ServiceProvider.GetRequiredService(); + return await db.RoleClaims.CountAsync(rc => + rc.RoleId == role.Id && rc.ClaimType == ClaimConstants.Permission && rc.ClaimValue == claimValue); + } + private async Task RunSyncRoleAsync(AppTenantInfo tenant, string roleName, IReadOnlyList target, bool removeRevoked) { using var scope = _factory.Services.CreateScope(); @@ -305,7 +389,7 @@ private async Task DeleteClaimAsync(AppTenantInfo tenant, string roleName, strin deleted.ShouldBe(1); } - private sealed class DeleteBeforeFirstSaveInterceptor(Func competingDelete) : SaveChangesInterceptor + private sealed class CompetingWriteBeforeFirstSaveInterceptor(Func competingWrite) : SaveChangesInterceptor { public bool Fired { get; private set; } @@ -317,7 +401,7 @@ public override async ValueTask> SavingChangesAsync( if (!Fired) { Fired = true; - await competingDelete(); + await competingWrite(); } return result; diff --git a/src/Tests/Integration.Tests/Tests/Roles/RolePermissionTests.cs b/src/Tests/Integration.Tests/Tests/Roles/RolePermissionTests.cs index 05159cb74e..0b7d619f8c 100644 --- a/src/Tests/Integration.Tests/Tests/Roles/RolePermissionTests.cs +++ b/src/Tests/Integration.Tests/Tests/Roles/RolePermissionTests.cs @@ -52,4 +52,34 @@ public async Task UpdateRolePermissions_Should_ReturnOk_When_PermissionsAreValid response.StatusCode.ShouldBe(HttpStatusCode.OK); } + + [Fact] + public async Task UpdateRolePermissions_Should_Store_A_Repeated_Permission_Once() + { + using var client = await _auth.CreateRootAdminClientAsync(); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + + var createResponse = await client.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/roles", new + { + id = string.Empty, + name = $"PermRole-{uniqueId}", + description = "Role for duplicate permission testing" + }); + var createdRole = await createResponse.DeserializeAsync(); + + // RoleClaims is unique per (RoleId, ClaimType, ClaimValue), so a repeated entry must be collapsed, not a 500. + var response = await client.PutAsJsonAsync( + $"{TestConstants.IdentityBasePath}/{createdRole.Id}/permissions", new + { + roleId = createdRole.Id, + permissions = new[] { "Permissions.AuditTrails.View", "Permissions.AuditTrails.View" } + }); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var getResponse = await client.GetAsync($"{TestConstants.IdentityBasePath}/{createdRole.Id}/permissions"); + var role = await getResponse.DeserializeAsync(); + role.Permissions.ShouldNotBeNull(); + role.Permissions.Count(p => p == "Permissions.AuditTrails.View").ShouldBe(1); + } }