From 20e8acca10d5e0f9318d0b96275a30566e854d52 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Tue, 3 Nov 2026 22:45:57 +0530 Subject: [PATCH] docs: role claims unique index and insert-race handling Changelog entry and Authorization note for fullstackhero/dotnet-starter-kit#1458 (closes #1435). --- src/content/docs/changelog/index.mdx | 1 + src/content/docs/security/authorization.mdx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index ee7e43dd..eb4daa84 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -13,6 +13,7 @@ Notable changes to the kit, newest first. ## 2026-11-03 +- **Identity: a role can no longer hold the same permission twice (fix, migration).** `identity.RoleClaims` had no uniqueness, so two API instances starting at once after a release that adds a permission, or the tenant seeder overlapping the startup sync, could both insert the same grant and leave a duplicate row behind with nothing in the logs. Authorization was not affected, but the table drifted. The new `AddRoleClaimsUniqueIndex` Identity migration first deletes existing duplicates (keeping the lowest `Id` per `(RoleId, ClaimType, ClaimValue)`) and then adds a unique index on those columns, which replaces the `RoleId`-only index. `RolePermissionSyncer` and `IdentityDbInitializer` treat a unique violation on that index as another writer having inserted the grant first and log it at Information instead of failing, and `PUT /api/v1/identity/{id}/permissions` collapses a permission repeated in the request. Run the DbMigrator `apply` as usual; claims with a `NULL` value are not affected. See [Authorization](/docs/security/authorization/#the-role-permission-syncer), [#1435](https://github.com/fullstackhero/dotnet-starter-kit/issues/1435) and [#1458](https://github.com/fullstackhero/dotnet-starter-kit/pull/1458). - **Eventing: one failing integration-event handler no longer blocks the others (fix).** `InMemoryEventBus` ran an event's handlers in order and stopped at the first exception, so the rest waited for the outbox retry, and if the failing one kept failing until the row dead-lettered they never ran. For `FileStorageKeyChangedIntegrationEvent` that meant a store rejecting Files' superseded-object delete also skipped Catalog's product-image URL rewrite. The bus now runs every handler, then rethrows (the single exception, or an `AggregateException` when several fail) so the outbox still retries; the inbox already records each handler that succeeded, so a retry re-runs only the failed ones. See [Eventing](/docs/building-blocks/eventing/#event-bus-implementations) and [#1423](https://github.com/fullstackhero/dotnet-starter-kit/issues/1423). - **Files: finalize no longer answers `500` for a file it already committed (fix).** `FinalizeUpload` moves an upload presigned before visibility roots existed after the finalize commit; when that move failed the client got a `500` for a file that was already `Available`, and a retried finalize answered `409`. The move is now best-effort: finalize returns the file and logs a warning, the file stays at its legacy key, and `MigrateLegacyPublicFileKeysJob` moves a public one on its next run (a private one stays presigned-only). See [Files](/docs/modules/files/#visibility-and-storage-moves). - **Files: a storage move whose commit fails no longer leaves an orphaned, quota-charged copy (fix).** `FileStorageRelocator` copies the object before committing the row; if the commit then failed, the copy stayed in the bucket, charged to the tenant's `StorageBytes`, with nothing pointing at it. The copy is now deleted again (refunding its bytes), best-effort and logged. diff --git a/src/content/docs/security/authorization.mdx b/src/content/docs/security/authorization.mdx index f1f9191f..679140b6 100644 --- a/src/content/docs/security/authorization.mdx +++ b/src/content/docs/security/authorization.mdx @@ -173,7 +173,7 @@ What the sync removes depends only on the modules the running process loaded, be With `RemoveRevoked` off, the sync still adds missing permissions but removes nothing, and logs that once at startup. Keep the default on the API host, which loads every module. Independently of the setting, a role whose catalog target comes out empty is never stripped: the sync logs a Warning and skips the removal for that role. -Two instances starting at the same time can both try to remove the same claim. The one that loses the race logs it at Information and moves on, because the other instance already wrote the same end state. +Two instances starting at the same time can both try to remove the same claim, or both try to insert the same missing one. `identity.RoleClaims` has a unique index on `(RoleId, ClaimType, ClaimValue)`, so a role holds each permission once and the second insert is rejected by the database. In either case the instance that loses the race logs it at Information and moves on, because the other instance already wrote the same end state. The tenant seeder (`IdentityDbInitializer`) handles a rejected insert the same way, so a seed overlapping the startup sync on a new tenant doesn't fail either. During a rolling deploy, an instance still on a version before this behaviour only adds permissions, so if it restarts it puts a revoked claim back; the claim is removed again at the next start of a new-version instance.