Skip to content

[PM-25520] - Adding a transaction for mutliple repo calls#7555

Draft
jrmccannon wants to merge 24 commits into
mainfrom
jmccannon/ac/poc-transaction
Draft

[PM-25520] - Adding a transaction for mutliple repo calls#7555
jrmccannon wants to merge 24 commits into
mainfrom
jmccannon/ac/poc-transaction

Conversation

@jrmccannon

@jrmccannon jrmccannon commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

PM-25520

📔 Objective

Adding a transaction around subsequent repo calls.

📸 Screenshots

@codecov

codecov Bot commented Apr 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.51913% with 53 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.65%. Comparing base (82e1c1c) to head (e40de74).
⚠️ Report is 158 commits behind head on main.

Files with missing lines Patch % Lines
src/Core/Platform/Data/TransactionState.cs 79.31% 8 Missing and 4 partials ⚠️
...ructure.EntityFramework/Repositories/Repository.cs 44.44% 10 Missing ⚠️
...work/Repositories/BaseEntityFrameworkRepository.cs 81.57% 4 Missing and 3 partials ⚠️
src/Core/Platform/Data/TransactionManagerBase.cs 78.94% 4 Missing ⚠️
...Console/Repositories/OrganizationUserRepository.cs 88.88% 4 Missing ⚠️
...cture.EntityFramework/Data/EfTransactionManager.cs 76.47% 4 Missing ⚠️
src/Core/Platform/Data/NestedTransactionScope.cs 83.33% 2 Missing and 1 partial ⚠️
src/Core/Platform/Data/RootTransactionScope.cs 88.00% 2 Missing and 1 partial ⚠️
...er/AdminConsole/Repositories/ProviderRepository.cs 0.00% 3 Missing ⚠️
...frastructure.Dapper/Repositories/BaseRepository.cs 97.43% 0 Missing and 1 partial ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7555      +/-   ##
==========================================
+ Coverage   61.15%   65.65%   +4.49%     
==========================================
  Files        2175     2188      +13     
  Lines       96792    97381     +589     
  Branches     8730     8786      +56     
==========================================
+ Hits        59196    63938    +4742     
+ Misses      35486    31225    -4261     
- Partials     2110     2218     +108     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Logo
Checkmarx One – Scan Summary & Details6c8e5418-0c83-477c-9bb1-014fe400bb43


New Issues (2) Checkmarx found the following issues in this Pull Request
# Severity Issue Source File / Package Checkmarx Insight
1 MEDIUM CSRF src/Api/Vault/Controllers/CiphersController.cs: 1558
detailsMethod at line 1558 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
Attack Vector
2 MEDIUM CSRF src/Api/Vault/Controllers/CiphersController.cs: 1385
detailsMethod at line 1385 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
Attack Vector

Fixed Issues (27) Great job! The following issues were fixed in this Pull Request
Severity Issue Source File / Package
MEDIUM CSRF src/Api/AdminConsole/Controllers/OrganizationUsersController.cs: 514
MEDIUM CSRF src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs: 55
MEDIUM CSRF src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs: 145
MEDIUM CSRF src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs: 97
MEDIUM CSRF src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs: 229
MEDIUM CSRF src/Api/Tools/Controllers/SendsController.cs: 73
MEDIUM CSRF src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs: 145
MEDIUM CSRF src/Identity/Controllers/AccountsController.cs: 138
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 217
MEDIUM CSRF src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs: 173
MEDIUM CSRF src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs: 104
MEDIUM CSRF src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs: 107
MEDIUM CSRF src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs: 95
MEDIUM CSRF src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs: 82
MEDIUM CSRF src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs: 93
MEDIUM CSRF src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs: 49
MEDIUM CSRF src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs: 40
MEDIUM CSRF src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs: 173
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 721
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 412
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 192
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 126
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 664
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 641
MEDIUM CSRF src/Api/Auth/Controllers/EmergencyAccessController.cs: 173
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 385
MEDIUM CSRF src/Api/Auth/Controllers/AccountsController.cs: 558

@jrmccannon

Copy link
Copy Markdown
Contributor Author

Test is failing due to the fact that its creating a SqlServerTestDatabase and it won't be able to pull the connection string to do so. Unsure of how to proceed with this.

Do we want to add a SQL server db for this test or just skip it in CI?

@eliykat @justindbaur

@eliykat

eliykat commented Apr 30, 2026

