Skip to content

feat: optimistic concurrency for UserStore.UpdateAsync (#70)#77

Merged
andregoepel merged 1 commit into
mainfrom
fix/updateasync-optimistic-concurrency
Jul 4, 2026
Merged

feat: optimistic concurrency for UserStore.UpdateAsync (#70)#77
andregoepel merged 1 commit into
mainfrom
fix/updateasync-optimistic-concurrency

Conversation

@andregoepel

Copy link
Copy Markdown
Owner

Fixes #70 (the deferred field-level concurrency follow-up).

Problem

UpdateAsync appends a full-state UserUpdated from the caller's snapshot. If a change committed after the caller loaded, this write silently reverts it — e.g. a user enables 2FA while a profile edit (loaded a moment earlier) is in flight; the profile save writes TwoFactorEnabled = false and 2FA is quietly turned back off. The earlier PR serialized the read-modify-write under the exclusive stream lock, but that alone can't tell a stale snapshot from an intended change.

Why not just use the stream version

A raw Marten stream version advances on every event, including the auto-managed lockout increments. Using it as the concurrency token would reject a profile update that merely raced a failed-login count — regressing the deliberate "merge lockout forward" behavior (and its test UpdateAsync_DoesNotRegressConcurrentlyChangedLockoutState).

Approach — a content-version token

  • User.ContentVersion (new) is advanced by the projection on every content-changing event (UserUpdated not marked lockout-only, and UserRestored).
  • UserUpdated gains a LockoutOnly flag that PersistLockoutStateAsync sets, so lockout increments do not bump the content version.
  • UpdateAsync, under the exclusive lock, compares the caller's ContentVersion to the committed one; if it advanced, the snapshot is stale → return IdentityResult.Failed with code ConcurrencyFailure instead of clobbering. Lockout counters are still merged forward, so concurrent failed-login counting never causes a spurious conflict.
  • After a successful write the in-memory ContentVersion is refreshed, so ASP.NET Identity flows that call UpdateAsync twice on the same instance (e.g. ResetAuthenticator: SetTwoFactorEnabledAsync then ResetAuthenticatorKeyAsync) keep working.

Compatibility

Existing projected docs (pre-ContentVersion) deserialize to 0 and self-heal on the next update; because both writers still go through AppendExclusive, concurrent updates are serialized and protected even across that 0 → 1 transition. A projection rebuild recomputes the token consistently.

Tests

New cases in UserStoreTests:

  • stale snapshot → ConcurrencyFailure, first writer's change survives;
  • stale profile edit does not clobber a concurrent 2FA enable;
  • sequential updates on the same instance both succeed;
  • a concurrent lockout increment is not treated as a conflict.

Existing coverage (lockout merge, security-stamp rotation, root invariants, the #70 no-lost-increments test) stays green. Full suite: 187 unit + 68 Blazor + 75 integration; build 0 warnings; CSharpier clean. THREAT-MODEL.md updated.

The generic update path appended a full-state UserUpdated from the caller's
snapshot. If a concurrent change committed after the caller loaded, that write
silently reverted it (e.g. a 2FA enable overwritten by a stale profile save) —
last-writer-wins. The earlier fix serialized the read-modify-write under the
stream lock but still couldn't detect a stale snapshot.

Add a content-version optimistic-concurrency token. A new User.ContentVersion is
advanced by the projection on every content-changing event, but NOT by the
auto-managed lockout increments (UserUpdated gains a LockoutOnly flag the lockout
path sets). UpdateAsync now, under the exclusive stream lock, rejects a write
whose content version no longer matches the committed one (IdentityResult
"ConcurrencyFailure") instead of clobbering; lockout counters are still merged
forward so concurrent failed-login counting never causes a spurious conflict.
After a successful write the in-memory version is refreshed so ASP.NET Identity
flows that call UpdateAsync twice on one instance (e.g. ResetAuthenticator) keep
working.

Existing docs (pre-ContentVersion) deserialize to 0 and self-heal on the next
update; the exclusive lock still serializes concurrent writers across that
transition. Adds tests: stale snapshot rejected, 2FA not clobbered, sequential
same-instance updates succeed, concurrent lockout increment is not a conflict.
@andregoepel
andregoepel merged commit d5c406f into main Jul 4, 2026
3 checks passed
@andregoepel
andregoepel deleted the fix/updateasync-optimistic-concurrency branch July 6, 2026 13:43
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.

[security] UserStore.UpdateAsync lacks optimistic concurrency — silent clobber of security settings

1 participant