From 3dd9e03ca12ad9aba68f6dfd99edb1970d18d4eb Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:39:03 -0300 Subject: [PATCH] docs(identity): document the profile ETag/If-Match precondition `PUT /identity/profile` gained an optimistic concurrency precondition: `GET /profile` returns the user's `ConcurrencyStamp` as a strong `ETag`, `PUT` honours `If-Match`, and a stale token is answered with `412` instead of overwriting the newer write. Documents the optional-header behaviour, the `*` and weak-validator rules, why a malformed header is a `400`, the guard running ahead of any storage call, and the retry-once guidance for clients (the stamp rotates on writes a user does not perceive as profile edits). Also notes the CORS requirement, since the contract is invisible to a browser on another origin without the `ETag` exposure and the `if-match` allow-entry. Accompanies fullstackhero/dotnet-starter-kit#1366. --- src/content/docs/changelog/index.mdx | 7 +++++- src/content/docs/modules/identity.mdx | 32 ++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 411e58d4..46820106 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastUpdated: 2026-07-13 +lastUpdated: 2026-08-17 description: Release notes and version history for fullstackhero. sidebar: order: 1 @@ -11,6 +11,11 @@ seo: Notable changes to the kit, newest first. +## 2026-08-17 + +- **Identity: two people editing the same profile no longer silently overwrite each other (fix).** `PUT /api/v1/identity/profile` replaces the whole representation and carried no concurrency token, so overlapping saves resolved as last-write-wins with the losing change gone and no error raised anywhere - and because both front-ends compensate with a client-side read-modify-write, a save built on a stale read confidently echoed every old field back over the newer write. `GET /profile` now returns the user's `ConcurrencyStamp` as a strong `ETag`, and `PUT /profile` honours `If-Match`: a stale token is answered with `412 Precondition Failed` and nothing is written. The header is optional, so existing callers are unaffected; `If-Match: *` is accepted, a weak validator never matches (`If-Match` mandates strong comparison), and a malformed header is a `400` rather than a `412` that would trap a client in an unwinnable retry loop. The precondition runs before any storage call, so a rejected update never orphans an uploaded avatar nor clears the current one. Two smaller fixes came with it: Identity's own `ConcurrencyFailure` result, previously surfacing as a generic `500`, now maps to the same `412`, and the sign-in refresh no longer runs when the update failed. No migration - `AspNetUsers.ConcurrencyStamp` was already an EF Core concurrency token. The tenant dashboard sends the header and retries once on `412`, since the stamp also rotates on writes a user doesn't think of as profile edits (a password change, a new avatar, a failed sign-in). See [Identity](/docs/modules/identity/). +- **CORS: the framework now exposes `ETag` and allows `If-Match`.** An `ETag`/`If-Match` contract is invisible to a browser on another origin unless the server says so: `ETag` is not a CORS-safelisted response header, and the kit's policy never called `WithExposedHeaders`, so a front-end read `null` and silently stopped sending the precondition. The policy now exposes `ETag` on both the permissive and restricted branches, and `if-match` ships in `CorsOptions.AllowedHeaders` in `appsettings.json` and `appsettings.Production.json`. If you replace the policy with your own, keep both, otherwise the profile precondition above degrades back to a lost update with no error to notice. + ## 2026-07-13 - **Dashboard: tenants can now edit their own branding from Settings.** A new **Settings → Branding** tab lets a tenant admin holding `Tenants.UpdateTheme` customise their **light and dark palettes** and **brand asset URLs** (logo, dark-mode logo, favicon) with a live preview - mirroring the operator's existing tenant-branding card, but self-service and with no `tenant:` header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API's `403`, surfaced as an error band. Editing is draft-based - a **Reset to defaults** action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin's concurrent change appears on a manual refresh rather than overwriting your form). diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx index fef3d19d..bf1ea04f 100644 --- a/src/content/docs/modules/identity.mdx +++ b/src/content/docs/modules/identity.mdx @@ -1,6 +1,6 @@ --- title: Identity module -lastUpdated: 2026-06-11 +lastUpdated: 2026-08-17 description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement. sidebar: label: Identity @@ -145,6 +145,32 @@ endpoints.MapPost("/users", handler) `EnrollTwoFactorCommand` returns a QR code + secret; `VerifyEnrollTwoFactorCommand(code)` activates; `DisableTwoFactorCommand(currentPassword)` turns it off. +### Profile concurrency + +`PUT /profile` replaces the whole representation, so two overlapping saves used to silently overwrite each other: whoever wrote last won, and the other person's change was gone with no error anywhere. The endpoint now supports an optimistic precondition. + +`GET /profile` returns the user's `ConcurrencyStamp` as a strong `ETag`. ASP.NET Identity already treats that column as an EF Core concurrency token and rotates it on every write, so no extra column and no migration were needed. Echo the tag back as `If-Match` on the `PUT` and the write is rejected with `412 Precondition Failed` when the stored profile has moved on: + +```http +GET /api/v1/identity/profile +→ 200 OK + ETag: "6f9619ff-8b86-d011-b42d-00cf4fc964ff" + +PUT /api/v1/identity/profile +If-Match: "6f9619ff-8b86-d011-b42d-00cf4fc964ff" +→ 200 OK # nobody else wrote in between +→ 412 # someone did; nothing was written +``` + +Details worth knowing: + +- **The header is optional.** A `PUT` without `If-Match` behaves exactly as before, so existing callers keep working. `If-Match: *` is accepted for the same reason: it matches any current representation. +- **Weak validators never match.** `If-Match` mandates the strong comparison function, so `W/"..."` is answered with `412`. +- **A malformed header is a `400`, not a `412`.** A `412` would send a well-behaved client into a refetch-and-retry loop it can never win, because the broken header is its own bug. +- **Nothing is written or deleted before the check.** The precondition runs ahead of the storage calls, so a rejected update never orphans an uploaded avatar and never clears the current one. +- **The token also rotates on writes the user does not think of as profile edits**, such as a password change, a new avatar, or a failed sign-in. A client should therefore refetch and retry once on `412` rather than surface it as a failed save, which is what `clients/dashboard` does. +- **CORS is already configured for it.** `ETag` is not a CORS-safelisted response header, so the framework's CORS policy exposes it explicitly and `if-match` ships in `CorsOptions.AllowedHeaders`. Without both, a browser on another origin cannot read the tag and silently stops sending the precondition. Keep them in place if you replace the policy with your own. + ## Endpoints All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table: @@ -153,8 +179,8 @@ All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy c |---|---|---| | POST | `/token/issue` | Login | | POST | `/token/refresh` | Rotate refresh token | -| GET | `/profile` | Current user profile | -| PUT | `/profile` | Update own profile | +| GET | `/profile` | Current user profile (returns a strong `ETag`) | +| PUT | `/profile` | Update own profile (honours `If-Match`, answers `412` on a stale token) | | PUT | `/profile/image` | Set / clear profile image | | GET | `/permissions` | Current user permissions | | GET | `/permissions/catalog` | All registered permissions (catalog) |