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);
+ }
}