Skip to content

fix(identity): enforce one row per role claim grant - #1458

Merged
iammukeshm merged 1 commit into
mainfrom
fix/roleclaims-unique-index
Nov 3, 2026
Merged

iammukeshm merged 1 commit into
mainfrom
fix/roleclaims-unique-index

Conversation

@iammukeshm

@iammukeshm iammukeshm commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Problem

identity.RoleClaims had no uniqueness on (RoleId, ClaimType, ClaimValue). Both writers that grant permissions to the built-in roles, RolePermissionSyncer.SyncRoleAsync and IdentityDbInitializer.AssignPermissionsToRoleAsync, read the role's claims and insert the missing ones without a lock. Two API instances starting at the same time after a release that adds a permission, or the seeder overlapping the startup sync on a new tenant, could both insert the same grant and leave a duplicate row, with nothing in the logs. Authorization was not affected, but the table drifted.

Fix

  • A unique index on RoleClaims (RoleId, ClaimType, ClaimValue), configured in ApplicationRoleClaimConfig.
  • Both writers treat a unique violation (23505) on that index as "another writer already inserted it": they clear the change tracker so the next role's save in the same scope doesn't retry the rejected rows (the same approach fix(identity): make the system-role permission sync remove revoked claims #1426 takes for the lost delete race), log at Information through a [LoggerMessage], and continue. Any other database error still throws.
  • RoleService.UpdatePermissionsAsync now collapses a permission repeated in the request. Before, ["x", "x"] silently stored two rows; with the index it would have been a 500.

Migration notes

AddRoleClaimsUniqueIndex (Identity):

  1. Deletes existing duplicates first, keeping the lowest Id per (RoleId, ClaimType, ClaimValue), with a migrationBuilder.Sql statement that runs before the index is created, in the same migration and transaction.
  2. Drops IX_RoleClaims_RoleId and creates the unique IX_RoleClaims_RoleId_ClaimType_ClaimValue. EF drops the old index because the new one leads with RoleId and covers the foreign key.

PostgreSQL treats NULLs as distinct in a unique index, so rows with a NULL ClaimType or ClaimValue never conflict. The dedupe uses plain equality to match exactly what the index enforces, so it leaves those rows alone (the sync already removes null-valued permission claims). The snapshot diff is only the index change plus the ProductVersion annotation bump to 10.0.13; no other model drift.

I also ran the dedupe + index SQL by hand against a throwaway Postgres 17 with three copies of one grant, a grant on a second role, two NULL-valued rows and a different claim type: it deleted the 2 extra copies, kept the lowest Id, left the NULL rows, and the index created cleanly.

Tests

  • RoleClaims_Should_Reject_The_Same_Permission_Twice_On_A_Role: a second insert of the same permission fails with 23505 on the new index, and one row remains.
  • SyncRoleAsync_Should_Treat_A_Lost_Insert_Race_As_Done_And_Keep_Syncing: another writer inserts the same missing permission just before the sync saves; the sync reports no change, exactly one row exists, and the next role in the same scope still syncs.
  • UpdateRolePermissions_Should_Store_A_Repeated_Permission_Once: a repeated permission in the request returns 200 and is stored once.

Results: dotnet build src/FSH.Starter.slnx 0 warnings, 0 errors. Integration.Tests 803/803 passed (migrations applied by the harness), Identity.Tests 348/348, Architecture.Tests 55/55, and the rest of the solution's suites all green.

Docs: fullstackhero/docs#277

Closes #1435. Thanks @marcelo-maciel for the report and the clear write-up.

Concurrent permission writers (two instances starting at once, or the
seeder and the startup sync on a fresh tenant) could both insert the same
permission on a role, because identity.RoleClaims had no uniqueness.

- Migration removes existing duplicates (keeping the lowest Id per
  RoleId, ClaimType, ClaimValue) and then adds a unique index on those
  columns, replacing the RoleId-only index it covers.
- RolePermissionSyncer and IdentityDbInitializer treat a unique violation
  on that index as another writer having inserted the grant first: they
  clear the change tracker, log at Information and continue.
- Role permission updates collapse repeated entries in the request, which
  would otherwise now hit the index.

Closes #1435
@iammukeshm
iammukeshm merged commit 2c3add9 into main Nov 3, 2026
17 checks passed
iammukeshm added a commit to fullstackhero/docs that referenced this pull request Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RoleClaims has no unique constraint on (RoleId, ClaimType, ClaimValue): concurrent permission syncs insert duplicate rows

1 participant