Copy link
Copy Markdown
Member

Test is failing due to the fact that its creating a SqlServerTestDatabase and it won't be able to pull the connection string to do so. Unsure of how to proceed with this.

Do we want to add a SQL server db for this test or just skip it in CI?

We definitely don't want to skip it in CI, or it'll never be run in practice.

The CI job does set up and connect to SQL server, e.g. https://github.com/bitwarden/server/blob/master/.github/workflows/test-database.yml#L131-L132.

I assume your issue is caused by the DapperTransactionManager getting its connection string from GlobalSettings. Have a look at how DatabaseDataAttribute works - it injects the integration test database strings into the repository implementations during tests, maybe you can do a similar thing here.

@mzieniukbw mzieniukbw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, i love the direction. There is a bit duplication between EF and Dapper, but generally good direction in my opinion.

Guid organizationId,
ScimProviderType scimProvider)
{
await using var transactionScope = await transactionManager.BeginTransactionAsync(IsolationLevel.Serializable);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Serializable transaction isolation is the strictest you can get, but it have side effects compared to default, like it can throw error if there was a concurrent write (in different transaciton). Is this by choice here ?
On a side note, this changes the business logic, which i wonder, whether this PR should do.

In most cases this transaction isolation is unnecessary, in which case, it is better to begin the transaction just before the first write, not during the get.

One important note, the copy paste code trend is unavoidable and we will see other places using this isolation level for no reason, just because it was used somewhere else. If we would use serializable isolation level by choice here, i think a reasonable comment should be added explaining why it might be needed here.

@bitwarden/dept-dbops Opinions ?

@jrmccannon jrmccannon May 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isolation is needed so that we can get the current seat count and ensure that it won't change before we complete our request.

For this specific command, multiple requests come into SCIM grab the organization at the same time and attempt to add their user. Currently, the orgservice#inviteuser method grabs resources multiple times including calling out to stripe. An optimized command for inviting users was created, but that introduced an issue where multiple requests would race to update the seat count and all set it to the same value instead of incrementing it.

3 requests come in => get Org: { Seats: 2 } and each add a seat to the org.Seat value. Each request would set org.Seat to 3 when in reality they should set it to 5 (assuming both Org.Seats are occupied). There's also the ability for an Org to set a limit (Org.MaxSeats: 4). So two should succeed and one should fail.

The SCIM protocol requires the server tell it whether the user was safely provisioned so we have to succeed or fail and can't just switch it to an asynchronous add.

With serialized, this would ensure that the seat count is updated correctly as the requests come in.

So that was the reason for this approach. I'd be interested in another way as this is very heavy handed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that serializable isolation level should be avoided if possible. A couple other options are:

  1. Query the data with an UPDATE lock, which locks only that row rather than everything involved in the transaction.
  • Pros: Simple, no schema changes needed
  • Cons: You have to write/maintain 4 separate queries (one for each DB)
  1. Use a row version column
  • Pros: Supported natively in SQL Server and EF, can be reused for future concurrency checks
  • Cons: Requires new column on table

I'd recommend the row version column, since it requires minimal code changes and retains optimistic concurrency. The main caveat is that instead of blocking other callers upfront, each caller proceeds as normal and you deal with race conditions (if any) afterwards, like this:

  1. Read the org row — you get back Seats = 9 and RowVersion = 42
  2. Check the seat limit in C# — passes
  3. Issue the update: UPDATE Organization SET Seats = 10 WHERE Id = @id AND RowVersion = 42
  4. If 1 row affected — you won the race, commit
  5. If 0 rows affected — someone else updated the row between your read and your write; RowVersion is no longer 42, so the WHERE clause matched nothing

Step 5 is the conflict. EF Core detects the 0-rows-affected case and throws DbUpdateConcurrencyException.

try
{
    await _organizationRepository.IncrementSeatCountAsync(org);
}
catch (DbUpdateConcurrencyException)
{
    // Option A: retry — reload the org and try again
    // Option B: surface a conflict error to the SCIM caller
    throw new ConflictException("Organization seat count changed concurrently, please retry.");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a branch i was trying something similar with Polly in order to not just fail everything. I'll give that a try.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mkincaid-bw I would also prefer to avoid SERIALIZABLE and use an optimistic solution (even at the expense of a bit of hand-crafting), however we went down this path at @withinfocus 's direction. I would prefer Architecture's confirmation before we spend more time exploring other solutions. (Thoughts on the above suggestions @withinfocus ?)

Comment thread src/Infrastructure.EntityFramework/Data/EfTransactionManager.cs Outdated
@jrmccannon
jrmccannon requested a review from mzieniukbw May 11, 2026 20:12
@sonarqubecloud

Copy link
Copy Markdown

/// Executes an action using the ambient transaction connection (if active) or a new
/// owned connection. The connection is opened and disposed automatically when owned.
/// </summary>
protected async Task<TResult> ExecuteWithConnectionAsync<TResult>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need solid integration test coverage with ITransactionManager and without it, for botj Dapper and EF. Include all edge cases and databases. Without it, it's hard to say if it is working as expected / stable to use in prod.

/// or creates a new owned connection. The caller must dispose the connection only if
/// <c>Owned</c> is true.
/// </summary>
protected (SqlConnection Connection, DbTransaction? Transaction, bool Owned) GetConnection()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be private? Looks like it should just be a helper for the methods below.

Comment on lines +40 to +48
private (DatabaseContext DbContext, IServiceScope? OwnedScope) GetDatabaseContextOrAmbient()
{
var holder = TransactionState.Current;
if (holder?.DbContext is DatabaseContext ambientContext)
{
return (ambientContext, null);
}

var scope = ServiceScopeFactory.CreateScope();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way both the begin transaction flow and this have the same type of scope.

Suggested change
private (DatabaseContext DbContext, IServiceScope? OwnedScope) GetDatabaseContextOrAmbient()
{
var holder = TransactionState.Current;
if (holder?.DbContext is DatabaseContext ambientContext)
{
return (ambientContext, null);
}
var scope = ServiceScopeFactory.CreateScope();
private (DatabaseContext DbContext, AsyncServiceScope? OwnedScope) GetDatabaseContextOrAmbient()
{
var holder = TransactionState.Current;
if (holder?.DbContext is DatabaseContext ambientContext)
{
return (ambientContext, null);
}
var scope = ServiceScopeFactory.CreateAsyncScope();

if (provider != SupportedDatabaseProviders.SqlServer)
{
services.AddPasswordManagerEFRepositories(globalSettings.SelfHosted);
services.AddSingleton<ITransactionManager, EfTransactionManager>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
services.AddSingleton<ITransactionManager, EfTransactionManager>();
services.TryAddSingleton<ITransactionManager, EfTransactionManager>();

else
{
services.AddDapperRepositories(globalSettings.SelfHosted);
services.AddSingleton<ITransactionManager, DapperTransactionManager>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same

Suggested change
services.AddSingleton<ITransactionManager, DapperTransactionManager>();
services.TryAddSingleton<ITransactionManager, DapperTransactionManager>();

}
finally
{
ownedScope?.Dispose();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would become:

Suggested change
ownedScope?.Dispose();
if (ownedScope is not null)
{
await ownedScope.DisposeAsync();
}

Comment thread src/Infrastructure.Dapper/Repositories/BaseRepository.cs

public async Task CommitAsync(CancellationToken cancellationToken = default)
{
if (_holder.Doomed)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (_holder.Doomed)
ObjectDisposedException.ThrowIf(_disposed, this);
if (_holder.Doomed)


public async Task RollbackAsync(CancellationToken cancellationToken = default)
{
_holder.Doomed = true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Suggested change
_holder.Doomed = true;
ObjectDisposedException.ThrowIf(_disposed, this);
_holder.Doomed = true;

Comment thread src/Core/Platform/Data/ITransactionManager.cs Outdated
}

var scope = ServiceScopeFactory.CreateScope();
return (GetDatabaseContext(scope), scope);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a bug happening here if secrets manager code tries to use a transaction, it would just silently not use a transaction when it could.

Suggested change
return (GetDatabaseContext(scope), scope);
var context = GetDatabaseContext(scope);
if (holder is not null)
{
context.UseTransaction(holder.Transaction);
holder.DbContext = context;
holder.Scope = scope;
}
return (context, scope);

Comment thread src/Infrastructure.Dapper/Repositories/BaseRepository.cs Dismissed
Comment thread src/Core/Platform/Data/TransactionState.cs Fixed
@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +105 to +108
catch
{
// Best-effort rollback; connection may already be broken.
}
@jrmccannon jrmccannon added the t:feature Change Type - Feature Development label Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants