Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 ?

Copy link
Copy Markdown
Contributor Author

@jrmccannon jrmccannon May 5, 2026

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.


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,93 @@
ο»Ώusing Bit.Core;
using Bit.Core.Billing.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.
/// Requires a real SQL Server (vault_test) β€” SQLite serializes writes globally and
/// cannot reproduce the read-modify-write race on Organization.Seats.
/// </summary>
public class UsersControllerConcurrencyTests
{
[Fact]
public async Task Post_ConcurrentInvites_DoNotOvershootMaxAutoscaleSeats()
{
const short startingSeats = 3;
const int availableSeats = 2;
const int concurrentInvites = 6;

var factory = new ScimApplicationFactory
{
TestDatabase = new SqlServerTestDatabase()
};

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();
}
}

private static ScimUserRequestModel BuildInvite(int i) => new()
{
DisplayName = $"Concurrent User {i}",
Emails = new List<BaseScimUserModel.EmailModel>
{
new() { Primary = true, Type = "work", Value = $"concurrent-{i}@example.com" }
},
ExternalId = $"CONC-{i}",
Active = true,
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,13 @@ private async Task<CommandResult<InviteOrganizationUsersResponse>> InviteOrganiz
{
logger.LogError(ex, FailedToInviteUsersError.Code);

await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));
// this should already be done
//await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));

// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats);

//this should already be done
await RevertPasswordManagerChangesAsync(validatedRequest, organization);

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

await organizationRepository.ReplaceAsync(organization);
//await organizationRepository.ReplaceAsync(organization);
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
}
Expand Down
26 changes: 26 additions & 0 deletions src/Core/Platform/Data/ITransactionManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ο»Ώ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 increment a reference count; only the
/// outermost Dispose/Commit actually affects the database. The isolation level
/// on a nested call is ignored β€” the inner scope joins the outer transaction.
/// </summary>
Task<ITransactionScope> BeginTransactionAsync(
IsolationLevel isolationLevel = IsolationLevel.ReadCommitted,
CancellationToken cancellationToken = default);

/// <summary>
/// Returns true if the current async flow has an active ambient transaction.
/// </summary>
bool HasActiveTransaction { get; }
}
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);
}
37 changes: 37 additions & 0 deletions src/Core/Platform/Data/NestedTransactionScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
ο»Ώ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.Doomed = true;
return Task.CompletedTask;
}

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

_disposed = true;
_holder.ReferenceCount--;
return ValueTask.CompletedTask;
}
}
42 changes: 42 additions & 0 deletions src/Core/Platform/Data/RootTransactionScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
ο»Ώnamespace Bit.Core.Platform.Data;

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

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

public async Task CommitAsync(CancellationToken cancellationToken = default)
{
if (_holder.Doomed)
{
throw new InvalidOperationException(
"Cannot commit a transaction that has been marked for rollback by a nested scope.");
}

_holder.Committed = true;
await _holder.Transaction.CommitAsync(cancellationToken);
}

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

public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}

_disposed = true;
TransactionState.Current = null;
await _holder.DisposeAsync();
}
}
67 changes: 67 additions & 0 deletions src/Core/Platform/Data/TransactionState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
ο»Ώusing System.Data.Common;

namespace Bit.Core.Platform.Data;

public static class TransactionState
{
private static readonly AsyncLocal<TransactionHolder?> _current = new();

public static TransactionHolder? Current
{
get => _current.Value;
set => _current.Value = value;
}
}

public sealed class TransactionHolder : IAsyncDisposable
{
public required DbConnection Connection { get; init; }
public required DbTransaction Transaction { get; init; }
public int ReferenceCount { get; set; } = 1;
public bool Committed { get; set; }
public bool Doomed { get; set; }

/// <summary>
/// True when this holder is responsible for disposing <see cref="Connection"/>.
/// EF reuses the DbContext's connection and must leave its lifetime to the scope;
/// Dapper opens its own connection and must dispose it here.
/// </summary>
public bool OwnsConnection { get; init; } = true;

/// <summary>
/// For EF: the DatabaseContext associated with this transaction.
/// </summary>
public object? DbContext { get; set; }

/// <summary>
/// For EF: the IServiceScope that owns the DatabaseContext.
/// </summary>
public IAsyncDisposable? Scope { get; set; }

public async ValueTask DisposeAsync()
{
if (!Committed)
{
try
{
await Transaction.RollbackAsync();
}
catch
{
// Best-effort rollback; connection may already be broken
}
}

await Transaction.DisposeAsync();

if (OwnsConnection)
{
await Connection.DisposeAsync();
}

if (Scope is not null)
{
await Scope.DisposeAsync();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 +250,16 @@ public async Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid>

public async Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
{
using (var connection = new SqlConnection(ConnectionString))
return await ExecuteWithConnectionAsync(async (connection, transaction) =>
{
var result = await connection.QueryAsync<OrganizationSeatCounts>(
"[dbo].[Organization_ReadOccupiedSeatCountByOrganizationId]",
new { OrganizationId = organizationId },
transaction: transaction,
commandType: CommandType.StoredProcedure);

return result.SingleOrDefault() ?? new OrganizationSeatCounts();
}
});
}

public async Task<IEnumerable<Organization>> GetOrganizationsForSubscriptionSyncAsync()
Expand All @@ -285,11 +286,13 @@ await connection.ExecuteAsync("[dbo].[Organization_UpdateSubscriptionStatus]",

public async Task IncrementSeatCountAsync(Guid organizationId, int increaseAmount, DateTime requestDate)
{
await using var connection = new SqlConnection(ConnectionString);

await connection.ExecuteAsync("[dbo].[Organization_IncrementSeatCount]",
new { OrganizationId = organizationId, SeatsToAdd = increaseAmount, RequestDate = requestDate },
commandType: CommandType.StoredProcedure);
await ExecuteWithConnectionAsync(async (connection, transaction) =>
{
await connection.ExecuteAsync("[dbo].[Organization_IncrementSeatCount]",
new { OrganizationId = organizationId, SeatsToAdd = increaseAmount, RequestDate = requestDate },
transaction: transaction,
commandType: CommandType.StoredProcedure);
});
}

public async Task InitializeOrganizationAsync(Organization organization, Func<DbConnection, DbTransaction, Task> confirmOwnerAction)
Expand Down
Loading
Loading