Summary
PUT /identity/profile is a full-representation update with no concurrency token, so two overlapping self-updates silently lose one another's changes. The last write wins on every field, not just the one the caller meant to change.
Why it happens
UpdateUserCommandHandler assigns every field unconditionally — there is no "leave this alone" sentinel:
// src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs
await _userService.UpdateAsync(
...,
command.FirstName ?? string.Empty,
command.LastName ?? string.Empty,
command.PhoneNumber ?? string.Empty,
...,
command.Locale,
...);
Because a partial body would blank the omitted fields, both front-ends compensate client-side with a read-modify-write: getMyProfile() first, merge, then PUT the merged representation (clients/admin/src/api/users.ts:98, clients/dashboard/src/api/identity.ts:411). That is correct as far as it goes, but the read and the write are two round trips with no server-side check that nothing moved in between. Nothing on the request identifies which version of the resource the caller edited, so the server cannot detect the overlap and has to accept the stale copy.
Reproduction
- Open Settings › Profile and let the form load (this issues the read).
- Before saving, change the UI language from the topbar language switcher. That is its own read-modify-write and it persists the new
locale.
- Save the profile form.
The profile save was built from a snapshot taken before step 2, so it echoes the old locale back and wins. The same shape applies to any pair of concurrent self-updates: two browser tabs, a phone and a desktop, or a slow save racing a fast one. locale is simply the field that made it visible, because the UI reacts to it.
Impact
Low severity, but it is real data loss on a user-facing endpoint, and it gets worse as more fields land on the profile. Today the observable symptom is a language preference that does not survive a reload.
Suggested fix
Add optimistic concurrency to the resource:
- A
RowVersion / xmin concurrency token on the user aggregate, surfaced as an ETag on GET /identity/profile.
PUT /identity/profile honours If-Match and answers 412 Precondition Failed when the token does not match, 428 Precondition Required if the header is absent (or accepts the absence for one release, to stay backward compatible).
- Both front-ends pass the
ETag they read through to the PUT and refetch-and-retry on 412.
This is a contract change to an existing endpoint, which is why it is filed separately rather than folded into #1344.
Context
Found during the adversarial audit of #1344 (i18n). That PR mitigates the visible symptom but does not fix the cause: the topbar stops hydrating the language from the persisted profile once the user has picked a language in-session, so a stale echo no longer changes the UI mid-session. The ponytail: comments in clients/admin/src/components/layout/topbar.tsx and clients/dashboard/src/components/layout/topbar.tsx point here.
Summary
PUT /identity/profileis a full-representation update with no concurrency token, so two overlapping self-updates silently lose one another's changes. The last write wins on every field, not just the one the caller meant to change.Why it happens
UpdateUserCommandHandlerassigns every field unconditionally — there is no "leave this alone" sentinel:Because a partial body would blank the omitted fields, both front-ends compensate client-side with a read-modify-write:
getMyProfile()first, merge, thenPUTthe merged representation (clients/admin/src/api/users.ts:98,clients/dashboard/src/api/identity.ts:411). That is correct as far as it goes, but the read and the write are two round trips with no server-side check that nothing moved in between. Nothing on the request identifies which version of the resource the caller edited, so the server cannot detect the overlap and has to accept the stale copy.Reproduction
locale.The profile save was built from a snapshot taken before step 2, so it echoes the old
localeback and wins. The same shape applies to any pair of concurrent self-updates: two browser tabs, a phone and a desktop, or a slow save racing a fast one.localeis simply the field that made it visible, because the UI reacts to it.Impact
Low severity, but it is real data loss on a user-facing endpoint, and it gets worse as more fields land on the profile. Today the observable symptom is a language preference that does not survive a reload.
Suggested fix
Add optimistic concurrency to the resource:
RowVersion/xminconcurrency token on the user aggregate, surfaced as anETagonGET /identity/profile.PUT /identity/profilehonoursIf-Matchand answers412 Precondition Failedwhen the token does not match,428 Precondition Requiredif the header is absent (or accepts the absence for one release, to stay backward compatible).ETagthey read through to thePUTand refetch-and-retry on412.This is a contract change to an existing endpoint, which is why it is filed separately rather than folded into #1344.
Context
Found during the adversarial audit of #1344 (i18n). That PR mitigates the visible symptom but does not fix the cause: the topbar stops hydrating the language from the persisted profile once the user has picked a language in-session, so a stale echo no longer changes the UI mid-session. The
ponytail:comments inclients/admin/src/components/layout/topbar.tsxandclients/dashboard/src/components/layout/topbar.tsxpoint here.