Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c0d47f0
initial work for transaction
jrmccannon Apr 14, 2026
53759ec
added the transaction
jrmccannon Apr 20, 2026
6c8284b
Merge branch 'refs/heads/main' into jmccannon/ac/poc-transaction
jrmccannon Apr 27, 2026
9863df4
Added transaction isolation and wrapped all calls (cept sm) in transa…
jrmccannon Apr 28, 2026
2302757
Corrected scope.
jrmccannon Apr 28, 2026
b8f5a1b
Merge branch 'refs/heads/main' into jmccannon/ac/poc-transaction
jrmccannon Apr 29, 2026
b9de163
Added db.SaveContext
jrmccannon Apr 29, 2026
4654104
commenting temporarily
jrmccannon Apr 29, 2026
0f95611
Correcting ownership of EF transaction (it disposes of the context it…
jrmccannon Apr 30, 2026
676f98d
Merge branch 'refs/heads/main' into jmccannon/ac/poc-transaction
jrmccannon Apr 30, 2026
cc9df67
testing envsqlserver config
jrmccannon May 1, 2026
4c4ed74
reverting previous change
jrmccannon May 4, 2026
6777e66
Merge branch 'main' into jmccannon/ac/poc-transaction
jrmccannon May 8, 2026
e3faeca
Removed reference count as it wasn't being used anywhere. Added base …
jrmccannon May 8, 2026
0d05e11
adding the base
jrmccannon May 8, 2026
f5167e7
Adding support for multiple dbs in scim integration tests.
jrmccannon May 8, 2026
ffde772
fixed
jrmccannon May 8, 2026
59dc73d
Merge branch 'main' into jmccannon/ac/poc-transaction
jrmccannon May 11, 2026
dea9a01
few things
jrmccannon May 11, 2026
45b9597
A few review changes.
jrmccannon May 12, 2026
fecb1a0
added some tests around rollback and commit
jrmccannon May 12, 2026
923a3c6
Merge branch 'refs/heads/main' into jmccannon/ac/poc-transaction
jrmccannon Jun 12, 2026
d25f94a
Code review feedback
jrmccannon Jun 12, 2026
e40de74
added initialize method. added tests.
jrmccannon Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/test-database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ on:
- "hotfix-rc"
paths:
- ".github/workflows/test-database.yml" # This file
- "bitwarden_license/src/Scim/**" # SCIM source — concurrency tests cover it
- "bitwarden_license/test/Scim.IntegrationTest/**" # SCIM integration tests
- "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL
Expand All @@ -17,10 +19,13 @@ on:
- "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer
- "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "test/IntegrationTestCommon/**" # Shared test database infrastructure
- "src/**/Entities/**/*.cs" # Database entity definitions
pull_request:
paths:
- ".github/workflows/test-database.yml" # This file
- "bitwarden_license/src/Scim/**" # SCIM source — concurrency tests cover it
- "bitwarden_license/test/Scim.IntegrationTest/**" # SCIM integration tests
- "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL
Expand All @@ -29,6 +34,7 @@ on:
- "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer
- "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "test/IntegrationTestCommon/**" # Shared test database infrastructure
- "src/**/Entities/**/*.cs" # Database entity definitions

permissions:
Expand Down Expand Up @@ -139,6 +145,21 @@ jobs:
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
shell: pwsh

- name: Run SCIM concurrency tests
working-directory: "bitwarden_license/test/Scim.IntegrationTest"
env:
# Each provider's Database= points at a system DB that always exists; the
# *TestDatabase classes create their own per-run vault_test_<guid> and use
# the admin endpoint for CREATE/DROP DATABASE.
BW_TEST_DATABASES__0__TYPE: "Postgres"
BW_TEST_DATABASES__0__CONNECTIONSTRING: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=postgres"
BW_TEST_DATABASES__1__TYPE: "MySql"
BW_TEST_DATABASES__1__CONNECTIONSTRING: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=mysql"
BW_TEST_DATABASES__2__TYPE: "SqlServer"
BW_TEST_DATABASES__2__CONNECTIONSTRING: "Server=localhost;Database=master;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;"
run: dotnet test --filter "FullyQualifiedName~UsersControllerConcurrencyTests" --logger "trx;LogFileName=scim-concurrency-test-results.trx"
shell: pwsh

- name: Print MySQL Logs
if: failure()
run: 'docker logs "$(docker ps --quiet --filter "name=mysql")"'
Expand Down
8 changes: 7 additions & 1 deletion bitwarden_license/src/Scim/Users/PostUserCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#nullable enable

using System.Data;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
Expand All @@ -10,6 +11,7 @@
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Platform.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Context;
Expand All @@ -27,7 +29,8 @@ public class PostUserCommand(
IScimContext scimContext,
IFeatureService featureService,
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
TimeProvider timeProvider)
TimeProvider timeProvider,
ITransactionManager transactionManager)
: IPostUserCommand
{
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
Expand All @@ -45,6 +48,8 @@ public class PostUserCommand(
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 ?)


var organization = await organizationRepository.GetByIdAsync(organizationId);

if (organization is null)
Expand Down Expand Up @@ -81,6 +86,7 @@ public class PostUserCommand(
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
: null;

await transactionScope.CommitAsync();
return organizationUser;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using Bit.Core;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.IntegrationTestCommon;
using Bit.Scim.IntegrationTest.Factories;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using NSubstitute;
using Xunit;

namespace Bit.Scim.IntegrationTest.Controllers.v2;

/// <summary>
/// Verifies seat-count integrity when SCIM invite requests run concurrently.
/// Runs against every supported real RDBMS (SqlServer, Postgres, MySql) when its
/// connection string is configured via BW_TEST_DATABASES__N__*. SQLite is excluded
/// because it serializes writes globally and cannot reproduce the read-modify-write
/// race on Organization.Seats.
/// </summary>
public class UsersControllerConcurrencyTests
{
private static readonly Lazy<IReadOnlyDictionary<SupportedDatabaseProviders, string>> _configuredConnections =
new(LoadConfiguredConnections);

[SkippableTheory]
[MemberData(nameof(DatabaseProviders))]
public async Task Post_ConcurrentInvites_DoNotOvershootMaxAutoscaleSeats(
SupportedDatabaseProviders providerType)
{
var baseConnectionString = _configuredConnections.Value.GetValueOrDefault(providerType);
Skip.If(baseConnectionString is null,
$"{providerType}: not configured (set BW_TEST_DATABASES__N__TYPE/CONNECTIONSTRING).");

const short startingSeats = 3;
const int availableSeats = 2;
const int concurrentInvites = 6;

const string testDatabaseName = "vault_test_scim";
ITestDatabase testDatabase = providerType switch
{
SupportedDatabaseProviders.SqlServer => new SqlServerTestDatabase(baseConnectionString!, testDatabaseName),
SupportedDatabaseProviders.Postgres => new PostgresTestDatabase(baseConnectionString!, testDatabaseName),
SupportedDatabaseProviders.MySql => new MySqlTestDatabase(baseConnectionString!, testDatabaseName),
_ => throw new InvalidOperationException($"Unsupported provider: {providerType}"),
};

var factory = new ScimApplicationFactory
{
TestDatabase = testDatabase
};

factory.SubstituteService((IFeatureService f) => f.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
.Returns(true));

try
{
factory.ReinitializeDbForTests(factory.GetDatabaseContext());

using (var setupScope = factory.Services.CreateScope())
{
var setupContext = setupScope.ServiceProvider.GetRequiredService<DatabaseContext>();
var org = setupContext.Organizations.Single(o => o.Id == ScimApplicationFactory.TestOrganizationId1);
org.PlanType = PlanType.EnterpriseAnnually;
org.Plan = "Enterprise (Annually)";
org.Seats = startingSeats;
org.MaxAutoscaleSeats = startingSeats + availableSeats;
await setupContext.SaveChangesAsync();
}

var inputs = Enumerable.Range(0, concurrentInvites).Select(BuildInvite).ToArray();

var responses = await Task.WhenAll(
inputs.Select(input =>
factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, input)));

var successfulInvites = responses.Count(r => r.Response.StatusCode == StatusCodes.Status201Created);

using var verifyScope = factory.Services.CreateScope();
var verifyContext = verifyScope.ServiceProvider.GetRequiredService<DatabaseContext>();
var finalOrg = verifyContext.Organizations
.Single(o => o.Id == ScimApplicationFactory.TestOrganizationId1);
var finalActiveUserCount = verifyContext.OrganizationUsers
.Count(ou => ou.OrganizationId == ScimApplicationFactory.TestOrganizationId1 && ou.Status >= 0);

Assert.All(responses, r => Assert.True(r.Response.StatusCode < 500,
$"Expected non-5xx status, got {r.Response.StatusCode}"));

Assert.Equal(startingSeats + successfulInvites, finalOrg.Seats);

Assert.Equal(startingSeats + successfulInvites, finalActiveUserCount);

Assert.True(finalOrg.Seats <= finalOrg.MaxAutoscaleSeats,
$"Seats {finalOrg.Seats} exceeded MaxAutoscaleSeats {finalOrg.MaxAutoscaleSeats}");
}
finally
{
await factory.DisposeAsync();
}
}

public static IEnumerable<object?[]> DatabaseProviders()
{
yield return [SupportedDatabaseProviders.SqlServer];
yield return [SupportedDatabaseProviders.Postgres];
yield return [SupportedDatabaseProviders.MySql];
}

private static Dictionary<SupportedDatabaseProviders, string> LoadConfiguredConnections()
{
var config = new ConfigurationBuilder()
.AddUserSecrets(typeof(Bit.Identity.Startup).Assembly, optional: true)
.AddEnvironmentVariables("BW_TEST_")
.Build();

var configured = new Dictionary<SupportedDatabaseProviders, string>();

// Preferred source: BW_TEST_DATABASES__N__* env vars (set by test-database.yml in CI)
for (var i = 0; ; i++)
{
var rawType = config[$"DATABASES:{i}:TYPE"];
var connectionString = config[$"DATABASES:{i}:CONNECTIONSTRING"];
if (rawType is null && connectionString is null)
{
break;
}
if (rawType is null || connectionString is null)
{
continue;
}
if (Enum.TryParse<SupportedDatabaseProviders>(rawType, ignoreCase: true, out var type))
{
configured.TryAdd(type, connectionString);
}
}

// Fallback for local dev: Identity user secrets (globalSettings:<provider>:connectionString)
TryAddFromUserSecrets(SupportedDatabaseProviders.SqlServer, "globalSettings:sqlServer:connectionString");
TryAddFromUserSecrets(SupportedDatabaseProviders.Postgres, "globalSettings:postgreSql:connectionString");
TryAddFromUserSecrets(SupportedDatabaseProviders.MySql, "globalSettings:mySql:connectionString");

return configured;

void TryAddFromUserSecrets(SupportedDatabaseProviders type, string key)
{
if (configured.ContainsKey(type))
{
return;
}
var value = config[key];
if (!string.IsNullOrWhiteSpace(value))
{
configured[type] = value;
}
}
}

private static ScimUserRequestModel BuildInvite(int i) => new()
{
DisplayName = $"Concurrent User {i}",
Emails = [new() { Primary = true, Type = "work", Value = $"concurrent-{i}@example.com" }],
ExternalId = $"CONC-{i}",
Active = true,
Schemas = [ScimConstants.Scim2SchemaUser]
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,10 @@ private async Task<CommandResult<InviteOrganizationUsersResponse>> InviteOrganiz
{
logger.LogError(ex, FailedToInviteUsersError.Code);

await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));

// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
// The ambient transaction rolls back DB writes; the calls below compensate for
// state that lives outside the transaction (Stripe subscription, application cache).
// SM revert runs first so SmSeats never exceed PM seats (current billing requirement).
await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats);

await RevertPasswordManagerChangesAsync(validatedRequest, organization);

return new Failure<InviteOrganizationUsersResponse>(
Expand Down Expand Up @@ -206,7 +205,8 @@ private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUse
{
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;

await organizationRepository.ReplaceAsync(organization);
// Compensating write: the application cache is not part of the ambient transaction,
// so we must explicitly restore the pre-increment seat count.
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
}
Expand Down
22 changes: 22 additions & 0 deletions src/Core/Platform/Data/ITransactionManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Data;

namespace Bit.Core.Platform.Data;

/// <summary>
/// Manages ambient database transactions that span multiple repository calls.
/// Implementations are singleton-safe; transaction state is stored per async flow.
/// </summary>
public interface ITransactionManager
{
/// <summary>
/// Begins a new ambient transaction. All repository operations on the current
/// async flow will use the same connection and transaction until disposed.
/// Supports nesting: inner calls return a no-op scope that shares the outer
/// transaction. Only the outermost Commit/Dispose actually affects the database.
/// The isolation level on nested calls is ignored — inner scopes inherit the
/// outer transaction's isolation level.
/// </summary>
Task<ITransactionScope> BeginTransactionAsync(
IsolationLevel isolationLevel = IsolationLevel.ReadCommitted,
CancellationToken cancellationToken = default);
}
11 changes: 11 additions & 0 deletions src/Core/Platform/Data/ITransactionScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Bit.Core.Platform.Data;

/// <summary>
/// Represents an ambient transaction scope. Commit must be called explicitly;
/// disposing without committing triggers rollback.
/// </summary>
public interface ITransactionScope : IAsyncDisposable
{
Task CommitAsync(CancellationToken cancellationToken = default);
Task RollbackAsync(CancellationToken cancellationToken = default);
}
36 changes: 36 additions & 0 deletions src/Core/Platform/Data/NestedTransactionScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Bit.Core.Platform.Data;

public sealed class NestedTransactionScope : ITransactionScope
{
private readonly TransactionHolder _holder;
private bool _disposed;

public NestedTransactionScope(TransactionHolder holder)
{
_holder = holder;
}

public Task CommitAsync(CancellationToken cancellationToken = default)
{
// Nested scope commit is a no-op; only the root scope commits.
return Task.CompletedTask;
}

public Task RollbackAsync(CancellationToken cancellationToken = default)
{
// Mark the transaction as doomed so the root scope cannot commit.
_holder.MarkDoomed();
return Task.CompletedTask;
}

public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}

_disposed = true;
return ValueTask.CompletedTask;
}
}
Loading
Loading