Skip to content

Fix EF UserRepository batched UpdateUserData transactional behavior#7567

Open
carladams1299-lab wants to merge 1 commit intobitwarden:mainfrom
carladams1299-lab:auth/issue-7566-ef-update-user-data-transaction
Open

Fix EF UserRepository batched UpdateUserData transactional behavior#7567
carladams1299-lab wants to merge 1 commit intobitwarden:mainfrom
carladams1299-lab:auth/issue-7566-ef-update-user-data-transaction

Conversation

@carladams1299-lab
Copy link
Copy Markdown

🎟️ Tracking

Resolves #7566.

📔 Objective

Fixes a transactional-correctness bug on the EF code path of UserRepository. UpdateUserDataAsync and SetV2AccountCryptographicStateAsync opened a transaction on a DatabaseContext resolved from one IServiceScope, but each UpdateUserData delegate (SetMasterPassword, SetKeyConnectorUserKey, UpdateMasterPasswordUnlockData) opened its own scope and resolved its own DatabaseContext. Each SaveChangesAsync therefore auto-committed on a different connection from the one that owned the outer transaction, and the absence of a try/catch around the action loop meant a mid-batch throw left earlier delegates' writes durably committed. For master-password / KDF / Key Connector mutations this could leave a user with mismatched cryptographic state (e.g. a new master-password hash without the matching master-key-wrapped user key), locking them out of their vault until manual database reconciliation. Self-hosted EF deployments (Postgres / MySQL / SQLite, plus EF-mode MSSQL) were exposed; cloud (MSSQL/Dapper) was not — see issue #7566 for the full mechanism.

The fix flows the active batch DatabaseContext into each delegate via an AsyncLocal<DatabaseContext?> on the EF UserRepository, so all writes in a batch share the connection that owns the open transaction. The UpdateUserData delegate signature is preserved (no public-API change). Both methods now wrap their action loops in try/catch with explicit RollbackAsync() on failure. When no batch is active, delegates fall back to a fresh scope, preserving the standalone-invocation path used by existing test helpers. The Dapper UserRepository was already correct and is unchanged.

Test plan

  • New integration test UpdateUserDataAsync_WhenSubsequentActionThrows_RollsBackPriorWrites batches [SetMasterPassword, throwing-delegate], asserts the call throws, then re-reads the user and asserts the prior delegate's write was rolled back. Runs against every configured [DatabaseData] provider — proves the fix on EF backends and regression-protects Dapper.
  • All 66 existing unit tests under Core.Test for the consumer commands (MasterPasswordServiceTests, TdeSetPasswordCommandTests, FinishSsoJitProvisionMasterPasswordCommandTests, SetKeyConnectorKeyCommandTests) continue to pass — confirming the IUserRepository contract relied on by production callers is unchanged.

Out of scope

  • Removing Microsoft.Data.SqlClient.SqlConnection? / SqlTransaction? from the public UpdateUserData delegate in IUserRepository. That's an API-shape change discussed as a follow-up in the original issue.
  • The same transactional-boundary fix for the UpdateEncryptedDataForKeyRotation delegate path in UpdateUserKeyAndEncryptedDataAsync / UpdateUserKeyAndEncryptedDataV2Async — separate delegate type with its own callers.

📸 Screenshots

N/A — backend correctness fix.

The EF UserRepository.UpdateUserDataAsync and SetV2AccountCryptographicStateAsync
opened a transaction on a DatabaseContext resolved from the outer scope, but
each UpdateUserData delegate (SetMasterPassword, SetKeyConnectorUserKey,
UpdateMasterPasswordUnlockData) opened its own scope and resolved its own
DatabaseContext. Each SaveChangesAsync auto-committed on a different
connection from the one that owned the outer transaction, so the outer
CommitAsync committed an empty unit of work. There was no try/catch around
the action loop, so a mid-batch throw left earlier delegates' writes durably
committed - leaving a user with cryptographic state that could lock them out
of their vault (e.g. a new master-password hash without the matching
master-key-wrapped user key). Self-hosted EF deployments (Postgres / MySQL /
SQLite, plus EF-mode MSSQL) were exposed; cloud (MSSQL/Dapper) was not.

Fix:
- Add an AsyncLocal<DatabaseContext?> on the EF UserRepository that flows
  the active batch DbContext into each delegate so all writes in a batch
  share the connection that owns the open transaction. The UpdateUserData
  delegate signature is preserved (no public-API change).
- Add try/catch with explicit RollbackAsync() to UpdateUserDataAsync and
  SetV2AccountCryptographicStateAsync.
- Fall back to a fresh scope when no batch is active, preserving the
  standalone-invocation path used by existing test helpers.

Add an integration test that batches [SetMasterPassword, throwing-delegate],
asserts the throw, and verifies the prior write was rolled back. Runs
against every configured [DatabaseData] provider.

Resolves bitwarden#7566
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 30, 2026

CLA assistant check
All committers have signed the CLA.

@bitwarden-bot
Copy link
Copy Markdown

Thank you for your contribution! We've added this to our internal Community PR board for review.
ID: PM-36087
Link: https://bitwarden.atlassian.net/browse/PM-36087

Details on our contribution process can be found here: https://contributing.bitwarden.com/contributing/pull-requests/community-pr-process.

@carladams1299-lab
Copy link
Copy Markdown
Author

Hi, @justindbaur ,
This is my first contribution.
Could you please review my PR?
Thank you.

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.

UserRepository EF UpdateUserDataAsync does not run delegates in a transaction

3 participants