Skip to content

Lost update on PUT /identity/profile: no concurrency token on a full-representation update #1359

Description

@marcelo-maciel

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

  1. Open Settings › Profile and let the form load (this issues the read).
  2. Before saving, change the UI language from the topbar language switcher. That is its own read-modify-write and it persists the new locale.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions