From 4b1dbb3f02abae5c3015fd77092984d386215d86 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:35:27 -0500 Subject: [PATCH 01/44] [PM-45107] feat: Autoscale Privileged Controls seats on sales-initiated trials PM-40118 specified that trials add seats on the organization only; the branch was deferred until trial detection existed. The command now asks the sales-initiated trial query when seats are short and within the limit, and on a trial raises PamSeats without a change set or Stripe update. The empty GatewaySubscriptionId guard is removed because the trial query treats a missing subscription id as a trial. Its test, which expected "No subscription found.", is replaced by Run_NoSubscriptionId_TreatedAsTrial_SavesSeats. The library now exposes internals to DynamicProxyGenAssembly2 so tests can substitute the internal trial query interface. --- ...AutoscalePrivilegedControlsSeatsCommand.cs | 6 +- ...AutoscalePrivilegedControlsSeatsCommand.cs | 14 ++-- .../Subscriptions.Organization.csproj | 1 + ...calePrivilegedControlsSeatsCommandTests.cs | 79 ++++++++++++++++--- 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs index c020936aea12..80fb568871d3 100644 --- a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs @@ -14,7 +14,8 @@ public interface IAutoscalePrivilegedControlsSeatsCommand /// /// Adds Privileged Controls seats when the seats in use plus exceed the seats the /// organization has bought. Nothing changes when the bought seats already cover that total. When a limit is set - /// and the total equals it, the organization's Owners are emailed. + /// and the total equals it, the organization's Owners are emailed. On a trial the seats are raised on the + /// organization only, with no change to Stripe. /// /// The organization that already has Privileged Controls. /// @@ -25,8 +26,7 @@ public interface IAutoscalePrivilegedControlsSeatsCommand /// /// /// Thrown on a self-hosted instance, when is below 1, when the organization does - /// not have Privileged Controls or has no subscription, or when the seat change or - /// subscription update is rejected. + /// not have Privileged Controls, or when the seat change or subscription update is rejected. /// /// Thrown when the organization's subscription is in a state that prevents the change. Task Run(OrganizationEntity organization, int seatsToAdd); diff --git a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs index d0db41adb68f..983fe66e1071 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs @@ -9,6 +9,7 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; +using Bit.Subscriptions.Organization.Queries; using Bitwarden.Server.Sdk.Environment; using Microsoft.Extensions.Logging; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; @@ -20,6 +21,7 @@ internal sealed class AutoscalePrivilegedControlsSeatsCommand( IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, IOrganizationService organizationService, IOrganizationUserRepository organizationUserRepository, + IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, IMailer mailer, IGlobalSettings globalSettings, IBitwardenEnvironment environment, @@ -42,11 +44,6 @@ public async Task Run(OrganizationEntity throw new BadRequestException("Your organization doesn't have Privileged Controls."); } - if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) - { - throw new BadRequestException("No subscription found."); - } - var seatsNeeded = await organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + seatsToAdd; @@ -57,8 +54,11 @@ public async Task Run(OrganizationEntity return new AutoscalePrivilegedControlsSeatsResult(false, organization.MaxAutoscalePamSeats); } - var changeSet = await seatChangeSetFactory.CreateAsync(organization, seatsNeeded); - (await updateOrganizationSubscriptionCommand.Run(organization, changeSet)).GetValueOrThrowHttpException(); + if (!await isOnTrialQuery.Run(organization)) + { + var changeSet = await seatChangeSetFactory.CreateAsync(organization, seatsNeeded); + (await updateOrganizationSubscriptionCommand.Run(organization, changeSet)).GetValueOrThrowHttpException(); + } organization.PamSeats = seatsNeeded; await organizationService.ReplaceAndUpdateCacheAsync(organization); diff --git a/src/Libraries/Subscriptions.Organization/Subscriptions.Organization.csproj b/src/Libraries/Subscriptions.Organization/Subscriptions.Organization.csproj index 5a68eb5ffab3..6df6213f3d9e 100644 --- a/src/Libraries/Subscriptions.Organization/Subscriptions.Organization.csproj +++ b/src/Libraries/Subscriptions.Organization/Subscriptions.Organization.csproj @@ -6,6 +6,7 @@ + diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index 231a848269a9..d427188962f8 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Queries; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -37,6 +38,8 @@ public class AutoscalePrivilegedControlsSeatsCommandTests Substitute.For(); private readonly IMailer _mailer = Substitute.For(); private readonly GlobalSettings _globalSettings = new(); + private readonly IIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = + Substitute.For(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly AutoscalePrivilegedControlsSeatsCommand _sut; @@ -46,6 +49,7 @@ public AutoscalePrivilegedControlsSeatsCommandTests() => _updateOrganizationSubscriptionCommand, _organizationService, _organizationUserRepository, + _isOnTrialQuery, _mailer, _globalSettings, _environment, @@ -97,20 +101,75 @@ public async Task Run_OrganizationUsesPrivilegedControlsWithNoSeats_ThrowsBadReq await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); } - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task Run_NoGatewaySubscription_ThrowsBadRequestWithoutReadingSeatsInUse(string? gatewaySubscriptionId) + [Fact] + public async Task Run_OnTrial_SeatsShort_WithinLimit_SavesSeatsWithoutTouchingStripe() { - var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); + var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 10); + SetSeatsInUse(organization, 5); + _isOnTrialQuery.Run(organization).Returns(true); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); + var result = await _sut.Run(organization, 2); - Assert.Equal("No subscription found.", exception.Message); - await _organizationUserRepository.DidNotReceive() - .GetOccupiedPamSeatCountByOrganizationIdAsync(Arg.Any()); + Assert.True(result.Success); + Assert.Equal(7, organization.PamSeats); + await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_OnTrial_PastLimit_ReturnsLimitWithoutSaving() + { + var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 6); + SetSeatsInUse(organization, 5); + _isOnTrialQuery.Run(organization).Returns(true); + + var result = await _sut.Run(organization, 2); + + Assert.False(result.Success); + Assert.Equal(6, result.MaxAutoscaleSeats); + await _organizationService.DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_OnTrial_ReachesLimitExactly_EmailsOwners() + { + var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 7); + SetSeatsInUse(organization, 5); + _isOnTrialQuery.Run(organization).Returns(true); + + await _sut.Run(organization, 2); + + await _mailer.Received(1).SendEmail(Arg.Any()); + } + + [Fact] + public async Task Run_NoSubscriptionId_TreatedAsTrial_SavesSeats() + { + var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 10, gatewaySubscriptionId: null); + SetSeatsInUse(organization, 5); + _isOnTrialQuery.Run(organization).Returns(true); + + var result = await _sut.Run(organization, 2); + + Assert.True(result.Success); + Assert.Equal(7, organization.PamSeats); + await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_SeatsAlreadyCovered_DoesNotQueryTrial() + { + var organization = CreateOrganization(pamSeats: 5); + SetSeatsInUse(organization, 3); + + await _sut.Run(organization, 1); + + await _isOnTrialQuery.DidNotReceive().Run(Arg.Any()); } [Fact] From 115de3a5fa8d2e19fe24598a59c6be2c1c8b3e53 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:39:42 -0500 Subject: [PATCH 02/44] [PM-45107] feat: Add a command to give back autoscaled Privileged Controls seats Inviting staged members already gives back Password Manager and Secrets Manager seats when the invite fails, and needs the same for Privileged Controls. --- .../IRevertPrivilegedControlsSeatsCommand.cs | 23 ++++ .../RevertPrivilegedControlsSeatsCommand.cs | 39 +++++++ ...vertPrivilegedControlsSeatsCommandTests.cs | 109 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs create mode 100644 src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs create mode 100644 test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs diff --git a/src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs new file mode 100644 index 000000000000..c15309c5ad20 --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs @@ -0,0 +1,23 @@ +using Bit.Core.Exceptions; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Core.Billing.Organizations.Commands; + +/// +/// Gives back Privileged Controls seats bought earlier in a request, for when a later step fails. +/// +public interface IRevertPrivilegedControlsSeatsCommand +{ + /// + /// Puts the organization's Privileged Controls seat count back to . Nothing changes + /// when the organization already has that many seats. On a trial the seats are reset on the organization only, + /// with no change to Stripe. The autoscale limit is not changed. + /// + /// The organization that already has Privileged Controls. + /// The seat count captured before the autoscale. + /// + /// Thrown on a self-hosted instance, or when the seat change or subscription update is rejected. + /// + /// Thrown when the organization's subscription is in a state that prevents the change. + Task Run(OrganizationEntity organization, int seats); +} diff --git a/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs new file mode 100644 index 000000000000..5f1d5bcd5531 --- /dev/null +++ b/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs @@ -0,0 +1,39 @@ +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Helpers; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Subscriptions.Organization.Queries; +using Bitwarden.Server.Sdk.Environment; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Subscriptions.Organization.Commands; + +internal sealed class RevertPrivilegedControlsSeatsCommand( + IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, + IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, + IOrganizationService organizationService, + IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, + IBitwardenEnvironment environment) : IRevertPrivilegedControlsSeatsCommand +{ + public async Task Run(OrganizationEntity organization, int seats) + { + if (environment.SelfHosted) + { + throw new BadRequestException("Cannot update subscription on a self-hosted instance."); + } + + if (seats == organization.PamSeats) + { + return; + } + + if (!await isOnTrialQuery.Run(organization)) + { + var seatChange = await seatChangeSetFactory.CreateAsync(organization, seats); + (await updateOrganizationSubscriptionCommand.Run(organization, seatChange.ChangeSet)).GetValueOrThrowHttpException(); + } + + organization.PamSeats = seats; + await organizationService.ReplaceAndUpdateCacheAsync(organization); + } +} diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs new file mode 100644 index 000000000000..35303e17b422 --- /dev/null +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs @@ -0,0 +1,109 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Helpers; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Queries; +using Bitwarden.Server.Sdk.Environment; +using NSubstitute; +using Stripe; +using Xunit; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Subscriptions.Organization.Test.Commands; + +public class RevertPrivilegedControlsSeatsCommandTests +{ + private readonly IPrivilegedControlsSeatChangeSetFactory _seatChangeSetFactory = + Substitute.For(); + private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = + Substitute.For(); + private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = + Substitute.For(); + private readonly IBitwardenEnvironment _environment = Substitute.For(); + private readonly RevertPrivilegedControlsSeatsCommand _sut; + + public RevertPrivilegedControlsSeatsCommandTests() => + _sut = new RevertPrivilegedControlsSeatsCommand( + _seatChangeSetFactory, + _updateOrganizationSubscriptionCommand, + _organizationService, + _isOnTrialQuery, + _environment); + + [Fact] + public async Task Run_SeatsUnchanged_DoesNothing() + { + var organization = CreateOrganization(pamSeats: 5); + + await _sut.Run(organization, 5); + + await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), Arg.Any(), Arg.Any()); + await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + } + + [Fact] + public async Task Run_OnTrial_ResetsSeatsOnOrganizationOnly() + { + var organization = CreateOrganization(pamSeats: 7); + _isOnTrialQuery.Run(organization).Returns(true); + + await _sut.Run(organization, 5); + + Assert.Equal(5, organization.PamSeats); + await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_Paid_UpdatesSubscriptionThenSaves() + { + var organization = CreateOrganization(pamSeats: 7); + _isOnTrialQuery.Run(organization).Returns(false); + var seatChange = new PrivilegedControlsSeatChange( + new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("privileged-controls-enterprise-seat-annually", 5)] + }, + 5); + _seatChangeSetFactory.CreateAsync(organization, 5).Returns(seatChange); + _updateOrganizationSubscriptionCommand + .Run(organization, seatChange.ChangeSet, Arg.Any()) + .Returns(new Subscription()); + + await _sut.Run(organization, 5); + + await _seatChangeSetFactory.Received(1).CreateAsync(organization, 5); + await _updateOrganizationSubscriptionCommand.Received(1).Run(organization, seatChange.ChangeSet); + Assert.Equal(5, organization.PamSeats); + await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + } + + [Fact] + public async Task Run_SelfHosted_ThrowsBadRequest() + { + var organization = CreateOrganization(pamSeats: 7); + _environment.SelfHosted.Returns(true); + + await Assert.ThrowsAsync(() => _sut.Run(organization, 5)); + } + + private static OrganizationEntity CreateOrganization(int pamSeats) => new() + { + Id = Guid.NewGuid(), + Name = "Acme.Corp", + PlanType = PlanType.EnterpriseAnnually, + UsePam = true, + PamSeats = pamSeats, + PamSeatMinimum = 10, + GatewayCustomerId = "cus_123", + GatewaySubscriptionId = "sub_123" + }; +} From ecc27f1b4bda6c62677507d8fc8ac466fc410a76 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:44:42 -0500 Subject: [PATCH 03/44] [PM-45107] feat: Register Privileged Controls seat autoscaling in every host The organization user commands every host registers will depend on the autoscale step, and hosts validate DI at startup, so each host registers the library's implementation as the library README requires. A new AddPrivilegedControlsSeatAutoscaling method registers the autoscale and revert commands with their trial query and seat change set factory; AddOrganizationSubscriptions calls it, so Api is unaffected. --- bitwarden_license/src/Scim/Scim.csproj | 1 + bitwarden_license/src/Scim/Startup.cs | 2 ++ bitwarden_license/src/Scim/packages.lock.json | 28 +++++++++++++++ bitwarden_license/src/Sso/Sso.csproj | 1 + bitwarden_license/src/Sso/Startup.cs | 2 ++ bitwarden_license/src/Sso/packages.lock.json | 28 +++++++++++++++ .../test/SSO.Test/packages.lock.json | 29 ++++++++++++++++ .../Scim.IntegrationTest/packages.lock.json | 34 +++++++++++++++++-- .../test/Scim.Test/packages.lock.json | 31 ++++++++++++++++- .../Pam.IntegrationTest/packages.lock.json | 3 +- .../Sso.IntegrationTest/packages.lock.json | 32 ++++++++++++++++- perf/MicroBenchmarks/packages.lock.json | 31 ++++++++++++++++- src/Admin/Admin.csproj | 1 + src/Admin/Startup.cs | 2 ++ src/Admin/packages.lock.json | 28 +++++++++++++++ src/Billing/Billing.csproj | 1 + src/Billing/Startup.cs | 2 ++ src/Billing/packages.lock.json | 28 +++++++++++++++ src/Identity/Identity.csproj | 1 + src/Identity/Startup.cs | 2 ++ src/Identity/packages.lock.json | 28 +++++++++++++++ .../RevertPrivilegedControlsSeatsCommand.cs | 4 +-- .../Subscriptions.Organization/README.md | 7 ++-- ...OrganizationServiceCollectionExtensions.cs | 18 ++++++++-- test/Admin.Test/packages.lock.json | 31 ++++++++++++++++- test/Api.IntegrationTest/packages.lock.json | 3 +- .../packages.lock.json | 7 ++-- test/Billing.Test/packages.lock.json | 29 ++++++++++++++++ .../Events.IntegrationTest/packages.lock.json | 31 ++++++++++++++++- .../packages.lock.json | 31 ++++++++++++++++- test/Identity.Test/packages.lock.json | 31 ++++++++++++++++- test/IntegrationTestCommon/packages.lock.json | 31 ++++++++++++++++- ...vertPrivilegedControlsSeatsCommandTests.cs | 16 ++++----- ...izationServiceCollectionExtensionsTests.cs | 25 ++++++++++++++ test/Notifications.Test/packages.lock.json | 31 ++++++++++++++++- .../packages.lock.json | 31 ++++++++++++++++- 36 files changed, 609 insertions(+), 32 deletions(-) diff --git a/bitwarden_license/src/Scim/Scim.csproj b/bitwarden_license/src/Scim/Scim.csproj index d3858e122566..2ff467b0723f 100644 --- a/bitwarden_license/src/Scim/Scim.csproj +++ b/bitwarden_license/src/Scim/Scim.csproj @@ -9,6 +9,7 @@ + diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index 6cab170329d2..623063acefd7 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -8,6 +8,7 @@ using Bit.Scim.Context; using Bit.Scim.Utilities; using Bit.SharedWeb.Utilities; +using Bit.Subscriptions.Organization; using Duende.IdentityModel; using Microsoft.Extensions.DependencyInjection.Extensions; using Stripe; @@ -72,6 +73,7 @@ public void ConfigureServices(IServiceCollection services) services.AddDefaultServices(globalSettings); services.AddDistributedCache(globalSettings); services.AddBillingOperations(); + services.AddPrivilegedControlsSeatAutoscaling(); services.TryAddSingleton(); diff --git a/bitwarden_license/src/Scim/packages.lock.json b/bitwarden_license/src/Scim/packages.lock.json index cd578b70c294..a734983b4fb9 100644 --- a/bitwarden_license/src/Scim/packages.lock.json +++ b/bitwarden_license/src/Scim/packages.lock.json @@ -1212,6 +1212,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1247,6 +1250,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1255,6 +1267,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1284,6 +1303,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/bitwarden_license/src/Sso/Sso.csproj b/bitwarden_license/src/Sso/Sso.csproj index 602274a9ed54..4eb3f0cd5d28 100644 --- a/bitwarden_license/src/Sso/Sso.csproj +++ b/bitwarden_license/src/Sso/Sso.csproj @@ -13,6 +13,7 @@ + diff --git a/bitwarden_license/src/Sso/Startup.cs b/bitwarden_license/src/Sso/Startup.cs index b10a427e8079..bcea5572ba3e 100644 --- a/bitwarden_license/src/Sso/Startup.cs +++ b/bitwarden_license/src/Sso/Startup.cs @@ -8,6 +8,7 @@ using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Bit.Sso.Utilities; +using Bit.Subscriptions.Organization; using Duende.IdentityServer.Services; using Stripe; @@ -83,6 +84,7 @@ public void ConfigureServices(IServiceCollection services) services.AddDefaultServices(globalSettings); services.AddCoreLocalizationServices(); services.AddBillingOperations(); + services.AddPrivilegedControlsSeatAutoscaling(); // TODO: Remove when OrganizationUser methods are moved out of OrganizationService, this noop dependency should // TODO: no longer be required - see PM-1880 diff --git a/bitwarden_license/src/Sso/packages.lock.json b/bitwarden_license/src/Sso/packages.lock.json index 1909a86539a3..48b22a6dd38a 100644 --- a/bitwarden_license/src/Sso/packages.lock.json +++ b/bitwarden_license/src/Sso/packages.lock.json @@ -1251,6 +1251,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1286,6 +1289,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1294,6 +1306,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1323,6 +1342,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/bitwarden_license/test/SSO.Test/packages.lock.json b/bitwarden_license/test/SSO.Test/packages.lock.json index 135771f10dd0..815509c8eb0a 100644 --- a/bitwarden_license/test/SSO.Test/packages.lock.json +++ b/bitwarden_license/test/SSO.Test/packages.lock.json @@ -1783,6 +1783,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1818,6 +1821,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1826,6 +1838,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1868,11 +1887,21 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )", "Sustainsys.Saml2.AspNetCore2": "[2.11.0, 2.11.0]" } }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json b/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json index 12e12a3ef6e3..a80581db5a43 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json +++ b/bitwarden_license/test/Scim.IntegrationTest/packages.lock.json @@ -1414,6 +1414,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1441,7 +1444,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -1478,6 +1482,15 @@ "Seeder": "[2026.10.1, )" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1494,6 +1507,13 @@ "dbup-sqlserver": "[7.2.0, 7.2.0]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1520,7 +1540,8 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "seeder": { @@ -1553,6 +1574,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/bitwarden_license/test/Scim.Test/packages.lock.json b/bitwarden_license/test/Scim.Test/packages.lock.json index 66c1f1d6b01f..5ae8a21a40fb 100644 --- a/bitwarden_license/test/Scim.Test/packages.lock.json +++ b/bitwarden_license/test/Scim.Test/packages.lock.json @@ -1743,6 +1743,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1778,6 +1781,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1786,6 +1798,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1809,7 +1828,8 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "serilogfilelogging": { @@ -1832,6 +1852,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json b/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json index cd40a44516d7..98541aa62878 100644 --- a/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json @@ -2056,7 +2056,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { diff --git a/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json b/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json index cd39e764e42e..e30f8d8b1ceb 100644 --- a/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json +++ b/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json @@ -1452,6 +1452,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1479,7 +1482,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -1516,6 +1520,15 @@ "Seeder": "[2026.10.1, )" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1532,6 +1545,13 @@ "dbup-sqlserver": "[7.2.0, 7.2.0]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1587,11 +1607,21 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )", "Sustainsys.Saml2.AspNetCore2": "[2.11.0, 2.11.0]" } }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/perf/MicroBenchmarks/packages.lock.json b/perf/MicroBenchmarks/packages.lock.json index 9e0835f77e3c..bd4a399381bf 100644 --- a/perf/MicroBenchmarks/packages.lock.json +++ b/perf/MicroBenchmarks/packages.lock.json @@ -1681,6 +1681,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1708,7 +1711,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -1734,6 +1738,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1742,6 +1755,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1771,6 +1791,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index 573358946604..bf38dd4f49a9 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index cb16304353b7..12d6430c1024 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -4,6 +4,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; +using Bit.Subscriptions.Organization; using Microsoft.AspNetCore.Identity; using Stripe; using Microsoft.AspNetCore.Mvc; @@ -98,6 +99,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddDistributedCache(globalSettings); services.AddBillingOperations(); + services.AddPrivilegedControlsSeatAutoscaling(); services.AddHttpClient(); services.AddHttpClient(HomeController.ExternalHttpClientName).AddSsrfProtection(); diff --git a/src/Admin/packages.lock.json b/src/Admin/packages.lock.json index 0bfec2f22fff..d527beb5fd79 100644 --- a/src/Admin/packages.lock.json +++ b/src/Admin/packages.lock.json @@ -1246,6 +1246,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1281,6 +1284,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1304,6 +1316,13 @@ "Infrastructure.EntityFramework": "[2026.10.1, )" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1347,6 +1366,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 6de178efe300..4ceedba15b7a 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index c24d58f696c3..64b308503618 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -11,6 +11,7 @@ using Bit.Core.SecretsManager.Repositories.Noop; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; +using Bit.Subscriptions.Organization; using Microsoft.Extensions.DependencyInjection.Extensions; using Quartz; using Stripe; @@ -83,6 +84,7 @@ public void ConfigureServices(IServiceCollection services) services.AddDefaultServices(globalSettings); services.AddDistributedCache(globalSettings); services.AddBillingOperations(); + services.AddPrivilegedControlsSeatAutoscaling(); services.AddCommercialCoreServices(); services.TryAddSingleton(); diff --git a/src/Billing/packages.lock.json b/src/Billing/packages.lock.json index cec4dc9c4624..bdd92a319ed0 100644 --- a/src/Billing/packages.lock.json +++ b/src/Billing/packages.lock.json @@ -1247,6 +1247,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1282,6 +1285,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1290,6 +1302,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1319,6 +1338,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index 694cc109a64c..baf891712354 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index f2625ea809db..81c1811de266 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -13,6 +13,7 @@ using Bit.Identity.Utilities; using Bit.SharedWeb.Swagger; using Bit.SharedWeb.Utilities; +using Bit.Subscriptions.Organization; using Duende.IdentityServer.Services; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.OpenApi; @@ -149,6 +150,7 @@ public void ConfigureServices(IServiceCollection services) services.AddDefaultServices(globalSettings); services.AddCoreLocalizationServices(); services.AddBillingOperations(); + services.AddPrivilegedControlsSeatAutoscaling(); // TODO: Remove when OrganizationUser methods are moved out of OrganizationService, this noop dependency should // TODO: no longer be required - see PM-1880 diff --git a/src/Identity/packages.lock.json b/src/Identity/packages.lock.json index cd578b70c294..a734983b4fb9 100644 --- a/src/Identity/packages.lock.json +++ b/src/Identity/packages.lock.json @@ -1212,6 +1212,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1247,6 +1250,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1255,6 +1267,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1284,6 +1303,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs index 5f1d5bcd5531..50f26c720052 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs @@ -29,8 +29,8 @@ public async Task Run(OrganizationEntity organization, int seats) if (!await isOnTrialQuery.Run(organization)) { - var seatChange = await seatChangeSetFactory.CreateAsync(organization, seats); - (await updateOrganizationSubscriptionCommand.Run(organization, seatChange.ChangeSet)).GetValueOrThrowHttpException(); + var changeSet = await seatChangeSetFactory.CreateAsync(organization, seats); + (await updateOrganizationSubscriptionCommand.Run(organization, changeSet)).GetValueOrThrowHttpException(); } organization.PamSeats = seats; diff --git a/src/Libraries/Subscriptions.Organization/README.md b/src/Libraries/Subscriptions.Organization/README.md index 866c7f8a062f..5505debdfa50 100644 --- a/src/Libraries/Subscriptions.Organization/README.md +++ b/src/Libraries/Subscriptions.Organization/README.md @@ -25,8 +25,11 @@ It also registers the implementation of `IPrivilegedControlsSeatChangeSetFactory `Core` so Admin Console operations, which `Core` cannot reference this library to reach, can inject it; the implementation stays internal here. `IAutoscalePrivilegedControlsSeatsCommand` and its `AutoscalePrivilegedControlsSeatsResult` follow the same split: they live in `Core` so hosts and Admin Console -operations can resolve the command, and this library implements it. A host that resolves it must call -`AddOrganizationSubscriptions()`. +operations can resolve the command, and this library implements it. +`IRevertPrivilegedControlsSeatsCommand` follows the same split: its contract lives in `Core` and this library +implements it. A host that resolves either command without mapping the endpoints calls +`AddPrivilegedControlsSeatAutoscaling()`, which registers the autoscale and revert commands, the trial query, and the +seat change set factory. `AddOrganizationSubscriptions()` calls it, so the Api host needs only that one. `MapOrganizationSubscriptionEndpoints()` attaches the group's cross-cutting chain and maps its endpoints to an empty group; the host owns the route prefix and mounts it at diff --git a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs index ea3c175afbd3..9f10160f5099 100644 --- a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs +++ b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs @@ -21,6 +21,7 @@ public static IServiceCollection AddOrganizationSubscriptions(this IServiceColle { services.AddInvoicing(); services.AddOrganizationAuthorization(); + services.AddPrivilegedControlsSeatAutoscaling(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -29,17 +30,28 @@ public static IServiceCollection AddOrganizationSubscriptions(this IServiceColle services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(); services.TryAddEnumerable( ServiceDescriptor.Scoped()); return services; } + + /// + /// Registers the Privileged Controls seat autoscale and revert commands and the services they use. A host that + /// resolves or + /// without mapping the endpoints calls this. + /// + public static IServiceCollection AddPrivilegedControlsSeatAutoscaling(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + return services; + } } diff --git a/test/Admin.Test/packages.lock.json b/test/Admin.Test/packages.lock.json index 653d2498781f..3e2d30d5f618 100644 --- a/test/Admin.Test/packages.lock.json +++ b/test/Admin.Test/packages.lock.json @@ -1704,7 +1704,8 @@ "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "PostgresMigrations": "[2026.10.1, )", "SharedWeb": "[2026.10.1, )", - "SqliteMigrations": "[2026.10.1, )" + "SqliteMigrations": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "clients": { @@ -1797,6 +1798,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1832,6 +1836,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1855,6 +1868,13 @@ "Infrastructure.EntityFramework": "[2026.10.1, )" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1898,6 +1918,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/test/Api.IntegrationTest/packages.lock.json b/test/Api.IntegrationTest/packages.lock.json index 4343ce75b07d..57d237a41583 100644 --- a/test/Api.IntegrationTest/packages.lock.json +++ b/test/Api.IntegrationTest/packages.lock.json @@ -1558,7 +1558,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { diff --git a/test/Billing.IntegrationTest/packages.lock.json b/test/Billing.IntegrationTest/packages.lock.json index 1fe0b165631b..4eecfd2fb973 100644 --- a/test/Billing.IntegrationTest/packages.lock.json +++ b/test/Billing.IntegrationTest/packages.lock.json @@ -1921,7 +1921,8 @@ "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "PostgresMigrations": "[2026.10.1, )", "SharedWeb": "[2026.10.1, )", - "SqliteMigrations": "[2026.10.1, )" + "SqliteMigrations": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "api": { @@ -1981,6 +1982,7 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )", "Swashbuckle.AspNetCore": "[10.1.7, 10.1.7]" } }, @@ -2104,7 +2106,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { diff --git a/test/Billing.Test/packages.lock.json b/test/Billing.Test/packages.lock.json index 2d9426a28630..1c55e38dd6de 100644 --- a/test/Billing.Test/packages.lock.json +++ b/test/Billing.Test/packages.lock.json @@ -1758,6 +1758,7 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )", "Swashbuckle.AspNetCore": "[10.1.7, 10.1.7]" } }, @@ -1857,6 +1858,9 @@ "xunit": "[2.6.6, )" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1892,6 +1896,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1900,6 +1913,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1929,6 +1949,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/test/Events.IntegrationTest/packages.lock.json b/test/Events.IntegrationTest/packages.lock.json index e2d427d1872d..7172e310e1ca 100644 --- a/test/Events.IntegrationTest/packages.lock.json +++ b/test/Events.IntegrationTest/packages.lock.json @@ -1863,6 +1863,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1907,7 +1910,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -1944,6 +1948,15 @@ "Seeder": "[2026.10.1, )" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1960,6 +1973,13 @@ "dbup-sqlserver": "[7.2.0, 7.2.0]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -2002,6 +2022,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/test/Identity.IntegrationTest/packages.lock.json b/test/Identity.IntegrationTest/packages.lock.json index ff827d0c6078..c0a338ae7da2 100644 --- a/test/Identity.IntegrationTest/packages.lock.json +++ b/test/Identity.IntegrationTest/packages.lock.json @@ -1449,6 +1449,9 @@ "xunit": "[2.6.6, )" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1476,7 +1479,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -1513,6 +1517,15 @@ "Seeder": "[2026.10.1, )" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1529,6 +1542,13 @@ "dbup-sqlserver": "[7.2.0, 7.2.0]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1571,6 +1591,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/test/Identity.Test/packages.lock.json b/test/Identity.Test/packages.lock.json index 3e0817f332eb..f162b68c47d0 100644 --- a/test/Identity.Test/packages.lock.json +++ b/test/Identity.Test/packages.lock.json @@ -1780,6 +1780,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1807,7 +1810,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -1833,6 +1837,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1841,6 +1854,13 @@ "SendGrid": "[9.29.3, 9.29.3]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1870,6 +1890,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/test/IntegrationTestCommon/packages.lock.json b/test/IntegrationTestCommon/packages.lock.json index 671c0cc2d8b8..4a720f75f383 100644 --- a/test/IntegrationTestCommon/packages.lock.json +++ b/test/IntegrationTestCommon/packages.lock.json @@ -1851,6 +1851,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1878,7 +1881,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -1904,6 +1908,15 @@ "linq2db.EntityFrameworkCore": "[8.8.0, 8.8.0]" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1920,6 +1933,13 @@ "dbup-sqlserver": "[7.2.0, 7.2.0]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1962,6 +1982,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs index 35303e17b422..0dbdb20ba23f 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs @@ -67,21 +67,19 @@ public async Task Run_Paid_UpdatesSubscriptionThenSaves() { var organization = CreateOrganization(pamSeats: 7); _isOnTrialQuery.Run(organization).Returns(false); - var seatChange = new PrivilegedControlsSeatChange( - new OrganizationSubscriptionChangeSet - { - Changes = [new UpdateItemQuantity("privileged-controls-enterprise-seat-annually", 5)] - }, - 5); - _seatChangeSetFactory.CreateAsync(organization, 5).Returns(seatChange); + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("privileged-controls-enterprise-seat-annually", 5)] + }; + _seatChangeSetFactory.CreateAsync(organization, 5).Returns(changeSet); _updateOrganizationSubscriptionCommand - .Run(organization, seatChange.ChangeSet, Arg.Any()) + .Run(organization, changeSet, Arg.Any()) .Returns(new Subscription()); await _sut.Run(organization, 5); await _seatChangeSetFactory.Received(1).CreateAsync(organization, 5); - await _updateOrganizationSubscriptionCommand.Received(1).Run(organization, seatChange.ChangeSet); + await _updateOrganizationSubscriptionCommand.Received(1).Run(organization, changeSet); Assert.Equal(5, organization.PamSeats); await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); } diff --git a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs index 3824de27d60a..5a43458213c5 100644 --- a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs @@ -2,8 +2,10 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Services; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Subscriptions.Organization.Commands; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Queries; @@ -111,4 +113,27 @@ public void AddOrganizationSubscriptions_RegistersRemovePrivilegedControlsHandle Assert.NotNull(scope.ServiceProvider.GetService()); } + + [Fact] + public void AddPrivilegedControlsSeatAutoscaling_ResolvesAutoscaleAndRevertCommands() + { + var services = new ServiceCollection(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddLogging(); + + services.AddPrivilegedControlsSeatAutoscaling(); + using var scope = services.BuildServiceProvider().CreateScope(); + + Assert.IsType( + scope.ServiceProvider.GetService()); + Assert.IsType( + scope.ServiceProvider.GetService()); + } } diff --git a/test/Notifications.Test/packages.lock.json b/test/Notifications.Test/packages.lock.json index c45d4b07483c..f8226cb2cb53 100644 --- a/test/Notifications.Test/packages.lock.json +++ b/test/Notifications.Test/packages.lock.json @@ -1988,6 +1988,9 @@ "xunit": "[2.6.6, )" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -2021,7 +2024,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -2058,6 +2062,15 @@ "Seeder": "[2026.10.1, )" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -2101,6 +2114,13 @@ "System.IdentityModel.Tokens.Jwt": "[8.23.0, 8.23.0]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -2143,6 +2163,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } diff --git a/test/SeederApi.IntegrationTest/packages.lock.json b/test/SeederApi.IntegrationTest/packages.lock.json index 76fd243c8252..d7ff3e5ce314 100644 --- a/test/SeederApi.IntegrationTest/packages.lock.json +++ b/test/SeederApi.IntegrationTest/packages.lock.json @@ -1437,6 +1437,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1464,7 +1467,8 @@ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", "Pam.Domain": "[2026.10.1, )", - "SharedWeb": "[2026.10.1, )" + "SharedWeb": "[2026.10.1, )", + "Subscriptions.Organization": "[0.0.1, )" } }, "infrastructure.dapper": { @@ -1501,6 +1505,15 @@ "Seeder": "[2026.10.1, )" } }, + "invoicing": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.5.0, )", + "Core": "[2026.10.1, )", + "Stripe.net": "[52.1.0, 52.1.0]" + } + }, "maildelivery": { "type": "Project", "dependencies": { @@ -1517,6 +1530,13 @@ "dbup-sqlserver": "[7.2.0, 7.2.0]" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1586,6 +1606,15 @@ }, "ssrfprotection": { "type": "Project" + }, + "subscriptions.organization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "ExceptionHandling": "[0.0.1, )", + "Invoicing": "[0.0.1, )", + "OrganizationAuthorization": "[0.0.1, )" + } } } } From 543ea240ade00e71addd8c4e5f7ddb6c91731131 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:48:06 -0500 Subject: [PATCH 04/44] [PM-45107] feat: Add a command that reserves Privileged Controls seats for admin actions Admin actions that grant Privileged Controls access (editing a member, bulk activate, restore, staged invite) all need the same seat decision. This command centralizes it: cloud organizations autoscale up to their limit, self-hosted organizations report a shortfall against the license, and organizations without a seat limit are unaffected. Callers map the outcome to their own errors. --- .../PrivilegedControlsSeats/Errors.cs | 5 + .../IReservePrivilegedControlsSeatsCommand.cs | 44 ++++++ .../ReservePrivilegedControlsSeatsCommand.cs | 36 +++++ ...OrganizationServiceCollectionExtensions.cs | 2 + ...ervePrivilegedControlsSeatsCommandTests.cs | 143 ++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs new file mode 100644 index 000000000000..08d50ffd11f8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs @@ -0,0 +1,5 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; + +public record PrivilegedControlsSeatLimitReached() : BadRequestError("Privileged Controls seat limit has been reached. Ask an Owner to add seats or raise the limit."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs new file mode 100644 index 000000000000..f407af9bbe7a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs @@ -0,0 +1,44 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; + +public enum PrivilegedControlsSeatReservation +{ + /// + /// Seats are available, or the organization is not subject to a Privileged Controls seat limit. + /// + Reserved, + /// + /// A cloud organization cannot add the seats because it would pass its autoscale limit. + /// + LimitReached, + /// + /// A self-hosted organization does not have enough seats on its license, and seats cannot be autoscaled. + /// + SelfHostedShortfall +} + +/// +/// Makes sure an organization has Privileged Controls seats for members who are about to be given access. +/// +public interface IReservePrivilegedControlsSeatsCommand +{ + /// + /// Reserves seats for members who are about to be granted Privileged + /// Controls access and do not already occupy a seat. On cloud, seats are autoscaled up to the organization's limit. + /// The caller grants the access. + /// + Task RunAsync(Organization organization, int membersTakingSeats); +} + +public static class PrivilegedControlsSeats +{ + /// + /// Whether a member with this status occupies a Privileged Controls seat once granted access. + /// + public static bool OccupiesSeat(OrganizationUserStatusType status) => + status is OrganizationUserStatusType.Invited + or OrganizationUserStatusType.Accepted + or OrganizationUserStatusType.Confirmed; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs new file mode 100644 index 000000000000..34d519132dfa --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs @@ -0,0 +1,36 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; + +public class ReservePrivilegedControlsSeatsCommand( + ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, + IAutoscalePrivilegedControlsSeatsCommand autoscalePrivilegedControlsSeatsCommand, + IGlobalSettings globalSettings) : IReservePrivilegedControlsSeatsCommand +{ + public async Task RunAsync(Organization organization, int membersTakingSeats) + { + if (membersTakingSeats < 1 || !organization.UsePam || organization.PamSeats is null) + { + return PrivilegedControlsSeatReservation.Reserved; + } + + if (globalSettings.SelfHosted) + { + var seatsRequired = await countNewPamSeatsRequiredQuery + .CountNewPamSeatsRequiredAsync(organization, membersTakingSeats); + + return seatsRequired > 0 + ? PrivilegedControlsSeatReservation.SelfHostedShortfall + : PrivilegedControlsSeatReservation.Reserved; + } + + var result = await autoscalePrivilegedControlsSeatsCommand.Run(organization, membersTakingSeats); + + return result.Success + ? PrivilegedControlsSeatReservation.Reserved + : PrivilegedControlsSeatReservation.LimitReached; + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index bbcacd84c265..48e524ec6d9d 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationUserAction; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; @@ -168,6 +169,7 @@ private static void AddOrganizationUserCommands(this IServiceCollection services services.AddScoped(); services.AddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs new file mode 100644 index 000000000000..f4915da0a177 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs @@ -0,0 +1,143 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Enums; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using SeatHelper = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats.PrivilegedControlsSeats; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; + +[SutProviderCustomize] +public class ReservePrivilegedControlsSeatsCommandTests +{ + [Theory] + [BitAutoData(0)] + [BitAutoData(-1)] + public async Task RunAsync_NoMembers_ReturnsReservedWithoutCallingAutoscale(int membersTakingSeats, + Organization organization, SutProvider sutProvider) + { + organization.UsePam = true; + organization.PamSeats = 5; + + var result = await sutProvider.Sut.RunAsync(organization, membersTakingSeats); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().Run(default!, default); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_PamOff_ReturnsReservedWithoutCallingAutoscale( + Organization organization, SutProvider sutProvider) + { + organization.UsePam = false; + organization.PamSeats = 5; + + var result = await sutProvider.Sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().Run(default!, default); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_PamSeatsNull_ReturnsReservedWithoutCallingAutoscale( + Organization organization, SutProvider sutProvider) + { + organization.UsePam = true; + organization.PamSeats = null; + + var result = await sutProvider.Sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().Run(default!, default); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_SelfHosted_Shortfall_ReturnsSelfHostedShortfall( + Organization organization, SutProvider sutProvider) + { + organization.UsePam = true; + organization.PamSeats = 5; + sutProvider.GetDependency().SelfHosted.Returns(true); + sutProvider.GetDependency() + .CountNewPamSeatsRequiredAsync(organization, 2).Returns(1); + + var result = await sutProvider.Sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.SelfHostedShortfall, result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().Run(default!, default); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_SelfHosted_NoShortfall_ReturnsReserved( + Organization organization, SutProvider sutProvider) + { + organization.UsePam = true; + organization.PamSeats = 5; + sutProvider.GetDependency().SelfHosted.Returns(true); + sutProvider.GetDependency() + .CountNewPamSeatsRequiredAsync(organization, 2).Returns(0); + + var result = await sutProvider.Sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_Cloud_AutoscaleSucceeds_ReturnsReserved( + Organization organization, SutProvider sutProvider) + { + organization.UsePam = true; + organization.PamSeats = 5; + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .Run(organization, 3).Returns(new AutoscalePrivilegedControlsSeatsResult(true, null)); + + var result = await sutProvider.Sut.RunAsync(organization, 3); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await sutProvider.GetDependency() + .Received(1).Run(organization, 3); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_Cloud_AutoscaleAtLimit_ReturnsLimitReached( + Organization organization, SutProvider sutProvider) + { + organization.UsePam = true; + organization.PamSeats = 5; + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .Run(organization, 3).Returns(new AutoscalePrivilegedControlsSeatsResult(false, 6)); + + var result = await sutProvider.Sut.RunAsync(organization, 3); + + Assert.Equal(PrivilegedControlsSeatReservation.LimitReached, result); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited, true)] + [BitAutoData(OrganizationUserStatusType.Accepted, true)] + [BitAutoData(OrganizationUserStatusType.Confirmed, true)] + [BitAutoData(OrganizationUserStatusType.Revoked, false)] + [BitAutoData(OrganizationUserStatusType.Staged, false)] + public void OccupiesSeat_TrueOnlyForInvitedAcceptedConfirmed(OrganizationUserStatusType status, bool expected) + { + Assert.Equal(expected, SeatHelper.OccupiesSeat(status)); + } +} From 00e3476d5c67a9e94d13bb7659ab96a66f69f99c Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:50:13 -0500 Subject: [PATCH 05/44] [PM-45107] refactor: Rename the Privileged Controls seat occupancy helper The static helper class shared its name with the PrivilegedControlsSeats namespace, so callers under the OrganizationUsers namespace resolved the simple name to the namespace and needed an alias. Renaming it to PrivilegedControlsSeatOccupancy removes the clash. --- .../IReservePrivilegedControlsSeatsCommand.cs | 2 +- .../ReservePrivilegedControlsSeatsCommandTests.cs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs index f407af9bbe7a..680d08faa145 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs @@ -32,7 +32,7 @@ public interface IReservePrivilegedControlsSeatsCommand Task RunAsync(Organization organization, int membersTakingSeats); } -public static class PrivilegedControlsSeats +public static class PrivilegedControlsSeatOccupancy { /// /// Whether a member with this status occupies a Privileged Controls seat once granted access. diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs index f4915da0a177..65b91558a2ee 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs @@ -9,7 +9,6 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -using SeatHelper = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats.PrivilegedControlsSeats; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; @@ -138,6 +137,6 @@ public async Task RunAsync_Cloud_AutoscaleAtLimit_ReturnsLimitReached( [BitAutoData(OrganizationUserStatusType.Staged, false)] public void OccupiesSeat_TrueOnlyForInvitedAcceptedConfirmed(OrganizationUserStatusType status, bool expected) { - Assert.Equal(expected, SeatHelper.OccupiesSeat(status)); + Assert.Equal(expected, PrivilegedControlsSeatOccupancy.OccupiesSeat(status)); } } From cd45da84e1ef6dd54126967483c4b37ecbe8975d Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:53:54 -0500 Subject: [PATCH 06/44] [PM-45107] feat: Email Owners when a joining member needs a Privileged Controls seat Self-service joins (SSO, invite links) and SCIM restores cannot be blocked by a Privileged Controls seat limit. This command reserves a seat for the joining member, and when none is available it removes the member's Privileged Controls access in memory and emails the Owners so they can raise the limit. The caller saves the member in its existing save. --- ...egedControlsSeatForJoiningMemberCommand.cs | 17 + ...egedControlsSeatForJoiningMemberCommand.cs | 68 +++ .../PrivilegedControlsSeatNeededView.cs | 21 + .../PrivilegedControlsSeatNeededView.html.hbs | 529 ++++++++++++++++++ .../PrivilegedControlsSeatNeededView.text.hbs | 7 + .../PrivilegedControlsSeatNeededView.mjml | 58 ++ ...OrganizationServiceCollectionExtensions.cs | 1 + ...ontrolsSeatForJoiningMemberCommandTests.cs | 155 +++++ .../PrivilegedControlsSeatNeededViewTests.cs | 47 ++ 9 files changed, 903 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs create mode 100644 src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.cs create mode 100644 src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs create mode 100644 src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs create mode 100644 src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs create mode 100644 test/Core.Test/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededViewTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs new file mode 100644 index 000000000000..1681a214ac21 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; + +/// +/// Makes sure a member who is joining an organization with Privileged Controls access has a seat for it. +/// +public interface IReservePrivilegedControlsSeatForJoiningMemberCommand +{ + /// + /// Returns true when the member keeps Privileged Controls access. When no seat is available, sets + /// to false in memory, emails the organization's Owners, and returns + /// false. The caller saves the member in its existing save. + /// + Task RunAsync(Organization organization, OrganizationUser organizationUser, string memberEmail); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs new file mode 100644 index 000000000000..07cb6882c3e3 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs @@ -0,0 +1,68 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Mail.Mailer; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; + +public class ReservePrivilegedControlsSeatForJoiningMemberCommand( + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + IOrganizationUserRepository organizationUserRepository, + IMailer mailer, + IGlobalSettings globalSettings, + ILogger logger) + : IReservePrivilegedControlsSeatForJoiningMemberCommand +{ + public async Task RunAsync(Organization organization, OrganizationUser organizationUser, string memberEmail) + { + if (!organization.UsePam || !organizationUser.AccessPam) + { + return true; + } + + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + + if (reservation == PrivilegedControlsSeatReservation.Reserved) + { + return true; + } + + organizationUser.AccessPam = false; + await NotifyOwnersAsync(organization, memberEmail); + return false; + } + + private async Task NotifyOwnersAsync(Organization organization, string memberEmail) + { + try + { + var ownerEmails = (await organizationUserRepository.GetManyByMinimumRoleAsync( + organization.Id, OrganizationUserType.Owner)) + .Select(u => u.Email) + .Distinct(); + + var organizationName = organization.DisplayName(); + + await mailer.SendEmail(new PrivilegedControlsSeatNeededMail + { + ToEmails = ownerEmails, + Subject = $"{organizationName} needs more Privileged Controls seats", + View = new PrivilegedControlsSeatNeededView(globalSettings) + { + OrganizationId = organization.Id, + OrganizationName = CoreHelpers.PreventEmailAutoLinking(organizationName), + MemberEmail = CoreHelpers.PreventEmailAutoLinking(memberEmail) + } + }); + } + catch (Exception e) + { + logger.LogError(e, "Error encountered notifying organization owners that a member needs a Privileged Controls seat."); + } + } +} diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.cs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.cs new file mode 100644 index 000000000000..0d3c9038f187 --- /dev/null +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.cs @@ -0,0 +1,21 @@ +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; + +namespace Bit.Core.Billing.Models.Mail.Mailer; + +public class PrivilegedControlsSeatNeededView(IGlobalSettings globalSettings) : BaseMailView +{ + public required Guid OrganizationId { get; init; } + + public required string OrganizationName { get; init; } + + public required string MemberEmail { get; init; } + + public string VaultSubscriptionUrl => + $"{globalSettings.BaseServiceUri.VaultWithHash}/organizations/{OrganizationId}/billing/subscription"; +} + +public class PrivilegedControlsSeatNeededMail : BaseMail +{ + public override string Subject { get; set; } = "Privileged Controls seat needed"; +} diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs new file mode 100644 index 000000000000..a025313014ee --- /dev/null +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs @@ -0,0 +1,529 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
A member needs a Privileged Controls seat
+ +
+ +
{{ MemberEmail }} joined {{ OrganizationName }} without access to + Privileged Controls because the organization has reached its + Privileged Controls seat limit.
+ +
+ +
An owner can add more seats by going to the Subscription page and + raising the limit.
+ +
+ + + + + + + +
+ + Manage subscription + +
+ +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, + Santa Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com + | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs new file mode 100644 index 000000000000..44af0d83884a --- /dev/null +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs @@ -0,0 +1,7 @@ +A member needs a Privileged Controls seat + +{{{MemberEmail}}} joined {{{OrganizationName}}} without access to Privileged Controls because the organization has reached its Privileged Controls seat limit. + +An owner can add more seats by going to the Subscription page and raising the limit. + +Manage subscription: {{{VaultSubscriptionUrl}}} diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml new file mode 100644 index 000000000000..8048e7d755c1 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + A member needs a Privileged Controls seat + + + {{ MemberEmail }} joined {{ OrganizationName }} without access to + Privileged Controls because the organization has reached its + Privileged Controls seat limit. + + + An owner can add more seats by going to the Subscription page and + raising the limit. + + Manage subscription + + + + + + + + + + diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 48e524ec6d9d..81276b4a554e 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -170,6 +170,7 @@ private static void AddOrganizationUserCommands(this IServiceCollection services services.AddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs new file mode 100644 index 000000000000..bed068421e2c --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs @@ -0,0 +1,155 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Billing.Models.Mail.Mailer; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; + +[SutProviderCustomize] +public class ReservePrivilegedControlsSeatForJoiningMemberCommandTests +{ + private const string _memberEmail = "member@example.com"; + + [Theory] + [BitAutoData] + public async Task RunAsync_MemberWithoutAccess_ReturnsTrueWithoutReserving( + Organization organization, OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = false; + + var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + + Assert.True(result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().RunAsync(default!, default); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_PamOff_KeepsAccessWithoutEmail( + Organization organization, OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = false; + organizationUser.AccessPam = true; + + var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + + Assert.True(result); + Assert.True(organizationUser.AccessPam); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().RunAsync(default!, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().SendEmail(default!); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_Reserved_KeepsAccessAndSendsNoEmail( + Organization organization, OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + sutProvider.GetDependency() + .RunAsync(organization, 1).Returns(PrivilegedControlsSeatReservation.Reserved); + + var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + + Assert.True(result); + Assert.True(organizationUser.AccessPam); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().SendEmail(default!); + } + + [Theory] + [BitAutoData(PrivilegedControlsSeatReservation.LimitReached)] + [BitAutoData(PrivilegedControlsSeatReservation.SelfHostedShortfall)] + public async Task RunAsync_NoSeatAvailable_RemovesAccessAndReturnsFalse( + PrivilegedControlsSeatReservation reservation, Organization organization, OrganizationUser organizationUser, + SutProvider sutProvider) + { + SetUpNoSeat(reservation, organization, organizationUser, sutProvider, ["owner@example.com"]); + + var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + + Assert.False(result); + Assert.False(organizationUser.AccessPam); + } + + [Theory] + [BitAutoData(PrivilegedControlsSeatReservation.LimitReached)] + [BitAutoData(PrivilegedControlsSeatReservation.SelfHostedShortfall)] + public async Task RunAsync_NoSeatAvailable_EmailsOwners( + PrivilegedControlsSeatReservation reservation, Organization organization, OrganizationUser organizationUser, + SutProvider sutProvider) + { + SetUpNoSeat(reservation, organization, organizationUser, sutProvider, + ["owner1@example.com", "owner2@example.com", "owner1@example.com"]); + + await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + + await sutProvider.GetDependency().Received(1).SendEmail(Arg.Is(mail => + mail.ToEmails.SequenceEqual(new[] { "owner1@example.com", "owner2@example.com" }) + && mail.View.MemberEmail == CoreHelpers.PreventEmailAutoLinking(_memberEmail) + && mail.View.OrganizationId == organization.Id)); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_LimitReached_RemovesAccessAndEmailsOwners( + Organization organization, OrganizationUser organizationUser, + SutProvider sutProvider) + { + SetUpNoSeat(PrivilegedControlsSeatReservation.LimitReached, organization, organizationUser, sutProvider, + ["owner@example.com"]); + + var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + + Assert.False(result); + Assert.False(organizationUser.AccessPam); + await sutProvider.GetDependency().Received(1).SendEmail(Arg.Is(mail => + mail.ToEmails.SequenceEqual(new[] { "owner@example.com" }))); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_EmailFails_StillReturnsFalseWithoutThrowing( + Organization organization, OrganizationUser organizationUser, + SutProvider sutProvider) + { + SetUpNoSeat(PrivilegedControlsSeatReservation.LimitReached, organization, organizationUser, sutProvider, + ["owner@example.com"]); + sutProvider.GetDependency().SendEmail(default!).ThrowsAsyncForAnyArgs(new Exception("smtp down")); + + var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + + Assert.False(result); + Assert.False(organizationUser.AccessPam); + } + + private static void SetUpNoSeat(PrivilegedControlsSeatReservation reservation, Organization organization, + OrganizationUser organizationUser, + SutProvider sutProvider, string[] ownerEmails) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + sutProvider.GetDependency() + .RunAsync(organization, 1).Returns(reservation); + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) + .Returns(ownerEmails.Select(email => new OrganizationUserUserDetails { Email = email }).ToList()); + } +} diff --git a/test/Core.Test/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededViewTests.cs b/test/Core.Test/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededViewTests.cs new file mode 100644 index 000000000000..548f253c5ee4 --- /dev/null +++ b/test/Core.Test/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededViewTests.cs @@ -0,0 +1,47 @@ +using Bit.Core.Billing.Models.Mail.Mailer; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Billing.Models.Mail.Mailer; + +public class PrivilegedControlsSeatNeededViewTests +{ + private static readonly Guid _organizationId = Guid.Parse("6d1f2b7c-0b8a-4a1e-9f0d-2f7b3c4d5e6a"); + + [Fact] + public async Task RenderAsync_CarriesTheOrganizationMemberAndSubscriptionLinkInBothTemplates() + { + var (html, text) = await RenderAsync(View()); + + foreach (var body in new[] { html, text }) + { + Assert.Contains("Contoso", body); + Assert.Contains("member@example.com", body); + Assert.Contains($"https://vault.example.com/#/organizations/{_organizationId}/billing/subscription", body); + } + } + + [Fact] + public async Task RenderAsync_EscapesTheMemberEmailInTheHtmlBody() + { + var (html, _) = await RenderAsync(View(memberEmail: "")); + + Assert.DoesNotContain("", html); + Assert.Contains("<script>", html); + } + + private static PrivilegedControlsSeatNeededView View(string memberEmail = "member@example.com") => + new(new GlobalSettings { BaseServiceUri = { Vault = "https://vault.example.com" } }) + { + OrganizationId = _organizationId, + OrganizationName = "Contoso", + MemberEmail = memberEmail + }; + + private static Task<(string html, string txt)> RenderAsync(BaseMailView view) => + new HandlebarMailRenderer(Substitute.For>(), new GlobalSettings()) + .RenderAsync(view); +} From 1967134e6326a3e8feda331917c87235af07f7a9 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:57:32 -0500 Subject: [PATCH 07/44] [PM-45107] feat: Autoscale Privileged Controls seats when an admin gives a member access Editing a member to grant Privileged Controls access previously only blocked self-hosted organizations that were short on seats, and cloud organizations were never checked. Route the grant through the shared seat reservation command so cloud organizations autoscale up to their limit and report a clear error when the limit is reached. Revoked and staged members do not occupy a seat, so no seat is reserved for them. --- .../v2/UpdateOrganizationUserCommand.cs | 20 +-- .../v2/UpdateOrganizationUserCommandTests.cs | 118 +++++++++++++++--- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs index 1a381161b6d1..bd93f2a8e28e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.UserFeatures.UserEmail; -using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; using Bit.Core.Enums; @@ -27,7 +27,7 @@ public class UpdateOrganizationUserCommand( IOrganizationUserRepository organizationUserRepository, IUpdateOrganizationUserValidator validator, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, - ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IPricingClient pricingClient, IEventService eventService, @@ -204,15 +204,19 @@ await mailer.SendEmail(new MemberEmailChangedNotificationMail private async Task TryEnablingPamAsync(UpdateOrganizationUserRequest request) { - var additionalPamSeatsRequired = - await countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(request.Organization, 1); - // Only self-hosted instances are limited here: they cannot change their subscription. Cloud grants are not seat-limited by this command. - if (additionalPamSeatsRequired > 0 && globalSettings.SelfHosted) + // Only members who occupy a seat need one reserved. Revoked and staged members are given access without one. + if (!PrivilegedControlsSeatOccupancy.OccupiesSeat(request.OrganizationUserToUpdate.Status)) { - return new CannotAutoscalePamSeatsOnSelfHost(); + return null; } - return null; + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(request.Organization, 1); + return reservation switch + { + PrivilegedControlsSeatReservation.LimitReached => new PrivilegedControlsSeatLimitReached(), + PrivilegedControlsSeatReservation.SelfHostedShortfall => new CannotAutoscalePamSeatsOnSelfHost(), + _ => null + }; } private async Task LoadUserToUpdateAsync(UpdateOrganizationUserRequest request) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index 3f5e05035791..dd71bf993fbc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.UserFeatures.UserEmail; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -15,7 +15,6 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Settings; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -114,10 +113,9 @@ public async Task UpdateUserAsync_WhenGrantingPamOnSelfHostWithoutSeats_ReturnsC { organizationUser.AccessPam = false; var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); - sutProvider.GetDependency().SelfHosted = true; - sutProvider.GetDependency() - .CountNewPamSeatsRequiredAsync(organization, 1) - .Returns(1); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); var result = await sutProvider.Sut.UpdateUserAsync(request); @@ -137,10 +135,9 @@ public async Task UpdateUserAsync_WhenGrantingPamOnCloudWithoutSeats_PersistsAcc { organizationUser.AccessPam = false; var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); - sutProvider.GetDependency().SelfHosted = false; - sutProvider.GetDependency() - .CountNewPamSeatsRequiredAsync(organization, 1) - .Returns(1); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.Reserved); var result = await sutProvider.Sut.UpdateUserAsync(request); @@ -160,10 +157,9 @@ public async Task UpdateUserAsync_WhenGrantingPamOnSelfHostWithSeatsAvailable_Pe { organizationUser.AccessPam = false; var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); - sutProvider.GetDependency().SelfHosted = true; - sutProvider.GetDependency() - .CountNewPamSeatsRequiredAsync(organization, 1) - .Returns(0); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.Reserved); var result = await sutProvider.Sut.UpdateUserAsync(request); @@ -174,6 +170,100 @@ await sutProvider.GetDependency() Arg.Any>()); } + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_EnablingPam_ConfirmedMember_ReservesOneSeat( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = false; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, 1); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_EnablingPam_AtLimit_ReturnsSeatLimitReachedAndDoesNotSave( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = false; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any(), Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_EnablingPam_RevokedMember_DoesNotReserve( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = false; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_EnablingPam_StagedMember_DoesNotReserve( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Staged, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = false; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_AlreadyHasPam_DoesNotReserve( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = true; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + [Theory] [BitAutoData] public async Task UpdateUserAsync_WhenEmailChanged_NotifiesMemberAtPreviousEmail( From 143e5a10545b3966be090ce3a72e774bc1dd2420 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:58:39 -0500 Subject: [PATCH 08/44] [PM-45107] feat: Autoscale Privileged Controls seats when bulk activating members Bulk activation previously only blocked self-hosted organizations that were short on seats, and cloud organizations were never checked. Route the grant through the shared seat reservation command so cloud organizations autoscale up to their limit and nothing is saved when the limit is reached. Only members who occupy a seat are counted. Revoked and staged members still receive access without a seat. --- .../BulkEnablePam/BulkEnablePamCommand.cs | 18 +-- .../BulkEnablePamCommandTests.cs | 122 ++++++++++++++++-- 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs index 7fe5ae196169..e1e9c9036c14 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs @@ -1,18 +1,16 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; -using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Repositories; -using Bit.Core.Settings; using OneOf.Types; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.BulkEnablePam; public class BulkEnablePamCommand( IOrganizationUserRepository organizationUserRepository, - ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, - IGlobalSettings globalSettings) : IBulkEnablePamCommand + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand) : IBulkEnablePamCommand { public async Task BulkEnablePamAsync(Organization organization, IEnumerable organizationUserIds) { @@ -31,11 +29,15 @@ public async Task BulkEnablePamAsync(Organization organization, I return new PamNotEnabled(); } - var additionalPamSeatsRequired = - await countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, organizationUsers.Count); - if (additionalPamSeatsRequired > 0 && globalSettings.SelfHosted) + // Revoked and staged members are given access without occupying a seat. + var membersTakingSeats = organizationUsers.Count(u => PrivilegedControlsSeatOccupancy.OccupiesSeat(u.Status)); + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); + switch (reservation) { - return new CannotAutoscalePamSeatsOnSelfHost(); + case PrivilegedControlsSeatReservation.LimitReached: + return new PrivilegedControlsSeatLimitReached(); + case PrivilegedControlsSeatReservation.SelfHostedShortfall: + return new CannotAutoscalePamSeatsOnSelfHost(); } foreach (var organizationUser in organizationUsers) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs index eafede1a7f8e..7909c2132695 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.BulkEnablePam; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; -using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Repositories; -using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -131,10 +131,9 @@ public async Task BulkEnablePamAsync_WhenSelfHostedAndSeatsRequired_ReturnsCanno sutProvider.GetDependency().GetManyAsync(organizationUserIds) .Returns(organizationUsers); - sutProvider.GetDependency().SelfHosted = true; - sutProvider.GetDependency() - .CountNewPamSeatsRequiredAsync(organization, organizationUsers.Count) - .Returns(1); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -160,10 +159,9 @@ public async Task BulkEnablePamAsync_WhenCloudAndSeatsRequired_Grants(Organizati sutProvider.GetDependency().GetManyAsync(organizationUserIds) .Returns(organizationUsers); - sutProvider.GetDependency().SelfHosted = false; - sutProvider.GetDependency() - .CountNewPamSeatsRequiredAsync(organization, organizationUsers.Count) - .Returns(1); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(PrivilegedControlsSeatReservation.Reserved); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -188,10 +186,9 @@ public async Task BulkEnablePamAsync_WhenSelfHostedAndNoSeatsRequired_Grants(Org sutProvider.GetDependency().GetManyAsync(organizationUserIds) .Returns(organizationUsers); - sutProvider.GetDependency().SelfHosted = true; - sutProvider.GetDependency() - .CountNewPamSeatsRequiredAsync(organization, organizationUsers.Count) - .Returns(0); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(PrivilegedControlsSeatReservation.Reserved); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -200,4 +197,101 @@ await sutProvider.GetDependency() .Received(1) .ReplaceManyAsync(Arg.Is>(users => users.All(u => u.AccessPam))); } + + [Theory] + [BitAutoData] + public async Task BulkEnablePamAsync_MixedStatuses_ReservesOnlySeatUsingMembers(Organization organization, + List organizationUserIds, SutProvider sutProvider) + { + organization.UsePam = true; + var statuses = new[] + { + OrganizationUserStatusType.Confirmed, OrganizationUserStatusType.Confirmed, + OrganizationUserStatusType.Invited, OrganizationUserStatusType.Revoked, OrganizationUserStatusType.Staged + }; + var organizationUsers = statuses.Select(status => new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + AccessPam = false, + Status = status + }).ToList(); + sutProvider.GetDependency().GetManyAsync(organizationUserIds) + .Returns(organizationUsers); + sutProvider.GetDependency() + .RunAsync(organization, 3) + .Returns(PrivilegedControlsSeatReservation.Reserved); + + var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, 3); + await sutProvider.GetDependency() + .Received(1) + .ReplaceManyAsync(Arg.Is>(users => + users.Count() == 5 && users.All(u => u.AccessPam))); + } + + [Theory] + [BitAutoData] + public async Task BulkEnablePamAsync_AtLimit_ReturnsSeatLimitReachedAndSavesNothing(Organization organization, + List organizationUserIds, List organizationUsers, + SutProvider sutProvider) + { + organization.UsePam = true; + foreach (var organizationUser in organizationUsers) + { + organizationUser.OrganizationId = organization.Id; + organizationUser.AccessPam = false; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + } + + sutProvider.GetDependency().GetManyAsync(organizationUserIds) + .Returns(organizationUsers); + sutProvider.GetDependency() + .RunAsync(organization, organizationUsers.Count) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceManyAsync(default!); + } + + [Theory] + [BitAutoData] + public async Task BulkEnablePamAsync_OnlyRevokedAndStaged_ReservesZero(Organization organization, + List organizationUserIds, SutProvider sutProvider) + { + organization.UsePam = true; + var organizationUsers = new[] { OrganizationUserStatusType.Revoked, OrganizationUserStatusType.Staged } + .Select(status => new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + AccessPam = false, + Status = status + }).ToList(); + sutProvider.GetDependency().GetManyAsync(organizationUserIds) + .Returns(organizationUsers); + sutProvider.GetDependency() + .RunAsync(organization, 0) + .Returns(PrivilegedControlsSeatReservation.Reserved); + + var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, 0); + await sutProvider.GetDependency() + .Received(1) + .ReplaceManyAsync(Arg.Is>(users => + users.Count() == 2 && users.All(u => u.AccessPam))); + } } From fc6ee4e665e40768aec7def303e39f43f4c31172 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:49 -0500 Subject: [PATCH 09/44] [PM-45107] feat: Autoscale Privileged Controls seats when restoring members Restoring a revoked member who has Privileged Controls access returns them to a seat, so the seat has to be reserved first. Admin single and bulk restores now reserve seats and fail with the seat limit message when none can be added. SCIM restores cannot fail the request, so the member is restored without Privileged Controls access and the Owners are told. Public API restores are unchanged. --- .../v1/RestoreOrganizationUserCommand.cs | 54 ++++- .../RestoreOrganizationUserCommandTests.cs | 228 ++++++++++++++++++ 2 files changed, 278 insertions(+), 4 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 12bebf3d023e..fdf70bd2e991 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -3,6 +3,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -33,7 +35,9 @@ public class RestoreOrganizationUserCommand( ICollectionRepository collectionRepository, IAutomaticUserConfirmationPolicyEnforcementHandler automaticUserConfirmationPolicyEnforcementHandler, IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, - TimeProvider timeProvider) : IRestoreOrganizationUserCommand + TimeProvider timeProvider, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName) { @@ -54,7 +58,7 @@ public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? rest throw new BadRequestException(new CustomUsersCannotRestoreAdmins().Message); } - await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName); + await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName, null); await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); if (organizationUser.UserId.HasValue) @@ -65,7 +69,7 @@ public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? rest public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser) { - await RepositoryRestoreUserAsync(organizationUser, null); // users stored by a system user will not get a default collection at this point. + await RepositoryRestoreUserAsync(organizationUser, null, systemUser); // users stored by a system user will not get a default collection at this point. await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, systemUser); @@ -75,7 +79,8 @@ await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.Org } } - private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, string defaultCollectionName) + private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, string defaultCollectionName, + EventSystemUser? systemUser) { if (organizationUser.Status != OrganizationUserStatusType.Revoked) { @@ -110,6 +115,26 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, var status = organizationUser.GetPriorActiveOrganizationUserStatusType(); + if (organization.UsePam && organizationUser.AccessPam && PrivilegedControlsSeatOccupancy.OccupiesSeat(status)) + { + if (systemUser is null) + { + await ReservePrivilegedControlsSeatsAsync(organization, 1); + } + else if (systemUser == EventSystemUser.SCIM) + { + var memberEmail = organizationUser.Email + ?? (await userRepository.GetByIdAsync(organizationUser.UserId!.Value)).Email; + var keepsAccess = await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync( + organization, organizationUser, memberEmail); + if (!keepsAccess) + { + // RestoreAsync only writes the status, so the removed access has to be saved separately. + await organizationUserRepository.ReplaceAsync(organizationUser); + } + } + } + await organizationUserRepository.RestoreAsync(organizationUser.Id, status); if (status == OrganizationUserStatusType.Invited) @@ -130,6 +155,18 @@ await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.Organi } } + private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int membersTakingSeats) + { + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); + switch (reservation) + { + case PrivilegedControlsSeatReservation.LimitReached: + throw new BadRequestException(new PrivilegedControlsSeatLimitReached().Message); + case PrivilegedControlsSeatReservation.SelfHostedShortfall: + throw new BadRequestException(new CannotAutoscalePamSeatsOnSelfHost().Message); + } + } + private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser) { var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId!.Value); @@ -197,6 +234,15 @@ public async Task>> RestoreUsersAsync(Guid var newSeatsRequired = organizationUserIds.Count() - availableSeats; await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); + if (organization.UsePam) + { + var membersTakingPamSeats = filteredUsers.Count(u => + u.Status == OrganizationUserStatusType.Revoked + && u.AccessPam + && PrivilegedControlsSeatOccupancy.OccupiesSeat(u.GetPriorActiveOrganizationUserStatusType())); + await ReservePrivilegedControlsSeatsAsync(organization, membersTakingPamSeats); + } + var restoringUserIsOwner = false; var restoringUserIsAdminOrHigher = false; if (restoringUserId.HasValue) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 2994b4fbf6c8..32ba9ab965f6 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -2,7 +2,9 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -1232,6 +1234,232 @@ await organizationUserRepository .RestoreAsync(orgUser1.Id, Arg.Is(x => x != OrganizationUserStatusType.Revoked)); } + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPam_ReservesOneSeat( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); + + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, 1); + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPam_AtLimit_ThrowsSeatLimitMessage( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Equal(new PrivilegedControlsSeatLimitReached().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPam_SelfHostedShortfall_ThrowsSelfHostMessage( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Equal(new CannotAutoscalePamSeatsOnSelfHost().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_PriorStatusStaged_DoesNotReserve( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Staged; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Scim_AtLimit_RestoresWithoutPamAndSaves( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, null, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, organizationUser, Arg.Any()) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return false; + }); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); + + var organizationUserRepository = sutProvider.GetDependency(); + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => ou.Id == organizationUser.Id && !ou.AccessPam)); + organizationUserRepository.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + }); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Scim_SeatAvailable_RestoresWithPam( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, null, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, organizationUser, Arg.Any()) + .Returns(true); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); + + var organizationUserRepository = sutProvider.GetDependency(); + await organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await organizationUserRepository.Received(1).RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + Assert.True(organizationUser.AccessPam); + } + + [Theory, BitAutoData] + public async Task RestoreUser_PublicApi_MemberWithPam_DoesNotTouchSeats( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, null, organizationUser, sutProvider); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!, default!); + Assert.True(organizationUser.AccessPam); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_CountsOnlyRevokedMembersWithPamReturningToSeatUsingStatus( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser confirmedWithPam, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser acceptedWithPam, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser invitedWithPam, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser stagedWithPam, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser confirmedWithoutPam, + SutProvider sutProvider) + { + organization.UsePam = true; + var userService = Substitute.For(); + RestoreUser_Setup(organization, owner, confirmedWithPam, sutProvider); + confirmedWithPam.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + acceptedWithPam.StatusNew = OrganizationUserStatusTypeNew.Accepted; + invitedWithPam.StatusNew = OrganizationUserStatusTypeNew.Invited; + stagedWithPam.StatusNew = OrganizationUserStatusTypeNew.Staged; + confirmedWithoutPam.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + OrganizationUser[] orgUsers = [confirmedWithPam, acceptedWithPam, invitedWithPam, stagedWithPam, confirmedWithoutPam]; + foreach (var orgUser in orgUsers) + { + orgUser.OrganizationId = organization.Id; + orgUser.AccessPam = true; + } + confirmedWithoutPam.AccessPam = false; + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(orgUsers); + + await sutProvider.Sut.RestoreUsersAsync(organization.Id, orgUsers.Select(ou => ou.Id).ToList(), owner.Id, userService, null); + + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, 3); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_AtLimit_ThrowsBeforeRestoringAnyone( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + SutProvider sutProvider) + { + organization.UsePam = true; + var userService = Substitute.For(); + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + orgUser1.AccessPam = true; + orgUser1.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns([orgUser1]); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null)); + + Assert.Equal(new PrivilegedControlsSeatLimitReached().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + } + private static void RestoreUser_Setup( Organization organization, OrganizationUser? requestingOrganizationUser, From 40f8103e5c74610f6047ae7e968ae8384be26326 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:06:18 -0500 Subject: [PATCH 10/44] [PM-45107] feat: Autoscale Privileged Controls seats when inviting staged members Staged members who already have Privileged Controls access start using a seat once they are invited, so the invite has to reserve those seats. At the limit the whole invite fails before any Password Manager seats are bought. If a later step fails, the Privileged Controls seats bought in this request are given back, the same way the command already gives back Password Manager and Secrets Manager seats. --- .../InviteStagedOrganizationUsersCommand.cs | 60 ++++++ ...viteStagedOrganizationUsersCommandTests.cs | 179 ++++++++++++++++++ 2 files changed, 239 insertions(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index 5928752cdae0..54b7a2e77568 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -2,8 +2,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -26,6 +29,8 @@ public class InviteStagedOrganizationUsersCommand( IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IStripePaymentService stripePaymentService, IPricingClient pricingClient, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + IRevertPrivilegedControlsSeatsCommand revertPrivilegedControlsSeatsCommand, ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, IEventService eventService, TimeProvider timeProvider, @@ -63,9 +68,19 @@ public async Task>> RunAsync(Invite return secretsManagerValidationError; } + // Captured before the reservation, because a successful autoscale writes the new total onto the entity. + var initialPamSeats = organization.PamSeats; + + var pamSeatReservationError = await ReservePrivilegedControlsSeatsAsync(organization, eligible); + if (pamSeatReservationError is not null) + { + return pamSeatReservationError; + } + var seatReservationError = await ReserveSeatsAsync(organization, seatsToAdd); if (seatReservationError is not null) { + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); return seatReservationError; } @@ -77,6 +92,7 @@ public async Task>> RunAsync(Invite if (secretsManagerSeatReservationError is not null) { await ReleaseSeatsAsync(organization, seatsToAdd); + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); return secretsManagerSeatReservationError; } @@ -88,6 +104,7 @@ public async Task>> RunAsync(Invite { await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); await ReleaseSeatsAsync(organization, seatsToAdd); + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); throw; } @@ -391,4 +408,47 @@ private async Task ReleaseSeatsAsync(Organization organization, int seatsToRelea seatsToRelease, organization.Id); } } + + /// + /// Reserves a Privileged Controls seat for each eligible member who already has access to it. + /// + private async Task ReservePrivilegedControlsSeatsAsync( + Organization organization, + List organizationUsers) + { + var membersWithPrivilegedControls = organization.UsePam + ? organizationUsers.Count(organizationUser => organizationUser.AccessPam) + : 0; + + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersWithPrivilegedControls); + return reservation switch + { + PrivilegedControlsSeatReservation.LimitReached => new PrivilegedControlsSeatLimitReached(), + PrivilegedControlsSeatReservation.SelfHostedShortfall => new CannotAutoscalePamSeatsOnSelfHost(), + _ => null + }; + } + + /// + /// Restores the Privileged Controls seat count captured before the reservation when a later step means + /// nobody will be invited. + /// + private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization, int? initialPamSeats) + { + if (initialPamSeats is null || initialPamSeats == organization.PamSeats) + { + return; + } + + try + { + await revertPrivilegedControlsSeatsCommand.Run(organization, initialPamSeats.Value); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to release Privileged Controls seat(s) for organization {OrganizationId}", + organization.Id); + } + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index 58bb2aadc9fa..b241ceffdddc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -2,10 +2,13 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -713,6 +716,182 @@ public async Task RunAsync_WhenOrganizationHasNoSecretsManagerStandalone_LeavesP Assert.All(organizationUsers, organizationUser => Assert.True(organizationUser.AccessSecretsManager)); } + /// + /// Gives the organization Privileged Controls with seats and the first + /// members access to it. + /// + private static void ArrangePrivilegedControls( + Organization organization, + List organizationUsers, + int membersWithAccess, + int pamSeats = 5) + { + organization.UsePam = true; + organization.PamSeats = pamSeats; + + for (var i = 0; i < organizationUsers.Count; i++) + { + organizationUsers[i].AccessPam = i < membersWithAccess; + } + } + + /// + /// Makes the reservation behave like a successful autoscale, which writes the grown total onto the entity. + /// + private static void AutoscalePamSeatsTo( + SutProvider sutProvider, Organization organization, int newPamSeats) => + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(callInfo => + { + organization.PamSeats = newPamSeats; + return PrivilegedControlsSeatReservation.Reserved; + }); + + [Theory, BitAutoData] + public async Task RunAsync_StagedMembersWithPam_ReservesTheirCount( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = Arrange(sutProvider, organization, organizationUsers, performedBy); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); + + await sutProvider.Sut.RunAsync(request); + + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, 2); + } + + [Theory, BitAutoData] + public async Task RunAsync_PamAtLimit_ReturnsSeatLimitReachedBeforeReservingPasswordManagerSeats( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + organization.MaxAutoscaleSeats = 100; + var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 9); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AutoAddSeatsAsync(default!, default); + await AssertNothingHappenedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task RunAsync_PamSelfHostedShortfall_ReturnsCannotAutoscaleOnSelfHost( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = Arrange(sutProvider, organization, organizationUsers, performedBy); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + await AssertNothingHappenedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task RunAsync_PasswordManagerReservationFails_RevertsPamSeats( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + organization.MaxAutoscaleSeats = 100; + var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 10); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); + AutoscalePamSeatsTo(sutProvider, organization, 7); + sutProvider.GetDependency() + .AutoAddSeatsAsync(organization, Arg.Any()) + .ThrowsAsync(new BadRequestException("Seat limit has been reached.")); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.True(result.IsError); + await sutProvider.GetDependency() + .Received(1) + .Run(organization, 5); + } + + [Theory, BitAutoData] + public async Task RunAsync_SecretsManagerAdjustFails_RevertsPamSeats( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = ArrangeWithSecretsManager(sutProvider, organization, organizationUsers, performedBy, + seats: 10, occupiedSeats: 9); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); + AutoscalePamSeatsTo(sutProvider, organization, 7); + sutProvider.GetDependency() + .UpdateSubscriptionAsync(Arg.Any()) + .ThrowsAsync(new GatewayException("Stripe rejected the subscription change.")); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .Received(1) + .Run(organization, 5); + } + + [Theory, BitAutoData] + public async Task RunAsync_InviteSendFails_RevertsPamSeatsAndRethrows( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = Arrange(sutProvider, organization, organizationUsers, performedBy); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); + AutoscalePamSeatsTo(sutProvider, organization, 7); + FailInviteSend(sutProvider); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + + await sutProvider.GetDependency() + .Received(1) + .Run(organization, 5); + } + + [Theory, BitAutoData] + public async Task RunAsync_PamRevertFails_StillRethrowsTheSendFailure( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = Arrange(sutProvider, organization, organizationUsers, performedBy); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); + AutoscalePamSeatsTo(sutProvider, organization, 7); + FailInviteSend(sutProvider); + sutProvider.GetDependency() + .Run(organization, 5) + .ThrowsAsync(new GatewayException("Stripe is unreachable.")); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + } + + [Theory, BitAutoData] + public async Task RunAsync_NoPamSeatsAdded_DoesNotRevert( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = Arrange(sutProvider, organization, organizationUsers, performedBy); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); + FailInviteSend(sutProvider); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .Run(default!, default); + } + /// Asserts the request succeeded overall but reported for one member. private static void AssertSkipped( CommandResult> result, Guid skippedId) where TError : Error From a12f54cc0682b4560b63a5e81951582603e707d4 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:09:26 -0500 Subject: [PATCH 11/44] [PM-45107] feat: Let staged members join by invite link without Privileged Controls at the seat limit A staged member can already hold Privileged Controls access before joining. Joining by invite link now reserves a Privileged Controls seat for that member. When none can be reserved the member still joins, without Privileged Controls access, and the Owners are emailed, instead of the join being blocked at the seat limit. --- .../AcceptOrganizationInviteLinkCommand.cs | 28 ++++ .../ConfirmOrganizationInviteLinkCommand.cs | 28 ++++ ...cceptOrganizationInviteLinkCommandTests.cs | 120 ++++++++++++++++++ ...nfirmOrganizationInviteLinkCommandTests.cs | 103 +++++++++++++++ 4 files changed, 279 insertions(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs index 37bc06d0511b..6885950114b1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.AdminConsole.Utilities; using Bit.Core.AdminConsole.Utilities.v2; @@ -26,6 +27,7 @@ public class AcceptOrganizationInviteLinkCommand( IMailService mailService, IPushAutoConfirmNotificationCommand pushAutoConfirmNotificationCommand, IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, + IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand, ILogger logger, IEventService eventService) : IAcceptOrganizationInviteLinkCommand @@ -77,6 +79,13 @@ private async Task> AcceptExistingOrgUserAsync( { return seatReservationError; } + + var privilegedControlsError = await ReservePrivilegedControlsSeatAsync( + organization, existingOrganizationUser, existingOrganizationUser.Email ?? user.Email); + if (privilegedControlsError is not null) + { + return privilegedControlsError; + } } existingOrganizationUser.Status = OrganizationUserStatusType.Accepted; @@ -94,6 +103,25 @@ private async Task> AcceptExistingOrgUserAsync( return existingOrganizationUser; } + /// + /// A staged member who has Privileged Controls access keeps it only when a seat for it can be reserved; + /// otherwise the member joins without it and the Owners are notified. + /// + private async Task ReservePrivilegedControlsSeatAsync( + Organization organization, OrganizationUser organizationUser, string memberEmail) + { + try + { + await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, organizationUser, memberEmail); + return null; + } + catch (Exception ex) when (ex is BadRequestException or GatewayException) + { + logger.LogWarning(ex, "Could not reserve a Privileged Controls seat while accepting invite link for organization {OrganizationId}", organization.Id); + return new SeatAddFailed(); + } + } + private async Task> CreateNewMembershipAsync( AcceptInviteLinkMembershipValidationResult validationResult, User user) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index 8ad827ff857f..d79ab9a9e5ca 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -37,6 +38,7 @@ public class ConfirmOrganizationInviteLinkCommand( IStripePaymentService stripePaymentService, IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, IPushNotificationService pushNotificationService, + IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand, IEventService eventService, ILogger logger) : IConfirmOrganizationInviteLinkCommand @@ -122,6 +124,13 @@ private async Task> ConfirmExistingMembershipAsy { return seatReservationError; } + + var privilegedControlsError = await ReservePrivilegedControlsSeatAsync( + organization, existingOrganizationUser, existingOrganizationUser.Email ?? user.Email); + if (privilegedControlsError is not null) + { + return privilegedControlsError; + } } existingOrganizationUser.Status = OrganizationUserStatusType.Confirmed; @@ -135,6 +144,25 @@ private async Task> ConfirmExistingMembershipAsy return existingOrganizationUser; } + /// + /// A staged member who has Privileged Controls access keeps it only when a seat for it can be reserved; + /// otherwise the member joins without it and the Owners are notified. + /// + private async Task ReservePrivilegedControlsSeatAsync( + Organization organization, OrganizationUser organizationUser, string memberEmail) + { + try + { + await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, organizationUser, memberEmail); + return null; + } + catch (Exception ex) when (ex is BadRequestException or GatewayException) + { + logger.LogWarning(ex, "Could not reserve a Privileged Controls seat while confirming invite link for organization {OrganizationId}", organization.Id); + return new ConfirmSeatAddFailed(); + } + } + /// /// Creates a new membership for the user directly in /// status with the org key, expanding the organization's seats first so a billing or persistence failure diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs index e9c6f0b21093..920d9ef10d00 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AcceptMembership; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Billing.Services; @@ -246,6 +247,125 @@ await sutProvider.GetDependency() .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any()); } + [Theory, BitAutoData] + public async Task AcceptAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, sutProvider); + SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); + stagedOrganizationUser.AccessPam = true; + var memberEmail = stagedOrganizationUser.Email; + sutProvider.GetDependency() + .RunAsync(organization, stagedOrganizationUser, memberEmail!) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return Task.FromResult(false); + }); + + var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == stagedOrganizationUser.Id && + ou.Status == OrganizationUserStatusType.Accepted && + !ou.AccessPam)); + } + + [Theory, BitAutoData] + public async Task AcceptAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, sutProvider); + SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); + stagedOrganizationUser.AccessPam = true; + sutProvider.GetDependency() + .RunAsync(organization, stagedOrganizationUser, Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == stagedOrganizationUser.Id && + ou.Status == OrganizationUserStatusType.Accepted && + ou.AccessPam)); + } + + [Theory, BitAutoData] + public async Task AcceptAsync_NotStaged_DoesNotCallJoiningCommand( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser invitedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, sutProvider); + invitedOrganizationUser.Status = OrganizationUserStatusType.Invited; + invitedOrganizationUser.Email = user.Email; + invitedOrganizationUser.UserId = null; + sutProvider.GetDependency() + .GetByOrganizationEmailAsync(organization.Id, user.Email) + .Returns(invitedOrganizationUser); + + var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(typeof(BadRequestException))] + [BitAutoData(typeof(GatewayException))] + public async Task AcceptAsync_StagedMember_PamStepThrowsBadRequest_ReturnsSeatAddFailed( + Type exceptionType, + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, sutProvider); + SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); + Exception businessFailure = exceptionType == typeof(BadRequestException) + ? new BadRequestException("seat failure") + : new GatewayException("seat failure"); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(businessFailure); + + var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + } + + private static AcceptOrganizationInviteLinkRequest BuildRequest( + Organization organization, OrganizationInviteLink inviteLink, User user) => + new() + { + OrganizationId = organization.Id, + Code = Guid.Parse(inviteLink.Code), + User = user + }; + [Theory, BitAutoData] public async Task AcceptAsync_WithAutoConfirmPolicy_AndNoSeatsAvailable_ReturnsErrorAndDoesNotPersist( Organization organization, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs index c68e45958f72..cfd206e7cc0f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -318,6 +319,108 @@ await sutProvider.GetDependency() ou.ExternalId == stagedOrganizationUser.ExternalId)); } + [Theory, BitAutoData] + public async Task ConfirmAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); + SetupStagedMembership(user, stagedOrganizationUser); + stagedOrganizationUser.AccessPam = true; + var memberEmail = stagedOrganizationUser.Email; + sutProvider.GetDependency() + .RunAsync(organization, stagedOrganizationUser, memberEmail!) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return Task.FromResult(false); + }); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == stagedOrganizationUser.Id && + ou.Status == OrganizationUserStatusType.Confirmed && + !ou.AccessPam)); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); + SetupStagedMembership(user, stagedOrganizationUser); + stagedOrganizationUser.AccessPam = true; + sutProvider.GetDependency() + .RunAsync(organization, stagedOrganizationUser, Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == stagedOrganizationUser.Id && + ou.Status == OrganizationUserStatusType.Confirmed && + ou.AccessPam)); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_NotStaged_DoesNotCallJoiningCommand( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser existingOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(typeof(BadRequestException))] + [BitAutoData(typeof(GatewayException))] + public async Task ConfirmAsync_StagedMember_PamStepThrowsBadRequest_ReturnsConfirmSeatAddFailed( + Type exceptionType, + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); + SetupStagedMembership(user, stagedOrganizationUser); + Exception businessFailure = exceptionType == typeof(BadRequestException) + ? new BadRequestException("seat failure") + : new GatewayException("seat failure"); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(businessFailure); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + AssertNoMembershipWritten(sutProvider); + } + [Theory, BitAutoData] public async Task ConfirmAsync_WhenAutoEnrollEnabled_EnrollsUser( Organization organization, From 195f7e79202104b616ab18fdd9dc1e378058fa74 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:11:46 -0500 Subject: [PATCH 12/44] [PM-45107] feat: Let staged members sign in through SSO without Privileged Controls at the seat limit A staged member can already hold Privileged Controls access before their first SSO sign-in. Promoting that member to Invited now reserves a Privileged Controls seat. When none can be reserved the member still signs in, without Privileged Controls access, and the Owners are emailed, instead of the sign-in being blocked at the seat limit. A failure while reserving the seat is reported as no seats available, like the existing seat check. If the invite email fails and the promotion is reverted, the member's Privileged Controls access is restored too. --- .../src/Sso/Controllers/AccountController.cs | 33 +++- .../Controllers/AccountControllerTest.cs | 179 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 3672bf92ccd2..5ae48e3ea68e 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -59,6 +60,7 @@ public class AccountController : Controller private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IRegisterUserCommand _registerUserCommand; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly IReservePrivilegedControlsSeatForJoiningMemberCommand _reservePrivilegedControlsSeatForJoiningMemberCommand; public AccountController( IAuthenticationSchemeProvider schemeProvider, @@ -80,7 +82,8 @@ public AccountController( IDataProtectorTokenFactory dataProtector, IOrganizationDomainRepository organizationDomainRepository, IRegisterUserCommand registerUserCommand, - ISendOrganizationInvitesCommand sendOrganizationInvitesCommand) + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand) { _schemeProvider = schemeProvider; _clientStore = clientStore; @@ -102,6 +105,7 @@ public AccountController( _organizationDomainRepository = organizationDomainRepository; _registerUserCommand = registerUserCommand; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _reservePrivilegedControlsSeatForJoiningMemberCommand = reservePrivilegedControlsSeatForJoiningMemberCommand; } [HttpGet] @@ -773,6 +777,8 @@ SsoConfigurationData ssoConfigData if (possibleOrgUser.Status == OrganizationUserStatusType.Staged) { // Seat availability was verified up-front before user creation so safe to consume this seat. + await ReservePrivilegedControlsSeatForStagedMemberAsync( + organization, possibleOrgUser, possibleOrgUser.Email ?? email); possibleOrgUser.Status = OrganizationUserStatusType.Invited; } @@ -836,6 +842,27 @@ await _organizationService.AdjustSeatsAsync(organization.Id, } } + /// + /// Reserves a Privileged Controls seat for a staged member who has Privileged Controls access. When none can + /// be reserved the member's access is removed in memory for the caller to save, and the Owners are notified. + /// Throws when the seat cannot be reserved because of an error. + /// + private async Task ReservePrivilegedControlsSeatForStagedMemberAsync( + Organization organization, + OrganizationUser orgUser, + string memberEmail) + { + try + { + await _reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, orgUser, memberEmail); + } + catch (Exception e) + { + _logger.LogInformation(e, "SSO auto provisioning failed to reserve a Privileged Controls seat"); + throw new SsoAuthnNoSeatsAvailableException(); + } + } + /// /// Promotes a Staged row to /// and sends the org invite email. @@ -851,6 +878,9 @@ private async Task PromoteStagedOrgUserAndSendInviteAsync( var previousStatus = orgUser.Status; var previousRevisionDate = orgUser.RevisionDate; + var previousAccessPam = orgUser.AccessPam; + + await ReservePrivilegedControlsSeatForStagedMemberAsync(organization, orgUser, orgUser.Email!); orgUser.Status = OrganizationUserStatusType.Invited; orgUser.RevisionDate = DateTime.UtcNow; @@ -873,6 +903,7 @@ await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( orgUser.Status = previousStatus; orgUser.RevisionDate = previousRevisionDate; + orgUser.AccessPam = previousAccessPam; await _organizationUserRepository.ReplaceAsync(orgUser); throw; diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 4b8885790a1c..4279b14fd94a 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -1,6 +1,12 @@ using System.Reflection; using System.Security.Claims; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Models.Business.Tokenables; @@ -8,6 +14,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -26,6 +33,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit.Abstractions; using AuthenticationOptions = Duende.IdentityServer.Configuration.AuthenticationOptions; @@ -1055,6 +1063,177 @@ public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingUserButNoOr Assert.Equal(email, ex.UserEmail); } + [Theory, BitAutoData] + public async Task ExternalCallback_ExistingUserStagedWithPam_AtLimit_PromotesWithoutPam( + SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var email = "staged.user@example.com"; + var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false }; + var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; + var orgUser = BuildStagedOrgUserWithPam(orgId, email); + ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); + sutProvider.GetDependency() + .RunAsync(organization, orgUser, email) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return Task.FromResult(false); + }); + + await Assert.ThrowsAsync( + () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == orgUser.Id && + ou.Status == OrganizationUserStatusType.Invited && + !ou.AccessPam)); + } + + [Theory, BitAutoData] + public async Task ExternalCallback_NewUserStagedWithPam_AtLimit_ProvisionsWithoutPam( + SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var email = "staged.new.user@example.com"; + var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; + var orgUser = BuildStagedOrgUserWithPam(orgId, email); + ArrangeStagedMember(sutProvider, organization, null, orgUser, email); + sutProvider.GetDependency() + .RunAsync(orgId, PolicyType.TwoFactorAuthentication) + .Returns(new PolicyStatus(orgId, PolicyType.TwoFactorAuthentication)); + sutProvider.GetDependency() + .RunAsync(organization, orgUser, email) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return Task.FromResult(false); + }); + + await InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == orgUser.Id && + ou.Status == OrganizationUserStatusType.Invited && + !ou.AccessPam)); + } + + [Theory, BitAutoData] + public async Task ExternalCallback_StagedWithPam_PamStepThrows_ThrowsNoSeatsAvailable( + SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var email = "staged.user@example.com"; + var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false }; + var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; + var orgUser = BuildStagedOrgUserWithPam(orgId, email); + ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new BadRequestException("seat failure")); + + await Assert.ThrowsAsync( + () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ExternalCallback_ExistingUserStagedWithPam_InviteSendFails_RestoresPam( + SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var email = "staged.user@example.com"; + var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false }; + var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; + var orgUser = BuildStagedOrgUserWithPam(orgId, email); + ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); + sutProvider.GetDependency() + .RunAsync(organization, orgUser, email) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return Task.FromResult(false); + }); + sutProvider.GetDependency() + .SendInvitesAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("mail failure")); + + await Assert.ThrowsAsync( + () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); + + // The promotion is saved and then reverted; both saves see the same instance, which ends back as Staged with access. + await sutProvider.GetDependency() + .Received(2) + .ReplaceAsync(Arg.Is(ou => + ou.Id == orgUser.Id && + ou.Status == OrganizationUserStatusType.Staged && + ou.AccessPam)); + } + + private static OrganizationUser BuildStagedOrgUserWithPam(Guid orgId, string email) => + new() + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + UserId = null, + Email = email, + Status = OrganizationUserStatusType.Staged, + Type = OrganizationUserType.User, + AccessPam = true, + }; + + private static void ArrangeStagedMember( + SutProvider sutProvider, + Organization organization, + User? existingUser, + OrganizationUser stagedOrgUser, + string email) + { + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + sutProvider.GetDependency().GetByEmailAsync(email).Returns(existingUser); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + if (existingUser != null) + { + sutProvider.GetDependency().GetManyByUserAsync(existingUser.Id) + .Returns(new List()); + } + sutProvider.GetDependency() + .GetByOrganizationEmailAsync(organization.Id, email).Returns(stagedOrgUser); + } + + private static async Task InvokeCreateUserAndOrgUserConditionallyAsync( + SutProvider sutProvider, Guid orgId, string email) + { + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "Staged User") + } as IEnumerable; + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + await (Task)method.Invoke(sutProvider.Sut, new object[] + { + orgId, + "provider-user-id", + claims, + null!, + new SsoConfigurationData() + })!; + } + [Theory, BitAutoData] public void ExternalChallenge_WithMatchingOrgId_Succeeds( SutProvider sutProvider, From d0589f5cd5c047f2159035ae7c9ce11e68eb18e0 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:15:03 -0500 Subject: [PATCH 13/44] [PM-45107] fix: Reserve the Privileged Controls seat before creating the SSO user For a staged member signing in through SSO for the first time, the Privileged Controls seat was reserved after the new user was created. A failure there left an orphaned user row and sent a welcome email for a sign-in that could not finish. The seat is now reserved up front, next to the existing seat check, so a failure stops the sign-in before any user is created. The same member row is still saved later, which persists the removed Privileged Controls access. --- .../src/Sso/Controllers/AccountController.cs | 14 +++++++++--- .../Controllers/AccountControllerTest.cs | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 5ae48e3ea68e..df1bace3591c 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -701,6 +701,14 @@ SsoConfigurationData ssoConfigData if (possibleOrgUser == null || willPromoteStagedOrgUser) { await EnsureSeatAvailableAsync(organization); + + if (willPromoteStagedOrgUser) + { + // The email claim has not been validated yet at this point, so fall back to it only when the + // staged row has no email of its own. + await ReservePrivilegedControlsSeatForStagedMemberAsync( + organization, possibleOrgUser!, possibleOrgUser!.Email ?? email!); + } } // If the email domain is verified, we can mark the email as verified @@ -776,9 +784,9 @@ SsoConfigurationData ssoConfigData if (possibleOrgUser.Status == OrganizationUserStatusType.Staged) { - // Seat availability was verified up-front before user creation so safe to consume this seat. - await ReservePrivilegedControlsSeatForStagedMemberAsync( - organization, possibleOrgUser, possibleOrgUser.Email ?? email); + // The member seat and the Privileged Controls seat were both reserved up-front before user + // creation, so it is safe to consume them here. The Privileged Controls step may have cleared + // AccessPam on this instance, which the ReplaceAsync below saves. possibleOrgUser.Status = OrganizationUserStatusType.Invited; } diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 4279b14fd94a..3293554e4af9 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -12,6 +12,7 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -1144,6 +1145,27 @@ await sutProvider.GetDependency() .ReplaceAsync(Arg.Any()); } + [Theory, BitAutoData] + public async Task ExternalCallback_NewUserStagedWithPam_PamStepThrows_ThrowsNoSeatsAvailableWithoutCreatingUser( + SutProvider sutProvider) + { + var orgId = Guid.NewGuid(); + var email = "staged.new.user@example.com"; + var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; + var orgUser = BuildStagedOrgUserWithPam(orgId, email); + ArrangeStagedMember(sutProvider, organization, null, orgUser, email); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new BadRequestException("seat failure")); + + await Assert.ThrowsAsync( + () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()); + } + [Theory, BitAutoData] public async Task ExternalCallback_ExistingUserStagedWithPam_InviteSendFails_RestoresPam( SutProvider sutProvider) From e0c6192190274413d77c2f9c370348e96e7cce0c Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:33:44 -0500 Subject: [PATCH 14/44] [PM-45107] fix: Release Privileged Controls seats when inviting staged members fails unexpectedly Privileged Controls seats are reserved before the Password Manager and Secrets Manager reservations, but those only convert known failures into error results. Any other exception skipped the release and left the seats bought for an invite that never happened. Unexpected exceptions now release the Privileged Controls seats and rethrow. --- .../InviteStagedOrganizationUsersCommand.cs | 33 ++++++++++++------- ...viteStagedOrganizationUsersCommandTests.cs | 20 +++++++++++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index 54b7a2e77568..40d182cfbeb1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -77,23 +77,32 @@ public async Task>> RunAsync(Invite return pamSeatReservationError; } - var seatReservationError = await ReserveSeatsAsync(organization, seatsToAdd); - if (seatReservationError is not null) - { - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); - return seatReservationError; - } - // Captured before the subscription grows, because a successful update writes the new total onto the entity. var initialSecretsManagerSeats = organization.SmSeats; - // Password Manager seats first: the subscription rejects more Secrets Manager seats than it has PM seats. - var secretsManagerSeatReservationError = await AdjustSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, dryRun: false); - if (secretsManagerSeatReservationError is not null) + try { - await ReleaseSeatsAsync(organization, seatsToAdd); + var seatReservationError = await ReserveSeatsAsync(organization, seatsToAdd); + if (seatReservationError is not null) + { + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); + return seatReservationError; + } + + // Password Manager seats first: the subscription rejects more Secrets Manager seats than it has PM seats. + var secretsManagerSeatReservationError = await AdjustSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, dryRun: false); + if (secretsManagerSeatReservationError is not null) + { + await ReleaseSeatsAsync(organization, seatsToAdd); + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); + return secretsManagerSeatReservationError; + } + } + catch + { + // Only unexpected exceptions land here: the known failures above return an error after releasing. await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); - return secretsManagerSeatReservationError; + throw; } try diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index b241ceffdddc..d39c62781d49 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -843,6 +843,26 @@ await sutProvider.GetDependency() .Run(organization, 5); } + [Theory, BitAutoData] + public async Task RunAsync_PasswordManagerReservationThrowsUnexpectedly_RevertsPamSeatsAndRethrows( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + organization.MaxAutoscaleSeats = 100; + var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 10); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); + AutoscalePamSeatsTo(sutProvider, organization, 7); + sutProvider.GetDependency() + .AutoAddSeatsAsync(organization, Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Unexpected failure.")); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + + await sutProvider.GetDependency() + .Received(1) + .Run(organization, 5); + } + [Theory, BitAutoData] public async Task RunAsync_InviteSendFails_RevertsPamSeatsAndRethrows( Organization organization, List organizationUsers, Guid performedBy) From 2ef71ac03361058775047ac96efedc96312bbdfc Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:34:03 -0500 Subject: [PATCH 15/44] [PM-45107] fix: Skip Privileged Controls seat reservation when an organization has no seats Billing's autoscale command rejects an organization with fewer than one Privileged Controls seat, so reserving for such an organization surfaced as a seat limit failure. An organization with zero seats is treated like one without a seat limit, the same as a null seat count. --- .../IReservePrivilegedControlsSeatsCommand.cs | 3 ++- .../ReservePrivilegedControlsSeatsCommand.cs | 2 +- .../ReservePrivilegedControlsSeatsCommandTests.cs | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs index 680d08faa145..2fe21adeaf70 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs @@ -6,7 +6,8 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Privilege public enum PrivilegedControlsSeatReservation { /// - /// Seats are available, or the organization is not subject to a Privileged Controls seat limit. + /// Seats are available, or the organization is not subject to a Privileged Controls seat limit (it has no + /// Privileged Controls seats at all). /// Reserved, /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs index 34d519132dfa..1dfa619e74e6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs @@ -12,7 +12,7 @@ public class ReservePrivilegedControlsSeatsCommand( { public async Task RunAsync(Organization organization, int membersTakingSeats) { - if (membersTakingSeats < 1 || !organization.UsePam || organization.PamSeats is null) + if (membersTakingSeats < 1 || !organization.UsePam || organization.PamSeats is null or < 1) { return PrivilegedControlsSeatReservation.Reserved; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs index 65b91558a2ee..2ca6a9f9be7f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs @@ -61,6 +61,21 @@ await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs().Run(default!, default); } + [Theory] + [BitAutoData] + public async Task RunAsync_PamSeatsZero_ReturnsReservedWithoutCallingAutoscale( + Organization organization, SutProvider sutProvider) + { + organization.UsePam = true; + organization.PamSeats = 0; + + var result = await sutProvider.Sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().Run(default!, default); + } + [Theory] [BitAutoData] public async Task RunAsync_SelfHosted_Shortfall_ReturnsSelfHostedShortfall( From 819782f62c7791e05d1e9177f9e93dcbeb2775f3 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:35:50 -0500 Subject: [PATCH 16/44] [PM-45107] refactor: Move the self-hosted Privileged Controls seat error next to the other seat errors The error is raised by restore, staged invite, bulk activate and edit, so it belongs with the shared Privileged Controls seat errors rather than in the edit-member namespace. The message is unchanged. --- .../OrganizationUsers/PrivilegedControlsSeats/Errors.cs | 2 ++ .../RestoreUser/v1/RestoreOrganizationUserCommand.cs | 1 - .../StagedUsers/InviteStagedOrganizationUsersCommand.cs | 1 - .../OrganizationUsers/UpdateUser/v2/Errors.cs | 1 - .../RestoreUser/RestoreOrganizationUserCommandTests.cs | 1 - .../StagedUsers/InviteStagedOrganizationUsersCommandTests.cs | 1 - 6 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs index 08d50ffd11f8..785a97d31ab8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs @@ -3,3 +3,5 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; public record PrivilegedControlsSeatLimitReached() : BadRequestError("Privileged Controls seat limit has been reached. Ask an Owner to add seats or raise the limit."); + +public record CannotAutoscalePamSeatsOnSelfHost() : BadRequestError("Cannot autoscale Privileged Controls seats on a self-hosted instance."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index fdf70bd2e991..0859c5bda2e7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index 40d182cfbeb1..fef5c4da971f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Billing.Organizations.Commands; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs index 150b0c8580cc..31bb67d90a62 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs @@ -11,7 +11,6 @@ public record MustHaveConfirmedOwner() : BadRequestError("Organization must have public record CustomPermissionsNotEnabled() : BadRequestError("To enable custom permissions the organization must be on an Enterprise plan."); public record CannotAssignDefaultCollection() : BadRequestError("Default collections cannot be assigned to a member."); public record CannotAutoscaleSecretsManagerSeatsOnSelfHost() : BadRequestError("Cannot autoscale on a self-hosted instance."); -public record CannotAutoscalePamSeatsOnSelfHost() : BadRequestError("Cannot autoscale Privileged Controls seats on a self-hosted instance."); public record PamNotEnabled() : BadRequestError("To grant PAM access the organization must have PAM enabled."); public record CouldNotIncreaseSeatsOfSecretManager(string Message) : BadRequestError(Message); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 32ba9ab965f6..58761de23001 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index d39c62781d49..5e362e9ee669 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Billing.Enums; From a6904f01744784188f1f1ba64fb84695976f98ff Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:51 -0500 Subject: [PATCH 17/44] [PM-45107] fix: Keep rejecting Privileged Controls autoscaling without a subscription An organization with no Stripe subscription would otherwise get Privileged Controls seats raised without anyone being billed. The tests fake the trial query the way the other Subscriptions.Organization tests stand in for internal types, so the library no longer exposes its internals to the mocking library. --- ...AutoscalePrivilegedControlsSeatsCommand.cs | 3 +- ...AutoscalePrivilegedControlsSeatsCommand.cs | 5 ++ .../Subscriptions.Organization.csproj | 1 - ...zationUsersControllerBulkEnablePamTests.cs | 57 +++++++++++++++++++ ...calePrivilegedControlsSeatsCommandTests.cs | 44 +++++++------- ...vertPrivilegedControlsSeatsCommandTests.cs | 8 +-- ...vilegedControlsSalesInitiatedTrialQuery.cs | 2 + 7 files changed, 90 insertions(+), 30 deletions(-) diff --git a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs index 80fb568871d3..8a071958969d 100644 --- a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs @@ -26,7 +26,8 @@ public interface IAutoscalePrivilegedControlsSeatsCommand /// /// /// Thrown on a self-hosted instance, when is below 1, when the organization does - /// not have Privileged Controls, or when the seat change or subscription update is rejected. + /// not have Privileged Controls or has no subscription, or when the seat change or + /// subscription update is rejected. /// /// Thrown when the organization's subscription is in a state that prevents the change. Task Run(OrganizationEntity organization, int seatsToAdd); diff --git a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs index 983fe66e1071..d4823f06e6a9 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs @@ -44,6 +44,11 @@ public async Task Run(OrganizationEntity throw new BadRequestException("Your organization doesn't have Privileged Controls."); } + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + throw new BadRequestException("No subscription found."); + } + var seatsNeeded = await organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + seatsToAdd; diff --git a/src/Libraries/Subscriptions.Organization/Subscriptions.Organization.csproj b/src/Libraries/Subscriptions.Organization/Subscriptions.Organization.csproj index 6df6213f3d9e..5a68eb5ffab3 100644 --- a/src/Libraries/Subscriptions.Organization/Subscriptions.Organization.csproj +++ b/src/Libraries/Subscriptions.Organization/Subscriptions.Organization.csproj @@ -6,7 +6,6 @@ - diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs index 305c95b1929c..c78a929212ef 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs @@ -4,12 +4,17 @@ using Bit.Api.IntegrationTest.Helpers; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; +using NSubstitute; +using Stripe; using Xunit; using V1_RestoreUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; @@ -22,13 +27,18 @@ public class OrganizationUsersControllerBulkEnablePamTests private readonly HttpClient _client; private readonly LoginHelper _loginHelper; + private const string _subscriptionId = "sub_integration_test"; + private const string _privilegedControlsSeatItemId = "si_privileged_controls_seats"; + private Organization _organization = null!; private string _ownerEmail = null!; private GlobalSettings _globalSettings = null!; + private IStripeAdapter _stripeAdapter = null!; public OrganizationUsersControllerBulkEnablePamTests(ApiApplicationFactory apiFactory) { _factory = apiFactory; + _factory.SubstituteService(_ => { }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } @@ -42,6 +52,15 @@ public async Task InitializeAsync() await _factory.LoginWithNewAccount(_ownerEmail); (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + _organization.GatewaySubscriptionId = _subscriptionId; + await _factory.GetService().ReplaceAsync(_organization); + + var plan = await _factory.GetService().GetPlanOrThrow(PlanType.EnterpriseAnnually); + var subscription = BuildSubscriptionWithPrivilegedControlsSeats(plan.PrivilegedControls.StripeSeatPlanId); + _stripeAdapter = _factory.GetService(); + _stripeAdapter.ClearReceivedCalls(); + _stripeAdapter.GetSubscriptionAsync(_subscriptionId, Arg.Any()).Returns(subscription); + _stripeAdapter.UpdateSubscriptionAsync(_subscriptionId, Arg.Any()).Returns(subscription); } public Task DisposeAsync() @@ -211,6 +230,11 @@ public async Task BulkEnablePam_WhenCloudAndSeatsInsufficient_Grants() Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); await AssertAccessPamAsync(member, true); + var reloadedOrganization = await _factory.GetService().GetByIdAsync(_organization.Id); + Assert.Equal(2, reloadedOrganization!.PamSeats); + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(_subscriptionId, + Arg.Is(options => options.Items.Any(item => + item.Id == _privilegedControlsSeatItemId && item.Quantity == 2))); } [Theory] @@ -233,6 +257,39 @@ public async Task BulkEnablePam_WithoutManageUsersPermission_ReturnsForbidden(Or await AssertAccessPamAsync(member, false); } + /// + /// An active, paid subscription that bills one Privileged Controls seat. + /// + private static Subscription BuildSubscriptionWithPrivilegedControlsSeats(string privilegedControlsSeatPriceId) => + new() + { + Id = _subscriptionId, + Status = StripeConstants.SubscriptionStatus.Active, + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, + Customer = new Customer { Id = "cus_integration_test" }, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Id = _privilegedControlsSeatItemId, + Quantity = 1, + Price = new Price + { + Id = privilegedControlsSeatPriceId, + Recurring = new PriceRecurring { Interval = StripeConstants.Intervals.Year }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.PurchasableReference] = + StripeConstants.PurchasableReferences.PrivilegedControlsSeat + } + } + } + ] + } + }; + private async Task CreateMemberAsync(Guid? organizationId = null, OrganizationUserStatusType status = OrganizationUserStatusType.Confirmed) { diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index d427188962f8..38fd0c070f5b 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs @@ -13,7 +13,6 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Subscriptions.Organization.Commands; -using Bit.Subscriptions.Organization.Queries; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -38,8 +37,7 @@ public class AutoscalePrivilegedControlsSeatsCommandTests Substitute.For(); private readonly IMailer _mailer = Substitute.For(); private readonly GlobalSettings _globalSettings = new(); - private readonly IIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = - Substitute.For(); + private readonly FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = new(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly AutoscalePrivilegedControlsSeatsCommand _sut; @@ -101,12 +99,28 @@ public async Task Run_OrganizationUsesPrivilegedControlsWithNoSeats_ThrowsBadReq await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Run_NoGatewaySubscription_ThrowsBadRequestWithoutReadingSeatsInUse(string? gatewaySubscriptionId) + { + var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); + + var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); + + Assert.Equal("No subscription found.", exception.Message); + await _organizationUserRepository.DidNotReceive() + .GetOccupiedPamSeatCountByOrganizationIdAsync(Arg.Any()); + await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task Run_OnTrial_SeatsShort_WithinLimit_SavesSeatsWithoutTouchingStripe() { var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 10); SetSeatsInUse(organization, 5); - _isOnTrialQuery.Run(organization).Returns(true); + _isOnTrialQuery.Result = true; var result = await _sut.Run(organization, 2); @@ -123,7 +137,7 @@ public async Task Run_OnTrial_PastLimit_ReturnsLimitWithoutSaving() { var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 6); SetSeatsInUse(organization, 5); - _isOnTrialQuery.Run(organization).Returns(true); + _isOnTrialQuery.Result = true; var result = await _sut.Run(organization, 2); @@ -138,29 +152,13 @@ public async Task Run_OnTrial_ReachesLimitExactly_EmailsOwners() { var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 7); SetSeatsInUse(organization, 5); - _isOnTrialQuery.Run(organization).Returns(true); + _isOnTrialQuery.Result = true; await _sut.Run(organization, 2); await _mailer.Received(1).SendEmail(Arg.Any()); } - [Fact] - public async Task Run_NoSubscriptionId_TreatedAsTrial_SavesSeats() - { - var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 10, gatewaySubscriptionId: null); - SetSeatsInUse(organization, 5); - _isOnTrialQuery.Run(organization).Returns(true); - - var result = await _sut.Run(organization, 2); - - Assert.True(result.Success); - Assert.Equal(7, organization.PamSeats); - await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); - await _updateOrganizationSubscriptionCommand.DidNotReceive() - .Run(Arg.Any(), Arg.Any(), Arg.Any()); - } - [Fact] public async Task Run_SeatsAlreadyCovered_DoesNotQueryTrial() { @@ -169,7 +167,7 @@ public async Task Run_SeatsAlreadyCovered_DoesNotQueryTrial() await _sut.Run(organization, 1); - await _isOnTrialQuery.DidNotReceive().Run(Arg.Any()); + Assert.Equal(0, _isOnTrialQuery.Calls); } [Fact] diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs index 0dbdb20ba23f..85cc6c569f53 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs @@ -5,7 +5,6 @@ using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Subscriptions.Organization.Commands; -using Bit.Subscriptions.Organization.Queries; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using Stripe; @@ -21,8 +20,7 @@ public class RevertPrivilegedControlsSeatsCommandTests private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); private readonly IOrganizationService _organizationService = Substitute.For(); - private readonly IIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = - Substitute.For(); + private readonly FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = new(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly RevertPrivilegedControlsSeatsCommand _sut; @@ -51,7 +49,7 @@ await _updateOrganizationSubscriptionCommand.DidNotReceive() public async Task Run_OnTrial_ResetsSeatsOnOrganizationOnly() { var organization = CreateOrganization(pamSeats: 7); - _isOnTrialQuery.Run(organization).Returns(true); + _isOnTrialQuery.Result = true; await _sut.Run(organization, 5); @@ -66,7 +64,7 @@ await _updateOrganizationSubscriptionCommand.DidNotReceive() public async Task Run_Paid_UpdatesSubscriptionThenSaves() { var organization = CreateOrganization(pamSeats: 7); - _isOnTrialQuery.Run(organization).Returns(false); + _isOnTrialQuery.Result = false; var changeSet = new OrganizationSubscriptionChangeSet { Changes = [new UpdateItemQuantity("privileged-controls-enterprise-seat-annually", 5)] diff --git a/test/Libraries/Subscriptions.Organization.Test/FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery.cs b/test/Libraries/Subscriptions.Organization.Test/FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery.cs index 5a9bc680ac20..23d36c0803c7 100644 --- a/test/Libraries/Subscriptions.Organization.Test/FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery.cs +++ b/test/Libraries/Subscriptions.Organization.Test/FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery.cs @@ -8,9 +8,11 @@ internal sealed class FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery : IIsOn public bool Result { get; set; } public Exception? Exception { get; set; } public OrganizationEntity? ReceivedOrganization { get; private set; } + public int Calls { get; private set; } public Task Run(OrganizationEntity organization) { + Calls++; ReceivedOrganization = organization; return Exception is null ? Task.FromResult(Result) : Task.FromException(Exception); } From 51a81fd6e0094baf385d6433e988d701e3a49010 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:54:47 -0500 Subject: [PATCH 18/44] [PM-45107] fix: Email Owners about a missing Privileged Controls seat only after the member joins Sending the email while reserving the seat could tell Owners about a join that later failed, and send it again on a retry. Self-hosted organizations get no email, matching the existing seat limit emails, and a member whose seat reservation fails now joins without Privileged Controls instead of being turned away. --- .../src/Sso/Controllers/AccountController.cs | 42 +++--- .../Controllers/AccountControllerTest.cs | 102 +++++++++------ .../AcceptOrganizationInviteLinkCommand.cs | 32 ++--- .../ConfirmOrganizationInviteLinkCommand.cs | 32 ++--- ...egedControlsSeatForJoiningMemberCommand.cs | 31 ++++- ...egedControlsSeatForJoiningMemberCommand.cs | 29 ++++- .../v1/RestoreOrganizationUserCommand.cs | 16 ++- ...cceptOrganizationInviteLinkCommandTests.cs | 86 ++++++------ ...nfirmOrganizationInviteLinkCommandTests.cs | 84 ++++++------ ...ontrolsSeatForJoiningMemberCommandTests.cs | 122 ++++++++++-------- .../RestoreOrganizationUserCommandTests.cs | 71 +++++++++- 11 files changed, 378 insertions(+), 269 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index df1bace3591c..9e783ac8f4dc 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -697,6 +697,7 @@ SsoConfigurationData ssoConfigData // won't complete. Staged rows aren't seat-counted; a Staged→Invited promotion // consumes a seat and must gate here alongside the fresh-JIT case. var willPromoteStagedOrgUser = possibleOrgUser?.Status == OrganizationUserStatusType.Staged; + var privilegedControlsAccess = JoiningMemberPrivilegedControlsAccess.Kept; if (possibleOrgUser == null || willPromoteStagedOrgUser) { @@ -704,10 +705,8 @@ SsoConfigurationData ssoConfigData if (willPromoteStagedOrgUser) { - // The email claim has not been validated yet at this point, so fall back to it only when the - // staged row has no email of its own. - await ReservePrivilegedControlsSeatForStagedMemberAsync( - organization, possibleOrgUser!, possibleOrgUser!.Email ?? email!); + privilegedControlsAccess = + await _reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, possibleOrgUser!); } } @@ -793,6 +792,12 @@ await ReservePrivilegedControlsSeatForStagedMemberAsync( possibleOrgUser.UserId = newUser.Id; possibleOrgUser.RevisionDate = DateTime.UtcNow; await _organizationUserRepository.ReplaceAsync(possibleOrgUser); + + if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) + { + await _reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync( + organization, possibleOrgUser.Email ?? newUser.Email); + } } // Create the SsoUser record to link the user to the SSO provider. @@ -850,27 +855,6 @@ await _organizationService.AdjustSeatsAsync(organization.Id, } } - /// - /// Reserves a Privileged Controls seat for a staged member who has Privileged Controls access. When none can - /// be reserved the member's access is removed in memory for the caller to save, and the Owners are notified. - /// Throws when the seat cannot be reserved because of an error. - /// - private async Task ReservePrivilegedControlsSeatForStagedMemberAsync( - Organization organization, - OrganizationUser orgUser, - string memberEmail) - { - try - { - await _reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, orgUser, memberEmail); - } - catch (Exception e) - { - _logger.LogInformation(e, "SSO auto provisioning failed to reserve a Privileged Controls seat"); - throw new SsoAuthnNoSeatsAvailableException(); - } - } - /// /// Promotes a Staged row to /// and sends the org invite email. @@ -888,7 +872,8 @@ private async Task PromoteStagedOrgUserAndSendInviteAsync( var previousRevisionDate = orgUser.RevisionDate; var previousAccessPam = orgUser.AccessPam; - await ReservePrivilegedControlsSeatForStagedMemberAsync(organization, orgUser, orgUser.Email!); + var privilegedControlsAccess = + await _reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, orgUser); orgUser.Status = OrganizationUserStatusType.Invited; orgUser.RevisionDate = DateTime.UtcNow; @@ -916,6 +901,11 @@ await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( throw; } + + if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) + { + await _reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync(organization, orgUser.Email!); + } } /// diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 3293554e4af9..f4add3f0f5d3 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -15,7 +15,6 @@ using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -1074,23 +1073,21 @@ public async Task ExternalCallback_ExistingUserStagedWithPam_AtLimit_PromotesWit var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; var orgUser = BuildStagedOrgUserWithPam(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); - sutProvider.GetDependency() - .RunAsync(organization, orgUser, email) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return Task.FromResult(false); - }); + var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( + sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); + var organizationUserRepository = sutProvider.GetDependency(); await Assert.ThrowsAsync( () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); - await sutProvider.GetDependency() - .Received(1) - .ReplaceAsync(Arg.Is(ou => + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Invited && !ou.AccessPam)); + joiningMemberCommand.NotifyOwnersAsync(organization, email); + }); } [Theory, BitAutoData] @@ -1105,26 +1102,24 @@ public async Task ExternalCallback_NewUserStagedWithPam_AtLimit_ProvisionsWithou sutProvider.GetDependency() .RunAsync(orgId, PolicyType.TwoFactorAuthentication) .Returns(new PolicyStatus(orgId, PolicyType.TwoFactorAuthentication)); - sutProvider.GetDependency() - .RunAsync(organization, orgUser, email) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return Task.FromResult(false); - }); + var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( + sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); + var organizationUserRepository = sutProvider.GetDependency(); await InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email); - await sutProvider.GetDependency() - .Received(1) - .ReplaceAsync(Arg.Is(ou => + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Invited && !ou.AccessPam)); + joiningMemberCommand.NotifyOwnersAsync(organization, email); + }); } [Theory, BitAutoData] - public async Task ExternalCallback_StagedWithPam_PamStepThrows_ThrowsNoSeatsAvailable( + public async Task ExternalCallback_ExistingUserStagedWithPam_ReservationFailed_PromotesWithoutPamOrEmail( SutProvider sutProvider) { var orgId = Guid.NewGuid(); @@ -1133,20 +1128,23 @@ public async Task ExternalCallback_StagedWithPam_PamStepThrows_ThrowsNoSeatsAvai var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; var orgUser = BuildStagedOrgUserWithPam(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); - sutProvider.GetDependency() - .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Throws(new BadRequestException("seat failure")); + var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( + sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAfterError); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .ReplaceAsync(Arg.Any()); + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == orgUser.Id && + ou.Status == OrganizationUserStatusType.Invited && + !ou.AccessPam)); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); } [Theory, BitAutoData] - public async Task ExternalCallback_NewUserStagedWithPam_PamStepThrows_ThrowsNoSeatsAvailableWithoutCreatingUser( + public async Task ExternalCallback_NewUserStagedWithPam_ReservationFailed_ProvisionsWithoutPamOrEmail( SutProvider sutProvider) { var orgId = Guid.NewGuid(); @@ -1154,16 +1152,21 @@ public async Task ExternalCallback_NewUserStagedWithPam_PamStepThrows_ThrowsNoSe var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; var orgUser = BuildStagedOrgUserWithPam(orgId, email); ArrangeStagedMember(sutProvider, organization, null, orgUser, email); - sutProvider.GetDependency() - .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Throws(new BadRequestException("seat failure")); + sutProvider.GetDependency() + .RunAsync(orgId, PolicyType.TwoFactorAuthentication) + .Returns(new PolicyStatus(orgId, PolicyType.TwoFactorAuthentication)); + var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( + sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAfterError); - await Assert.ThrowsAsync( - () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); + await InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email); await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()); + .Received(1) + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), organization); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => ou.Id == orgUser.Id && !ou.AccessPam)); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); } [Theory, BitAutoData] @@ -1176,13 +1179,8 @@ public async Task ExternalCallback_ExistingUserStagedWithPam_InviteSendFails_Res var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; var orgUser = BuildStagedOrgUserWithPam(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); - sutProvider.GetDependency() - .RunAsync(organization, orgUser, email) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return Task.FromResult(false); - }); + var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( + sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); sutProvider.GetDependency() .SendInvitesAsync(Arg.Any()) .ThrowsAsync(new InvalidOperationException("mail failure")); @@ -1197,6 +1195,24 @@ await sutProvider.GetDependency() ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Staged && ou.AccessPam)); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + } + + private static IReservePrivilegedControlsSeatForJoiningMemberCommand ArrangePrivilegedControlsAccessRemoved( + SutProvider sutProvider, + Organization organization, + OrganizationUser orgUser, + JoiningMemberPrivilegedControlsAccess outcome) + { + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, orgUser) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return Task.FromResult(outcome); + }); + return joiningMemberCommand; } private static OrganizationUser BuildStagedOrgUserWithPam(Guid orgId, string email) => diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs index 6885950114b1..6833f7a0290b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs @@ -72,6 +72,7 @@ private async Task> AcceptExistingOrgUserAsync( { var organization = validationResult.Organization; + var privilegedControlsAccess = JoiningMemberPrivilegedControlsAccess.Kept; if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -80,14 +81,11 @@ private async Task> AcceptExistingOrgUserAsync( return seatReservationError; } - var privilegedControlsError = await ReservePrivilegedControlsSeatAsync( - organization, existingOrganizationUser, existingOrganizationUser.Email ?? user.Email); - if (privilegedControlsError is not null) - { - return privilegedControlsError; - } + privilegedControlsAccess = await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync( + organization, existingOrganizationUser); } + var memberEmail = existingOrganizationUser.Email ?? user.Email; existingOrganizationUser.Status = OrganizationUserStatusType.Accepted; existingOrganizationUser.UserId = user.Id; existingOrganizationUser.Email = null; @@ -100,26 +98,12 @@ private async Task> AcceptExistingOrgUserAsync( await organizationUserRepository.ReplaceAsync(existingOrganizationUser); - return existingOrganizationUser; - } - - /// - /// A staged member who has Privileged Controls access keeps it only when a seat for it can be reserved; - /// otherwise the member joins without it and the Owners are notified. - /// - private async Task ReservePrivilegedControlsSeatAsync( - Organization organization, OrganizationUser organizationUser, string memberEmail) - { - try - { - await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, organizationUser, memberEmail); - return null; - } - catch (Exception ex) when (ex is BadRequestException or GatewayException) + if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) { - logger.LogWarning(ex, "Could not reserve a Privileged Controls seat while accepting invite link for organization {OrganizationId}", organization.Id); - return new SeatAddFailed(); + await reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); } + + return existingOrganizationUser; } private async Task> CreateNewMembershipAsync( diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index d79ab9a9e5ca..b91b154a621e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -117,6 +117,7 @@ private async Task> AddUserToOrganizationAsync(C private async Task> ConfirmExistingMembershipAsync( Organization organization, OrganizationUser existingOrganizationUser, User user, string orgUserKey) { + var privilegedControlsAccess = JoiningMemberPrivilegedControlsAccess.Kept; if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -125,14 +126,11 @@ private async Task> ConfirmExistingMembershipAsy return seatReservationError; } - var privilegedControlsError = await ReservePrivilegedControlsSeatAsync( - organization, existingOrganizationUser, existingOrganizationUser.Email ?? user.Email); - if (privilegedControlsError is not null) - { - return privilegedControlsError; - } + privilegedControlsAccess = await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync( + organization, existingOrganizationUser); } + var memberEmail = existingOrganizationUser.Email ?? user.Email; existingOrganizationUser.Status = OrganizationUserStatusType.Confirmed; existingOrganizationUser.UserId = user.Id; existingOrganizationUser.Email = null; @@ -141,26 +139,12 @@ private async Task> ConfirmExistingMembershipAsy await organizationUserRepository.ReplaceAsync(existingOrganizationUser); - return existingOrganizationUser; - } - - /// - /// A staged member who has Privileged Controls access keeps it only when a seat for it can be reserved; - /// otherwise the member joins without it and the Owners are notified. - /// - private async Task ReservePrivilegedControlsSeatAsync( - Organization organization, OrganizationUser organizationUser, string memberEmail) - { - try - { - await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, organizationUser, memberEmail); - return null; - } - catch (Exception ex) when (ex is BadRequestException or GatewayException) + if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) { - logger.LogWarning(ex, "Could not reserve a Privileged Controls seat while confirming invite link for organization {OrganizationId}", organization.Id); - return new ConfirmSeatAddFailed(); + await reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); } + + return existingOrganizationUser; } /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs index 1681a214ac21..5bbe2e002692 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs @@ -3,15 +3,38 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +public enum JoiningMemberPrivilegedControlsAccess +{ + /// + /// The member keeps Privileged Controls access. + /// + Kept, + /// + /// The organization has no Privileged Controls seat left, so the member joins without access. + /// + RemovedAtSeatLimit, + /// + /// Reserving the seat failed, so the member joins without access. + /// + RemovedAfterError +} + /// /// Makes sure a member who is joining an organization with Privileged Controls access has a seat for it. /// public interface IReservePrivilegedControlsSeatForJoiningMemberCommand { /// - /// Returns true when the member keeps Privileged Controls access. When no seat is available, sets - /// to false in memory, emails the organization's Owners, and returns - /// false. The caller saves the member in its existing save. + /// Reserves a Privileged Controls seat for the member. When no seat can be reserved, sets + /// to false in memory so the member can still join. The caller saves + /// the member in its existing save. + /// + Task RunAsync(Organization organization, OrganizationUser organizationUser); + + /// + /// Emails the organization's Owners that the member joined without Privileged Controls access. Call after the + /// member is saved, when returned . + /// Does nothing on self-hosted. /// - Task RunAsync(Organization organization, OrganizationUser organizationUser, string memberEmail); + Task NotifyOwnersAsync(Organization organization, string memberEmail); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs index 07cb6882c3e3..3b5c76be3eab 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs @@ -18,27 +18,42 @@ public class ReservePrivilegedControlsSeatForJoiningMemberCommand( ILogger logger) : IReservePrivilegedControlsSeatForJoiningMemberCommand { - public async Task RunAsync(Organization organization, OrganizationUser organizationUser, string memberEmail) + public async Task RunAsync(Organization organization, OrganizationUser organizationUser) { if (!organization.UsePam || !organizationUser.AccessPam) { - return true; + return JoiningMemberPrivilegedControlsAccess.Kept; } - var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + PrivilegedControlsSeatReservation reservation; + try + { + reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + } + catch (Exception e) + { + logger.LogError(e, "Could not reserve a Privileged Controls seat for a member joining organization {OrganizationId}; the member joins without Privileged Controls access.", + organization.Id); + organizationUser.AccessPam = false; + return JoiningMemberPrivilegedControlsAccess.RemovedAfterError; + } if (reservation == PrivilegedControlsSeatReservation.Reserved) { - return true; + return JoiningMemberPrivilegedControlsAccess.Kept; } organizationUser.AccessPam = false; - await NotifyOwnersAsync(organization, memberEmail); - return false; + return JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit; } - private async Task NotifyOwnersAsync(Organization organization, string memberEmail) + public async Task NotifyOwnersAsync(Organization organization, string memberEmail) { + if (globalSettings.SelfHosted) + { + return; + } + try { var ownerEmails = (await organizationUserRepository.GetManyByMinimumRoleAsync( diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 0859c5bda2e7..5af758b026b5 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -113,6 +113,7 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled); var status = organizationUser.GetPriorActiveOrganizationUserStatusType(); + var privilegedControlsAccess = JoiningMemberPrivilegedControlsAccess.Kept; if (organization.UsePam && organizationUser.AccessPam && PrivilegedControlsSeatOccupancy.OccupiesSeat(status)) { @@ -122,11 +123,9 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, } else if (systemUser == EventSystemUser.SCIM) { - var memberEmail = organizationUser.Email - ?? (await userRepository.GetByIdAsync(organizationUser.UserId!.Value)).Email; - var keepsAccess = await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync( - organization, organizationUser, memberEmail); - if (!keepsAccess) + privilegedControlsAccess = await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync( + organization, organizationUser); + if (privilegedControlsAccess != JoiningMemberPrivilegedControlsAccess.Kept) { // RestoreAsync only writes the status, so the removed access has to be saved separately. await organizationUserRepository.ReplaceAsync(organizationUser); @@ -142,6 +141,13 @@ await organizationUserRepository.StartInviteReminderSequencesAsync( [organizationUser.Id], timeProvider.GetUtcNow().UtcDateTime); } + if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) + { + var memberEmail = organizationUser.Email + ?? (await userRepository.GetByIdAsync(organizationUser.UserId!.Value)).Email; + await reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); + } + if (organizationUser.UserId.HasValue && organization.UseMyItems && (await policyRequirementQuery.GetAsync(organizationUser.UserId.Value)).State == OrganizationDataOwnershipState.Enabled diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs index 920d9ef10d00..1f92217af4cc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs @@ -258,24 +258,57 @@ public async Task AcceptAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( SetupHappyPath(organization, inviteLink, user, sutProvider); SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); stagedOrganizationUser.AccessPam = true; - var memberEmail = stagedOrganizationUser.Email; - sutProvider.GetDependency() - .RunAsync(organization, stagedOrganizationUser, memberEmail!) + var memberEmail = stagedOrganizationUser.Email!; + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, stagedOrganizationUser) .Returns(callInfo => { callInfo.Arg().AccessPam = false; - return Task.FromResult(false); + return Task.FromResult(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); }); + var organizationUserRepository = sutProvider.GetDependency(); var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); Assert.True(result.IsSuccess); - await sutProvider.GetDependency() - .Received(1) - .ReplaceAsync(Arg.Is(ou => + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => ou.Id == stagedOrganizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted && !ou.AccessPam)); + joiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); + }); + } + + [Theory, BitAutoData] + public async Task AcceptAsync_StagedMemberWithPam_ReservationFailed_JoinsWithoutPamOrEmail( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, sutProvider); + SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); + stagedOrganizationUser.AccessPam = true; + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, stagedOrganizationUser) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return Task.FromResult(JoiningMemberPrivilegedControlsAccess.RemovedAfterError); + }); + + var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => ou.Id == stagedOrganizationUser.Id && !ou.AccessPam)); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); } [Theory, BitAutoData] @@ -289,9 +322,10 @@ public async Task AcceptAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( SetupHappyPath(organization, inviteLink, user, sutProvider); SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); stagedOrganizationUser.AccessPam = true; - sutProvider.GetDependency() - .RunAsync(organization, stagedOrganizationUser, Arg.Any()) - .Returns(true); + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, stagedOrganizationUser) + .Returns(JoiningMemberPrivilegedControlsAccess.Kept); var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); @@ -302,6 +336,7 @@ await sutProvider.GetDependency() ou.Id == stagedOrganizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted && ou.AccessPam)); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); } [Theory, BitAutoData] @@ -325,36 +360,7 @@ public async Task AcceptAsync_NotStaged_DoesNotCallJoiningCommand( Assert.True(result.IsSuccess); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData(typeof(BadRequestException))] - [BitAutoData(typeof(GatewayException))] - public async Task AcceptAsync_StagedMember_PamStepThrowsBadRequest_ReturnsSeatAddFailed( - Type exceptionType, - Organization organization, - OrganizationInviteLink inviteLink, - User user, - OrganizationUser stagedOrganizationUser, - SutProvider sutProvider) - { - SetupHappyPath(organization, inviteLink, user, sutProvider); - SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); - Exception businessFailure = exceptionType == typeof(BadRequestException) - ? new BadRequestException("seat failure") - : new GatewayException("seat failure"); - sutProvider.GetDependency() - .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Throws(businessFailure); - - var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); - - Assert.True(result.IsError); - Assert.IsType(result.AsError); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .ReplaceAsync(Arg.Any()); + .RunAsync(Arg.Any(), Arg.Any()); } private static AcceptOrganizationInviteLinkRequest BuildRequest( diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs index cfd206e7cc0f..4f975a37bb8f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -330,28 +330,32 @@ public async Task ConfirmAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); SetupStagedMembership(user, stagedOrganizationUser); stagedOrganizationUser.AccessPam = true; - var memberEmail = stagedOrganizationUser.Email; - sutProvider.GetDependency() - .RunAsync(organization, stagedOrganizationUser, memberEmail!) + var memberEmail = stagedOrganizationUser.Email!; + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, stagedOrganizationUser) .Returns(callInfo => { callInfo.Arg().AccessPam = false; - return Task.FromResult(false); + return Task.FromResult(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); }); + var organizationUserRepository = sutProvider.GetDependency(); var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); Assert.True(result.IsSuccess); - await sutProvider.GetDependency() - .Received(1) - .ReplaceAsync(Arg.Is(ou => + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => ou.Id == stagedOrganizationUser.Id && ou.Status == OrganizationUserStatusType.Confirmed && !ou.AccessPam)); + joiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); + }); } [Theory, BitAutoData] - public async Task ConfirmAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( + public async Task ConfirmAsync_StagedMemberWithPam_ReservationFailed_JoinsWithoutPamOrEmail( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -361,64 +365,68 @@ public async Task ConfirmAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); SetupStagedMembership(user, stagedOrganizationUser); stagedOrganizationUser.AccessPam = true; - sutProvider.GetDependency() - .RunAsync(organization, stagedOrganizationUser, Arg.Any()) - .Returns(true); + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, stagedOrganizationUser) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return Task.FromResult(JoiningMemberPrivilegedControlsAccess.RemovedAfterError); + }); var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); Assert.True(result.IsSuccess); await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(ou => - ou.Id == stagedOrganizationUser.Id && - ou.Status == OrganizationUserStatusType.Confirmed && - ou.AccessPam)); + .ReplaceAsync(Arg.Is(ou => ou.Id == stagedOrganizationUser.Id && !ou.AccessPam)); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); } [Theory, BitAutoData] - public async Task ConfirmAsync_NotStaged_DoesNotCallJoiningCommand( + public async Task ConfirmAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( Organization organization, OrganizationInviteLink inviteLink, User user, - OrganizationUser existingOrganizationUser, + OrganizationUser stagedOrganizationUser, SutProvider sutProvider) { - SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); + SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); + SetupStagedMembership(user, stagedOrganizationUser); + stagedOrganizationUser.AccessPam = true; + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, stagedOrganizationUser) + .Returns(JoiningMemberPrivilegedControlsAccess.Kept); var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); Assert.True(result.IsSuccess); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == stagedOrganizationUser.Id && + ou.Status == OrganizationUserStatusType.Confirmed && + ou.AccessPam)); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); } - [Theory] - [BitAutoData(typeof(BadRequestException))] - [BitAutoData(typeof(GatewayException))] - public async Task ConfirmAsync_StagedMember_PamStepThrowsBadRequest_ReturnsConfirmSeatAddFailed( - Type exceptionType, + [Theory, BitAutoData] + public async Task ConfirmAsync_NotStaged_DoesNotCallJoiningCommand( Organization organization, OrganizationInviteLink inviteLink, User user, - OrganizationUser stagedOrganizationUser, + OrganizationUser existingOrganizationUser, SutProvider sutProvider) { - SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); - SetupStagedMembership(user, stagedOrganizationUser); - Exception businessFailure = exceptionType == typeof(BadRequestException) - ? new BadRequestException("seat failure") - : new GatewayException("seat failure"); - sutProvider.GetDependency() - .RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Throws(businessFailure); + SetupHappyPath(organization, inviteLink, user, existingOrganizationUser, sutProvider); var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); - Assert.True(result.IsError); - Assert.IsType(result.AsError); - AssertNoMembershipWritten(sutProvider); + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs index bed068421e2c..a14cfe5443f2 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs @@ -3,9 +3,11 @@ using Bit.Core.Billing.Models.Mail.Mailer; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; +using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -22,84 +24,105 @@ public class ReservePrivilegedControlsSeatForJoiningMemberCommandTests [Theory] [BitAutoData] - public async Task RunAsync_MemberWithoutAccess_ReturnsTrueWithoutReserving( + public async Task RunAsync_MemberWithoutAccess_KeepsWithoutReserving( Organization organization, OrganizationUser organizationUser, SutProvider sutProvider) { organization.UsePam = true; organizationUser.AccessPam = false; - var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - Assert.True(result); + Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs().RunAsync(default!, default); } [Theory] [BitAutoData] - public async Task RunAsync_PamOff_KeepsAccessWithoutEmail( + public async Task RunAsync_PamOff_KeepsAccessWithoutReserving( Organization organization, OrganizationUser organizationUser, SutProvider sutProvider) { organization.UsePam = false; organizationUser.AccessPam = true; - var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - Assert.True(result); + Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); Assert.True(organizationUser.AccessPam); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs().RunAsync(default!, default); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().SendEmail(default!); } [Theory] [BitAutoData] - public async Task RunAsync_Reserved_KeepsAccessAndSendsNoEmail( + public async Task RunAsync_Reserved_KeepsAccess( Organization organization, OrganizationUser organizationUser, SutProvider sutProvider) { - organization.UsePam = true; - organizationUser.AccessPam = true; - sutProvider.GetDependency() - .RunAsync(organization, 1).Returns(PrivilegedControlsSeatReservation.Reserved); + SetUpReservation(PrivilegedControlsSeatReservation.Reserved, organization, organizationUser, sutProvider); - var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - Assert.True(result); + Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); Assert.True(organizationUser.AccessPam); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().SendEmail(default!); } [Theory] [BitAutoData(PrivilegedControlsSeatReservation.LimitReached)] [BitAutoData(PrivilegedControlsSeatReservation.SelfHostedShortfall)] - public async Task RunAsync_NoSeatAvailable_RemovesAccessAndReturnsFalse( + public async Task RunAsync_NoSeatAvailable_RemovesAccessAtSeatLimit( PrivilegedControlsSeatReservation reservation, Organization organization, OrganizationUser organizationUser, SutProvider sutProvider) { - SetUpNoSeat(reservation, organization, organizationUser, sutProvider, ["owner@example.com"]); + SetUpReservation(reservation, organization, organizationUser, sutProvider); - var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - Assert.False(result); + Assert.Equal(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit, result); Assert.False(organizationUser.AccessPam); } [Theory] - [BitAutoData(PrivilegedControlsSeatReservation.LimitReached)] - [BitAutoData(PrivilegedControlsSeatReservation.SelfHostedShortfall)] - public async Task RunAsync_NoSeatAvailable_EmailsOwners( - PrivilegedControlsSeatReservation reservation, Organization organization, OrganizationUser organizationUser, + [BitAutoData] + public async Task RunAsync_ReservationThrows_RemovesAccessAfterError( + Organization organization, OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + sutProvider.GetDependency() + .RunAsync(organization, 1).ThrowsAsync(new GatewayException("Stripe is down.")); + + var result = await sutProvider.Sut.RunAsync(organization, organizationUser); + + Assert.Equal(JoiningMemberPrivilegedControlsAccess.RemovedAfterError, result); + Assert.False(organizationUser.AccessPam); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_NoSeatAvailable_SendsNoEmail( + Organization organization, OrganizationUser organizationUser, SutProvider sutProvider) { - SetUpNoSeat(reservation, organization, organizationUser, sutProvider, - ["owner1@example.com", "owner2@example.com", "owner1@example.com"]); + SetUpReservation(PrivilegedControlsSeatReservation.LimitReached, organization, organizationUser, sutProvider); + + await sutProvider.Sut.RunAsync(organization, organizationUser); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().SendEmail(default!); + } + + [Theory] + [BitAutoData] + public async Task NotifyOwnersAsync_Cloud_EmailsEachOwnerOnce( + Organization organization, SutProvider sutProvider) + { + SetUpOwners(organization, sutProvider, ["owner1@example.com", "owner2@example.com", "owner1@example.com"]); - await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + await sutProvider.Sut.NotifyOwnersAsync(organization, _memberEmail); await sutProvider.GetDependency().Received(1).SendEmail(Arg.Is(mail => mail.ToEmails.SequenceEqual(new[] { "owner1@example.com", "owner2@example.com" }) @@ -109,47 +132,44 @@ await sutProvider.GetDependency().Received(1).SendEmail(Arg.Is sutProvider) + public async Task NotifyOwnersAsync_SelfHosted_SendsNoEmail( + Organization organization, SutProvider sutProvider) { - SetUpNoSeat(PrivilegedControlsSeatReservation.LimitReached, organization, organizationUser, sutProvider, - ["owner@example.com"]); + SetUpOwners(organization, sutProvider, ["owner@example.com"]); + sutProvider.GetDependency().SelfHosted.Returns(true); - var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + await sutProvider.Sut.NotifyOwnersAsync(organization, _memberEmail); - Assert.False(result); - Assert.False(organizationUser.AccessPam); - await sutProvider.GetDependency().Received(1).SendEmail(Arg.Is(mail => - mail.ToEmails.SequenceEqual(new[] { "owner@example.com" }))); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().SendEmail(default!); } [Theory] [BitAutoData] - public async Task RunAsync_EmailFails_StillReturnsFalseWithoutThrowing( - Organization organization, OrganizationUser organizationUser, - SutProvider sutProvider) + public async Task NotifyOwnersAsync_EmailFails_DoesNotThrow( + Organization organization, SutProvider sutProvider) { - SetUpNoSeat(PrivilegedControlsSeatReservation.LimitReached, organization, organizationUser, sutProvider, - ["owner@example.com"]); - sutProvider.GetDependency().SendEmail(default!).ThrowsAsyncForAnyArgs(new Exception("smtp down")); + SetUpOwners(organization, sutProvider, ["owner@example.com"]); + sutProvider.GetDependency().SendEmail(default!) + .ThrowsAsyncForAnyArgs(new Exception("smtp down")); - var result = await sutProvider.Sut.RunAsync(organization, organizationUser, _memberEmail); + var exception = await Record.ExceptionAsync(() => sutProvider.Sut.NotifyOwnersAsync(organization, _memberEmail)); - Assert.False(result); - Assert.False(organizationUser.AccessPam); + Assert.Null(exception); } - private static void SetUpNoSeat(PrivilegedControlsSeatReservation reservation, Organization organization, - OrganizationUser organizationUser, - SutProvider sutProvider, string[] ownerEmails) + private static void SetUpReservation(PrivilegedControlsSeatReservation reservation, Organization organization, + OrganizationUser organizationUser, SutProvider sutProvider) { organization.UsePam = true; organizationUser.AccessPam = true; sutProvider.GetDependency() .RunAsync(organization, 1).Returns(reservation); + } + + private static void SetUpOwners(Organization organization, + SutProvider sutProvider, string[] ownerEmails) => sutProvider.GetDependency() .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) .Returns(ownerEmails.Select(email => new OrganizationUserUserDetails { Email = email }).ToList()); - } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 58761de23001..f979001192cf 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -1331,13 +1331,15 @@ public async Task RestoreUser_Scim_AtLimit_RestoresWithoutPamAndSaves( organization.UsePam = true; organizationUser.AccessPam = true; organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + organizationUser.Email = "member@example.com"; RestoreUser_Setup(organization, null, organizationUser, sutProvider); - sutProvider.GetDependency() - .RunAsync(organization, organizationUser, Arg.Any()) + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, organizationUser) .Returns(callInfo => { callInfo.Arg().AccessPam = false; - return false; + return JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit; }); await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); @@ -1347,9 +1349,62 @@ public async Task RestoreUser_Scim_AtLimit_RestoresWithoutPamAndSaves( { organizationUserRepository.ReplaceAsync(Arg.Is(ou => ou.Id == organizationUser.Id && !ou.AccessPam)); organizationUserRepository.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + joiningMemberCommand.NotifyOwnersAsync(organization, "member@example.com"); }); } + [Theory, BitAutoData] + public async Task RestoreUser_Scim_AtLimit_LinkedMember_EmailsOwnersWithAccountEmail( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + User user, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + organizationUser.Email = null; + organizationUser.UserId = user.Id; + RestoreUser_Setup(organization, null, organizationUser, sutProvider); + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, organizationUser) + .Returns(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); + + await joiningMemberCommand.Received(1).NotifyOwnersAsync(organization, user.Email); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Scim_ReservationFailed_RestoresWithoutPamOrEmail( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, null, organizationUser, sutProvider); + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, organizationUser) + .Returns(callInfo => + { + callInfo.Arg().AccessPam = false; + return JoiningMemberPrivilegedControlsAccess.RemovedAfterError; + }); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); + + var organizationUserRepository = sutProvider.GetDependency(); + await organizationUserRepository.Received(1) + .ReplaceAsync(Arg.Is(ou => ou.Id == organizationUser.Id && !ou.AccessPam)); + await organizationUserRepository.Received(1).RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + } + [Theory, BitAutoData] public async Task RestoreUser_Scim_SeatAvailable_RestoresWithPam( Organization organization, @@ -1360,15 +1415,17 @@ public async Task RestoreUser_Scim_SeatAvailable_RestoresWithPam( organizationUser.AccessPam = true; organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; RestoreUser_Setup(organization, null, organizationUser, sutProvider); - sutProvider.GetDependency() - .RunAsync(organization, organizationUser, Arg.Any()) - .Returns(true); + var joiningMemberCommand = sutProvider.GetDependency(); + joiningMemberCommand + .RunAsync(organization, organizationUser) + .Returns(JoiningMemberPrivilegedControlsAccess.Kept); await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); var organizationUserRepository = sutProvider.GetDependency(); await organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); await organizationUserRepository.Received(1).RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); Assert.True(organizationUser.AccessPam); } @@ -1390,7 +1447,7 @@ await sutProvider.GetDependency() .RunAsync(default!, default); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .RunAsync(default!, default!, default!); + .RunAsync(default!, default!); Assert.True(organizationUser.AccessPam); } From 29486ccd9a1e0b11a3d3ff216ca0d6e09322128c Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:56:48 -0500 Subject: [PATCH 19/44] [PM-45107] fix: Reserve Password Manager seats before Privileged Controls seats when inviting staged members When one action adds both a Password Manager seat and a Privileged Controls seat, the Password Manager seat comes first. A Privileged Controls failure gives back the Password Manager and Secrets Manager seats just added, and a billing failure is logged and returned as an error like the other seat steps. --- .../OrganizationUsers/StagedUsers/Errors.cs | 3 + .../InviteStagedOrganizationUsersCommand.cs | 63 +++++----- ...viteStagedOrganizationUsersCommandTests.cs | 114 ++++++++++++------ 3 files changed, 113 insertions(+), 67 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/Errors.cs index 532f7a72b017..8287432f93f3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/Errors.cs @@ -16,3 +16,6 @@ public record SeatExpansionFailed(string Reason) public record SecretsManagerSeatExpansionFailed(string Reason) : BadRequestError(Reason); + +public record PrivilegedControlsSeatExpansionFailed(string Reason) + : BadRequestError(Reason); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index fef5c4da971f..5dd7582db5a4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -67,41 +67,30 @@ public async Task>> RunAsync(Invite return secretsManagerValidationError; } - // Captured before the reservation, because a successful autoscale writes the new total onto the entity. - var initialPamSeats = organization.PamSeats; - - var pamSeatReservationError = await ReservePrivilegedControlsSeatsAsync(organization, eligible); - if (pamSeatReservationError is not null) + var seatReservationError = await ReserveSeatsAsync(organization, seatsToAdd); + if (seatReservationError is not null) { - return pamSeatReservationError; + return seatReservationError; } // Captured before the subscription grows, because a successful update writes the new total onto the entity. var initialSecretsManagerSeats = organization.SmSeats; + var initialPamSeats = organization.PamSeats; - try + // Password Manager seats first: the subscription rejects more Secrets Manager seats than it has PM seats. + var secretsManagerSeatReservationError = await AdjustSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, dryRun: false); + if (secretsManagerSeatReservationError is not null) { - var seatReservationError = await ReserveSeatsAsync(organization, seatsToAdd); - if (seatReservationError is not null) - { - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); - return seatReservationError; - } - - // Password Manager seats first: the subscription rejects more Secrets Manager seats than it has PM seats. - var secretsManagerSeatReservationError = await AdjustSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, dryRun: false); - if (secretsManagerSeatReservationError is not null) - { - await ReleaseSeatsAsync(organization, seatsToAdd); - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); - return secretsManagerSeatReservationError; - } + await ReleaseSeatsAsync(organization, seatsToAdd); + return secretsManagerSeatReservationError; } - catch + + var pamSeatReservationError = await ReservePrivilegedControlsSeatsAsync(organization, eligible); + if (pamSeatReservationError is not null) { - // Only unexpected exceptions land here: the known failures above return an error after releasing. - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); - throw; + await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); + await ReleaseSeatsAsync(organization, seatsToAdd); + return pamSeatReservationError; } try @@ -428,13 +417,23 @@ private async Task ReleaseSeatsAsync(Organization organization, int seatsToRelea ? organizationUsers.Count(organizationUser => organizationUser.AccessPam) : 0; - var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersWithPrivilegedControls); - return reservation switch + try { - PrivilegedControlsSeatReservation.LimitReached => new PrivilegedControlsSeatLimitReached(), - PrivilegedControlsSeatReservation.SelfHostedShortfall => new CannotAutoscalePamSeatsOnSelfHost(), - _ => null - }; + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersWithPrivilegedControls); + return reservation switch + { + PrivilegedControlsSeatReservation.LimitReached => new PrivilegedControlsSeatLimitReached(), + PrivilegedControlsSeatReservation.SelfHostedShortfall => new CannotAutoscalePamSeatsOnSelfHost(), + _ => null + }; + } + catch (Exception ex) when (ex is BadRequestException or GatewayException) + { + logger.LogWarning(ex, + "Could not add {SeatsToAdd} Privileged Controls seat(s) while inviting staged members for organization {OrganizationId}", + membersWithPrivilegedControls, organization.Id); + return new PrivilegedControlsSeatExpansionFailed(ex.Message); + } } /// diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index 5e362e9ee669..a70a9d2aba5a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -763,7 +763,27 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamAtLimit_ReturnsSeatLimitReachedBeforeReservingPasswordManagerSeats( + public async Task RunAsync_ReservesPasswordManagerSeatsBeforePamSeats( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + organization.MaxAutoscaleSeats = 100; + var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 9); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); + var organizationService = sutProvider.GetDependency(); + var reservePrivilegedControlsSeatsCommand = sutProvider.GetDependency(); + + await sutProvider.Sut.RunAsync(request); + + Received.InOrder(() => + { + organizationService.AutoAddSeatsAsync(organization, organizationUsers.Count - 1); + reservePrivilegedControlsSeatsCommand.RunAsync(organization, 2); + }); + } + + [Theory, BitAutoData] + public async Task RunAsync_PamAtLimit_ReturnsSeatLimitReachedAndReleasesPasswordManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -778,9 +798,55 @@ public async Task RunAsync_PamAtLimit_ReturnsSeatLimitReachedBeforeReservingPass Assert.IsType(result.AsError); await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .AutoAddSeatsAsync(default!, default); - await AssertNothingHappenedAsync(sutProvider); + .Received(1) + .AdjustSeatsAsync(organization.Id, -(organizationUsers.Count - 1)); + Assert.All(organizationUsers, organizationUser => + Assert.Equal(OrganizationUserStatusType.Staged, organizationUser.Status)); + } + + [Theory, BitAutoData] + public async Task RunAsync_PamAtLimit_ReleasesSecretsManagerSeats( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = ArrangeWithSecretsManager(sutProvider, organization, organizationUsers, performedBy); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .Received(1) + .UpdateSubscriptionAsync(Arg.Is(update => update.SmSeats == 5)); + } + + [Theory] + [BitAutoData(typeof(BadRequestException))] + [BitAutoData(typeof(GatewayException))] + public async Task RunAsync_PamReservationFails_ReturnsSeatExpansionFailedAndReleasesPasswordManagerSeats( + Type exceptionType, Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + organization.MaxAutoscaleSeats = 100; + var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 9); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); + Exception failure = exceptionType == typeof(BadRequestException) + ? new BadRequestException("Stripe rejected the subscription change.") + : new GatewayException("Stripe rejected the subscription change."); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .ThrowsAsync(failure); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + Assert.Equal("Stripe rejected the subscription change.", result.AsError.Message); + await sutProvider.GetDependency() + .Received(1) + .AdjustSeatsAsync(organization.Id, -(organizationUsers.Count - 1)); } [Theory, BitAutoData] @@ -801,35 +867,33 @@ public async Task RunAsync_PamSelfHostedShortfall_ReturnsCannotAutoscaleOnSelfHo } [Theory, BitAutoData] - public async Task RunAsync_PasswordManagerReservationFails_RevertsPamSeats( + public async Task RunAsync_PasswordManagerReservationFails_DoesNotReservePamSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); organization.MaxAutoscaleSeats = 100; var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 10); ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); - AutoscalePamSeatsTo(sutProvider, organization, 7); sutProvider.GetDependency() .AutoAddSeatsAsync(organization, Arg.Any()) .ThrowsAsync(new BadRequestException("Seat limit has been reached.")); var result = await sutProvider.Sut.RunAsync(request); - Assert.True(result.IsError); - await sutProvider.GetDependency() - .Received(1) - .Run(organization, 5); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); } [Theory, BitAutoData] - public async Task RunAsync_SecretsManagerAdjustFails_RevertsPamSeats( + public async Task RunAsync_SecretsManagerAdjustFails_DoesNotReservePamSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); var request = ArrangeWithSecretsManager(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 9); ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); - AutoscalePamSeatsTo(sutProvider, organization, 7); sutProvider.GetDependency() .UpdateSubscriptionAsync(Arg.Any()) .ThrowsAsync(new GatewayException("Stripe rejected the subscription change.")); @@ -837,29 +901,9 @@ public async Task RunAsync_SecretsManagerAdjustFails_RevertsPamSeats( var result = await sutProvider.Sut.RunAsync(request); Assert.IsType(result.AsError); - await sutProvider.GetDependency() - .Received(1) - .Run(organization, 5); - } - - [Theory, BitAutoData] - public async Task RunAsync_PasswordManagerReservationThrowsUnexpectedly_RevertsPamSeatsAndRethrows( - Organization organization, List organizationUsers, Guid performedBy) - { - var sutProvider = GetSutProvider(); - organization.MaxAutoscaleSeats = 100; - var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 10); - ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); - AutoscalePamSeatsTo(sutProvider, organization, 7); - sutProvider.GetDependency() - .AutoAddSeatsAsync(organization, Arg.Any()) - .ThrowsAsync(new InvalidOperationException("Unexpected failure.")); - - await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); - - await sutProvider.GetDependency() - .Received(1) - .Run(organization, 5); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); } [Theory, BitAutoData] From 795d74e4837f7d5c0aa53e779ec4b9338e68b6b9 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:18:06 -0500 Subject: [PATCH 20/44] [PM-45107] test: Cover the Privileged Controls seat limit end to end for edit member and invite links An admin granting access at the limit gets the limit error, and a staged member joining by invite link at the limit joins without Privileged Controls. --- .../OrganizationUserControllerPutTests.cs | 27 ++++++++++++++ ...ionUsersControllerAcceptInviteLinkTests.cs | 35 ++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs index 7d7072f8a7e6..c748a783b384 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs @@ -6,6 +6,7 @@ using Bit.Api.Models.Request; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2.Validation; @@ -417,6 +418,32 @@ public async Task Put_AdminGrantingPamToSelf_Succeeds() await AssertAccessPamAsync(admin, true); } + [Fact] + public async Task Put_GrantingPamAtSeatLimit_ReturnsBadRequestAndDoesNotGrant() + { + await SetUsePamAsync(true); + _organization.PamSeats = 1; + _organization.MaxAutoscalePamSeats = 1; + _organization.GatewaySubscriptionId = "sub_integration_test"; + await _factory.GetService().ReplaceAsync(_organization); + await _loginHelper.LoginAsync(_ownerEmail); + + var (_, seatHolder) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + await GrantPamAsync(seatHolder); + var (_, member) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + + var response = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/{member.Id}", + UpdateRequest(accessPam: true)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains(new PrivilegedControlsSeatLimitReached().Message, await response.Content.ReadAsStringAsync()); + await AssertAccessPamAsync(member, false); + var reloadedOrganization = await _factory.GetService().GetByIdAsync(_organization.Id); + Assert.Equal(1, reloadedOrganization!.PamSeats); + } + [Fact] public async Task Put_WhenChangingRoleAndNameForClaimedMember_ReturnsNoContentAndPersistsBoth() { diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs index 7b1bc212f14f..4f1c42d8d4f4 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs @@ -438,6 +438,23 @@ public async Task AcceptInviteLink_WithStagedMembership_WithValidRequest_Returns await AssertAcceptedMemberAsync(joinerEmail); } + // Staged path: a staged member given Privileged Controls joins without it when the organization is at its + // Privileged Controls seat limit. + [Fact] + public async Task AcceptInviteLink_WithStagedMembershipWithPam_AtPamSeatLimit_JoinsWithoutPam() + { + await FillPamSeatsAtLimitAsync(); + var created = await CreateInviteLinkAsync(); + var (joinerEmail, joinerClient) = await RegisterAndLoginJoinerAsync(); + await CreateStagedMembershipAsync(joinerEmail, accessPam: true); + + var response = await AcceptAsync(joinerClient, created); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var organizationUser = await AssertAcceptedMemberAsync(joinerEmail); + Assert.False(organizationUser.AccessPam); + } + // Staged path: a joiner with two-step login may accept the invite link into a 2FA org. [Fact] public async Task AcceptInviteLink_WithStagedMembership_WhenOrganizationRequiresTwoFactor_AndJoinerHasTwoFactor_Succeeds() @@ -626,7 +643,7 @@ await organizationUserRepository.CreateAsync(new OrganizationUser // Creates a Staged membership (Email set, no linked UserId), mirroring SCIM/Directory-Connector // provisioning, so the accept resolves it as an existing membership. - private async Task CreateStagedMembershipAsync(string joinerEmail) + private async Task CreateStagedMembershipAsync(string joinerEmail, bool accessPam = false) { var organizationUserRepository = _factory.GetService(); await organizationUserRepository.CreateAsync(new OrganizationUser @@ -636,9 +653,25 @@ await organizationUserRepository.CreateAsync(new OrganizationUser Email = joinerEmail, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Staged, + AccessPam = accessPam, }); } + // Gives the organization one Privileged Controls seat, already taken, with no room to autoscale. + private async Task FillPamSeatsAtLimitAsync() + { + _organization.UsePam = true; + _organization.PamSeats = 1; + _organization.MaxAutoscalePamSeats = 1; + _organization.GatewaySubscriptionId = "sub_integration_test"; + await _factory.GetService().ReplaceAsync(_organization); + + var (_, seatHolder) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + seatHolder.AccessPam = true; + await _factory.GetService().ReplaceAsync(seatHolder); + } + private async Task SetJoinerTwoFactorEnabledAsync(string joinerEmail) { var userRepository = _factory.GetService(); From 0ccd5474b0faafb24288e7648bcb7d6356154a70 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:57:23 -0500 Subject: [PATCH 21/44] [PM-45107] fix: Give back Password Manager seats when a restore is blocked on Privileged Controls seats An admin restore buys any Password Manager seats it needs before reserving Privileged Controls seats. When the Privileged Controls reservation hits the limit, finds a self-hosted shortfall, or throws, the restore is abandoned, so the Password Manager seats it just bought are handed back instead of being billed for members who were never restored. A failure to hand them back is logged rather than hiding the original error. --- .../v1/RestoreOrganizationUserCommand.cs | 64 +++++- .../RestoreOrganizationUserCommandTests.cs | 191 ++++++++++++++++++ 2 files changed, 245 insertions(+), 10 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 5af758b026b5..b520203090e7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -18,6 +18,7 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; +using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; @@ -36,7 +37,8 @@ public class RestoreOrganizationUserCommand( IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, TimeProvider timeProvider, IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, - IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand) : IRestoreOrganizationUserCommand + IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand, + ILogger logger) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName) { @@ -89,10 +91,12 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId); var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; + var passwordManagerSeatsAdded = 0; if (availableSeats < 1) { await organizationService.AutoAddSeatsAsync(organization, 1); // Hooray + passwordManagerSeatsAdded = organization.Seats.HasValue ? 1 : 0; } var userTwoFactorIsEnabled = false; @@ -119,7 +123,7 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, { if (systemUser is null) { - await ReservePrivilegedControlsSeatsAsync(organization, 1); + await ReservePrivilegedControlsSeatsAsync(organization, 1, passwordManagerSeatsAdded); } else if (systemUser == EventSystemUser.SCIM) { @@ -160,15 +164,54 @@ await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.Organi } } - private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int membersTakingSeats) + /// + /// Reserves Privileged Controls seats for members being restored with access. When the seats cannot be + /// reserved, the Password Manager seats bought for the same restore are handed back before the error is raised. + /// + private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int membersTakingSeats, + int passwordManagerSeatsAdded) { - var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); - switch (reservation) + PrivilegedControlsSeatReservation reservation; + try { - case PrivilegedControlsSeatReservation.LimitReached: - throw new BadRequestException(new PrivilegedControlsSeatLimitReached().Message); - case PrivilegedControlsSeatReservation.SelfHostedShortfall: - throw new BadRequestException(new CannotAutoscalePamSeatsOnSelfHost().Message); + reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); + } + catch + { + await ReleasePasswordManagerSeatsAsync(organization, passwordManagerSeatsAdded); + throw; + } + + var errorMessage = reservation switch + { + PrivilegedControlsSeatReservation.LimitReached => new PrivilegedControlsSeatLimitReached().Message, + PrivilegedControlsSeatReservation.SelfHostedShortfall => new CannotAutoscalePamSeatsOnSelfHost().Message, + _ => null + }; + + if (errorMessage is not null) + { + await ReleasePasswordManagerSeatsAsync(organization, passwordManagerSeatsAdded); + throw new BadRequestException(errorMessage); + } + } + + private async Task ReleasePasswordManagerSeatsAsync(Organization organization, int seatsToRelease) + { + if (seatsToRelease <= 0) + { + return; + } + + try + { + await organizationService.AdjustSeatsAsync(organization.Id, -seatsToRelease); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to release {SeatsToRelease} auto-added seat(s) for organization {OrganizationId}", + seatsToRelease, organization.Id); } } @@ -238,6 +281,7 @@ public async Task>> RestoreUsersAsync(Guid var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; var newSeatsRequired = organizationUserIds.Count() - availableSeats; await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); + var passwordManagerSeatsAdded = organization.Seats.HasValue ? Math.Max(newSeatsRequired, 0) : 0; if (organization.UsePam) { @@ -245,7 +289,7 @@ public async Task>> RestoreUsersAsync(Guid u.Status == OrganizationUserStatusType.Revoked && u.AccessPam && PrivilegedControlsSeatOccupancy.OccupiesSeat(u.GetPriorActiveOrganizationUserStatusType())); - await ReservePrivilegedControlsSeatsAsync(organization, membersTakingPamSeats); + await ReservePrivilegedControlsSeatsAsync(organization, membersTakingPamSeats, passwordManagerSeatsAdded); } var restoringUserIsOwner = false; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index f979001192cf..08f29c007b24 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -26,6 +26,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; @@ -1303,6 +1304,105 @@ await sutProvider.GetDependency() .RestoreAsync(Arg.Any(), Arg.Any()); } + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivesBackThePasswordManagerSeatItBought( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organization.Seats = 1; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + var organizationService = sutProvider.GetDependency(); + Received.InOrder(() => + { + organizationService.AutoAddSeatsAsync(organization, 1); + organizationService.AdjustSeatsAsync(organization.Id, -1); + }); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPam_ReservationThrows_GivesBackThePasswordManagerSeatAndRethrows( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organization.Seats = 1; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .ThrowsAsync(new GatewayException("Stripe rejected the update.")); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Equal("Stripe rejected the update.", exception.Message); + await sutProvider.GetDependency().Received(1).AdjustSeatsAsync(organization.Id, -1); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivingBackTheSeatFails_StillThrowsSeatLimitMessage( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organization.Seats = 1; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + sutProvider.GetDependency() + .AdjustSeatsAsync(organization.Id, -1) + .ThrowsAsync(new GatewayException("Stripe is unavailable.")); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Equal(new PrivilegedControlsSeatLimitReached().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPam_AtLimitWithFreePasswordManagerSeat_GivesNothingBack( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organization.Seats = 5; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AdjustSeatsAsync(default, default); + } + [Theory, BitAutoData] public async Task RestoreUser_Admin_PriorStatusStaged_DoesNotReserve( Organization organization, @@ -1516,6 +1616,97 @@ await sutProvider.GetDependency() .RestoreAsync(Arg.Any(), Arg.Any()); } + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_AtLimit_GivesBackThePasswordManagerSeatsItBought( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + SutProvider sutProvider) + { + organization.UsePam = true; + organization.Seats = 1; + var userService = Substitute.For(); + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + OrganizationUser[] orgUsers = [orgUser1, orgUser2]; + foreach (var orgUser in orgUsers) + { + orgUser.OrganizationId = organization.Id; + orgUser.AccessPam = true; + orgUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + } + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(orgUsers); + sutProvider.GetDependency() + .RunAsync(organization, 2) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id], owner.Id, userService, null)); + + var organizationService = sutProvider.GetDependency(); + Received.InOrder(() => + { + organizationService.AutoAddSeatsAsync(organization, 2); + organizationService.AdjustSeatsAsync(organization.Id, -2); + }); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_ReservationThrows_GivesBackThePasswordManagerSeatsAndRethrows( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + SutProvider sutProvider) + { + organization.UsePam = true; + organization.Seats = 1; + var userService = Substitute.For(); + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + orgUser1.AccessPam = true; + orgUser1.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns([orgUser1]); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .ThrowsAsync(new GatewayException("Stripe rejected the update.")); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null)); + + await sutProvider.GetDependency().Received(1).AdjustSeatsAsync(organization.Id, -1); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_AtLimitWithFreePasswordManagerSeats_GivesNothingBack( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + SutProvider sutProvider) + { + organization.UsePam = true; + organization.Seats = 5; + var userService = Substitute.For(); + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + orgUser1.AccessPam = true; + orgUser1.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns([orgUser1]); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AdjustSeatsAsync(default, default); + } + private static void RestoreUser_Setup( Organization organization, OrganizationUser? requestingOrganizationUser, From 03ab2d8310e891c399c2777afe6415e51f98b189 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:57:28 -0500 Subject: [PATCH 22/44] [PM-45107] fix: Reserve Privileged Controls seats when an invite promotes a staged member with access Edit member and bulk activate can give a staged member access without taking a seat, on the understanding that the seat is taken once they become Invited. An email invite whose address matches that staged member promoted them to Invited without reserving anything, so an admin could put members past the autoscale limit unbilled. The Admin Console invite endpoint and Directory Connector import both take this path. The invite now reserves the seats after the Password Manager and Secrets Manager seats are bought and before any invite is sent, fails at the limit like the other admin actions, and gives the Privileged Controls seats back in its existing failure handling. OrganizationService now depends on the reserve and revert commands, and the autoscale and revert commands used to save through IOrganizationService, which would have been a dependency loop. Saving moves into IOrganizationReplaceCommand, which OrganizationService.ReplaceAndUpdateCacheAsync forwards to, and the two seat commands depend on that instead. The reserve commands move into Subscriptions.Organization next to the autoscale and revert commands, registered by AddPrivilegedControlsSeatAutoscaling, with their contracts left in Core for Admin Console to call. They check for self-hosting through IBitwardenEnvironment, like the autoscale command. --- .../Interfaces/IOrganizationReplaceCommand.cs | 14 ++ .../OrganizationReplaceCommand.cs | 35 +++ .../Implementations/OrganizationService.cs | 80 +++++-- ...OrganizationServiceCollectionExtensions.cs | 4 +- ...AutoscalePrivilegedControlsSeatsCommand.cs | 8 +- ...egedControlsSeatForJoiningMemberCommand.cs | 15 +- .../ReservePrivilegedControlsSeatsCommand.cs | 15 +- .../RevertPrivilegedControlsSeatsCommand.cs | 8 +- .../Subscriptions.Organization/README.md | 21 +- ...OrganizationServiceCollectionExtensions.cs | 11 +- .../PrivilegedControlsSeatOccupancyTests.cs | 19 ++ ...ontrolsSeatForJoiningMemberCommandTests.cs | 175 -------------- ...ervePrivilegedControlsSeatsCommandTests.cs | 157 ------------- .../OrganizationReplaceCommandTests.cs | 76 ++++++ .../Services/OrganizationServiceTests.cs | 221 ++++++++++++++++-- ...calePrivilegedControlsSeatsCommandTests.cs | 24 +- ...ontrolsSeatForJoiningMemberCommandTests.cs | 179 ++++++++++++++ ...ervePrivilegedControlsSeatsCommandTests.cs | 121 ++++++++++ ...vertPrivilegedControlsSeatsCommandTests.cs | 14 +- ...izationServiceCollectionExtensionsTests.cs | 14 +- 20 files changed, 781 insertions(+), 430 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationReplaceCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommand.cs rename src/{Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats => Libraries/Subscriptions.Organization/Commands}/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs (82%) rename src/{Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats => Libraries/Subscriptions.Organization/Commands}/ReservePrivilegedControlsSeatsCommand.cs (69%) create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/PrivilegedControlsSeatOccupancyTests.cs delete mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs delete mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommandTests.cs create mode 100644 test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs create mode 100644 test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationReplaceCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationReplaceCommand.cs new file mode 100644 index 000000000000..19e849a65acc --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationReplaceCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IOrganizationReplaceCommand +{ + /// + /// Saves the organization and refreshes its cached abilities. + /// + /// The organization to save. + /// When provided, an organization event of this type is logged after the save. + Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommand.cs new file mode 100644 index 000000000000..2de6a6ad968a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommand.cs @@ -0,0 +1,35 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class OrganizationReplaceCommand( + IOrganizationRepository organizationRepository, + IOrganizationAbilityCacheService organizationAbilityCacheService, + IEventService eventService, + ILogger logger) : IOrganizationReplaceCommand +{ + public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) + { + try + { + await organizationRepository.ReplaceAsync(org); + await organizationAbilityCacheService.UpsertOrganizationAbilityAsync(org); + + if (orgEvent.HasValue) + { + await eventService.LogOrganizationEventAsync(org, orgEvent.Value); + } + } + catch (Exception exception) + { + logger.LogError(exception, "An error occurred while calling {Method} for Organization ({OrganizationID})", nameof(ReplaceAndUpdateCacheAsync), org.Id); + throw; + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index ccfc0f37326d..53bebbb603f2 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -4,11 +4,13 @@ using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -59,6 +61,9 @@ public class OrganizationService : IOrganizationService private readonly IStripeAdapter _stripeAdapter; private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand; private readonly TimeProvider _timeProvider; + private readonly IOrganizationReplaceCommand _organizationReplaceCommand; + private readonly IReservePrivilegedControlsSeatsCommand _reservePrivilegedControlsSeatsCommand; + private readonly IRevertPrivilegedControlsSeatsCommand _revertPrivilegedControlsSeatsCommand; public OrganizationService( IOrganizationRepository organizationRepository, @@ -82,7 +87,10 @@ public OrganizationService( ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, IStripeAdapter stripeAdapter, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - TimeProvider timeProvider) + TimeProvider timeProvider, + IOrganizationReplaceCommand organizationReplaceCommand, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + IRevertPrivilegedControlsSeatsCommand revertPrivilegedControlsSeatsCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -106,6 +114,9 @@ public OrganizationService( _stripeAdapter = stripeAdapter; _updateOrganizationSubscriptionCommand = updateOrganizationSubscriptionCommand; _timeProvider = timeProvider; + _organizationReplaceCommand = organizationReplaceCommand; + _reservePrivilegedControlsSeatsCommand = reservePrivilegedControlsSeatsCommand; + _revertPrivilegedControlsSeatsCommand = revertPrivilegedControlsSeatsCommand; } public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) @@ -695,6 +706,12 @@ await _organizationUserRepository.SelectKnownEmailsAsync(organizationId, request .Concat(stagedInvitations.Values.Select(s => s.OrgUser)) .ToList(); + // Staged members keep any Privileged Controls access they were given, and become Invited, which occupies a seat. + var privilegedControlsSeatsToReserve = organization.UsePam + ? stagedInvitations.Values.Count(s => s.OrgUser.AccessPam) + : 0; + var initialPamSeats = organization.PamSeats; + // Staged rows whose promotion has actually committed, and so must be put back if the batch fails. var promotedStagedUsers = new List(); @@ -724,6 +741,11 @@ await _organizationUserRepository.SelectKnownEmailsAsync(organizationId, request await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdate); } + if (privilegedControlsSeatsToReserve > 0) + { + await ReservePrivilegedControlsSeatsAsync(organization, privilegedControlsSeatsToReserve); + } + await SendInvitesAsync(allOrgUsers, organization, invitingUserId); // Staged users' changes are handled separately to avoid unnecessary conditions above @@ -754,6 +776,8 @@ await _organizationUserRepository.SelectKnownEmailsAsync(organizationId, request // Demote any staged rows that were promoted before the failure. await RestorePromotedStagedUsersAsync(promotedStagedUsers, stagedSnapshots); + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); + var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id); // Revert autoscaling @@ -828,6 +852,40 @@ private async Task RestorePromotedStagedUsersAsync( await _organizationUserRepository.ReplaceManyAsync(promotedStagedUsers); } + private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int membersTakingSeats) + { + var reservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); + switch (reservation) + { + case PrivilegedControlsSeatReservation.LimitReached: + throw new BadRequestException(new PrivilegedControlsSeatLimitReached().Message); + case PrivilegedControlsSeatReservation.SelfHostedShortfall: + throw new BadRequestException(new CannotAutoscalePamSeatsOnSelfHost().Message); + } + } + + /// + /// Restores the Privileged Controls seat count captured before the reservation when the invite fails. + /// + private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization, int? initialPamSeats) + { + if (initialPamSeats is null || initialPamSeats == organization.PamSeats) + { + return; + } + + try + { + await _revertPrivilegedControlsSeatsCommand.Run(organization, initialPamSeats.Value); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to release Privileged Controls seat(s) for organization {OrganizationId}", + organization.Id); + } + } + private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization, Guid? invitingUserId = null) => await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization, initOrganization: false, invitingUserId: invitingUserId)); @@ -977,24 +1035,8 @@ await _eventService.LogOrganizationUserEventAsync(organizationUser, } - public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) - { - try - { - await _organizationRepository.ReplaceAsync(org); - await _organizationAbilityCacheService.UpsertOrganizationAbilityAsync(org); - - if (orgEvent.HasValue) - { - await _eventService.LogOrganizationEventAsync(org, orgEvent.Value); - } - } - catch (Exception exception) - { - _logger.LogError(exception, "An error occurred while calling {Method} for Organization ({OrganizationID})", nameof(ReplaceAndUpdateCacheAsync), org.Id); - throw; - } - } + public Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) => + _organizationReplaceCommand.ReplaceAndUpdateCacheAsync(org, orgEvent); private async Task GetOrgById(Guid id) { diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 81276b4a554e..204cb2e69e90 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -32,7 +32,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationUserAction; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; @@ -113,6 +112,7 @@ private static void AddOrganizationUpdateCommands(this IServiceCollection servic services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationEnableCommands(this IServiceCollection services) => @@ -169,8 +169,6 @@ private static void AddOrganizationUserCommands(this IServiceCollection services services.AddScoped(); services.AddScoped(); services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs index d4823f06e6a9..46ab0c7c4521 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Models.Mail.Mailer; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Models.Mail.Mailer; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Billing.Organizations.Models; @@ -6,7 +7,6 @@ using Bit.Core.Exceptions; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Subscriptions.Organization.Queries; @@ -19,7 +19,7 @@ namespace Bit.Subscriptions.Organization.Commands; internal sealed class AutoscalePrivilegedControlsSeatsCommand( IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - IOrganizationService organizationService, + IOrganizationReplaceCommand organizationReplaceCommand, IOrganizationUserRepository organizationUserRepository, IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, IMailer mailer, @@ -66,7 +66,7 @@ public async Task Run(OrganizationEntity } organization.PamSeats = seatsNeeded; - await organizationService.ReplaceAndUpdateCacheAsync(organization); + await organizationReplaceCommand.ReplaceAndUpdateCacheAsync(organization); } if (organization.MaxAutoscalePamSeats == seatsNeeded) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs similarity index 82% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs index 3b5c76be3eab..d33990a0e804 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.Billing.Models.Mail.Mailer; using Bit.Core.Entities; using Bit.Core.Enums; @@ -6,19 +6,22 @@ using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; +using Bitwarden.Server.Sdk.Environment; using Microsoft.Extensions.Logging; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +namespace Bit.Subscriptions.Organization.Commands; -public class ReservePrivilegedControlsSeatForJoiningMemberCommand( +internal sealed class ReservePrivilegedControlsSeatForJoiningMemberCommand( IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, IOrganizationUserRepository organizationUserRepository, IMailer mailer, IGlobalSettings globalSettings, + IBitwardenEnvironment environment, ILogger logger) : IReservePrivilegedControlsSeatForJoiningMemberCommand { - public async Task RunAsync(Organization organization, OrganizationUser organizationUser) + public async Task RunAsync(OrganizationEntity organization, OrganizationUser organizationUser) { if (!organization.UsePam || !organizationUser.AccessPam) { @@ -47,9 +50,9 @@ public async Task RunAsync(Organization o return JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit; } - public async Task NotifyOwnersAsync(Organization organization, string memberEmail) + public async Task NotifyOwnersAsync(OrganizationEntity organization, string memberEmail) { - if (globalSettings.SelfHosted) + if (environment.SelfHosted) { return; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatsCommand.cs similarity index 69% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatsCommand.cs index 1dfa619e74e6..c6567f489899 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatsCommand.cs @@ -1,23 +1,24 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Queries; -using Bit.Core.Settings; +using Bitwarden.Server.Sdk.Environment; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +namespace Bit.Subscriptions.Organization.Commands; -public class ReservePrivilegedControlsSeatsCommand( +internal sealed class ReservePrivilegedControlsSeatsCommand( ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, IAutoscalePrivilegedControlsSeatsCommand autoscalePrivilegedControlsSeatsCommand, - IGlobalSettings globalSettings) : IReservePrivilegedControlsSeatsCommand + IBitwardenEnvironment environment) : IReservePrivilegedControlsSeatsCommand { - public async Task RunAsync(Organization organization, int membersTakingSeats) + public async Task RunAsync(OrganizationEntity organization, int membersTakingSeats) { if (membersTakingSeats < 1 || !organization.UsePam || organization.PamSeats is null or < 1) { return PrivilegedControlsSeatReservation.Reserved; } - if (globalSettings.SelfHosted) + if (environment.SelfHosted) { var seatsRequired = await countNewPamSeatsRequiredQuery .CountNewPamSeatsRequiredAsync(organization, membersTakingSeats); diff --git a/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs index 50f26c720052..2aca941101e8 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bit.Subscriptions.Organization.Queries; using Bitwarden.Server.Sdk.Environment; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; @@ -11,7 +11,7 @@ namespace Bit.Subscriptions.Organization.Commands; internal sealed class RevertPrivilegedControlsSeatsCommand( IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - IOrganizationService organizationService, + IOrganizationReplaceCommand organizationReplaceCommand, IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, IBitwardenEnvironment environment) : IRevertPrivilegedControlsSeatsCommand { @@ -34,6 +34,6 @@ public async Task Run(OrganizationEntity organization, int seats) } organization.PamSeats = seats; - await organizationService.ReplaceAndUpdateCacheAsync(organization); + await organizationReplaceCommand.ReplaceAndUpdateCacheAsync(organization); } } diff --git a/src/Libraries/Subscriptions.Organization/README.md b/src/Libraries/Subscriptions.Organization/README.md index 5505debdfa50..a2f4f17bbd12 100644 --- a/src/Libraries/Subscriptions.Organization/README.md +++ b/src/Libraries/Subscriptions.Organization/README.md @@ -26,10 +26,11 @@ It also registers the implementation of `IPrivilegedControlsSeatChangeSetFactory implementation stays internal here. `IAutoscalePrivilegedControlsSeatsCommand` and its `AutoscalePrivilegedControlsSeatsResult` follow the same split: they live in `Core` so hosts and Admin Console operations can resolve the command, and this library implements it. -`IRevertPrivilegedControlsSeatsCommand` follows the same split: its contract lives in `Core` and this library -implements it. A host that resolves either command without mapping the endpoints calls -`AddPrivilegedControlsSeatAutoscaling()`, which registers the autoscale and revert commands, the trial query, and the -seat change set factory. `AddOrganizationSubscriptions()` calls it, so the Api host needs only that one. +`IRevertPrivilegedControlsSeatsCommand`, `IReservePrivilegedControlsSeatsCommand`, and +`IReservePrivilegedControlsSeatForJoiningMemberCommand` follow the same split: their contracts live in `Core` and this +library implements them. A host that resolves any of these commands without mapping the endpoints calls +`AddPrivilegedControlsSeatAutoscaling()`, which registers the reserve, autoscale, and revert commands, the trial query, +and the seat change set factory. `AddOrganizationSubscriptions()` calls it, so the Api host needs only that one. `MapOrganizationSubscriptionEndpoints()` attaches the group's cross-cutting chain and maps its endpoints to an empty group; the host owns the route prefix and mounts it at @@ -326,9 +327,15 @@ This library depends on `Core` as a documented deviation from the rule restricti | `IUpdateOrganizationSubscriptionCommand` (`Bit.Core.Billing.Organizations.Commands`), `BillingCommandResult` (`Bit.Core.Billing.Commands`) | Applying each Privileged Controls change (first purchase, seat change, autoscale, removal) to the subscription, and surfacing a failed update's message to the client through `GetValueOrThrowHttpException` | | `IPrivilegedControlsSeatChangeSetFactory` (`Bit.Core.Billing.Organizations.Helpers`) | The factory contract Core owns so Admin Console can inject it; this library implements it | | `IAutoscalePrivilegedControlsSeatsCommand` (`Bit.Core.Billing.Organizations.Commands`), `AutoscalePrivilegedControlsSeatsResult` (`Bit.Core.Billing.Organizations.Models`) | The autoscale command contract and its result, which Core owns so hosts and Admin Console can resolve the command; this library implements it | -| `IOrganizationService` (`Bit.Core.Services`) | Saving the organization's Privileged Controls seats and flags after each Privileged Controls change (first purchase, seat change, autoscale, removal) | -| `IOrganizationUserRepository` (`Bit.Core.Repositories`) | Counting the Privileged Controls seats in use before a seat decrease and before an autoscale; finding the Owners to email when the autoscale limit is reached | -| `IMailer` (`Bit.Core.Platform.Mail.Mailer`), `PrivilegedControlsSeatLimitReachedMail` (`Bit.Core.Billing.Models.Mail.Mailer`) | Sending the Privileged Controls autoscale limit reached email to the Owners | +| `IRevertPrivilegedControlsSeatsCommand` (`Bit.Core.Billing.Organizations.Commands`) | The contract for handing back autoscaled Privileged Controls seats, which Core owns so Admin Console can undo a reservation when the rest of an action fails; this library implements it | +| `IReservePrivilegedControlsSeatsCommand`, `IReservePrivilegedControlsSeatForJoiningMemberCommand`, `PrivilegedControlsSeatReservation`, `JoiningMemberPrivilegedControlsAccess` (`Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats`) | The reserve command contracts and their results, which Core owns so Admin Console member actions can reserve seats before granting access; this library implements them | +| `ICountNewPamSeatsRequiredQuery` (`Bit.Core.Billing.Organizations.Queries`) | Checking a self-hosted license has the Privileged Controls seats a reservation needs | +| `OrganizationUser` (`Bit.Core.Entities`) | The joining member whose Privileged Controls access is kept or removed | +| `IGlobalSettings` (`Bit.Core.Settings`), `CoreHelpers` (`Bit.Core.Utilities`) | Building the Owner email links and preventing auto-linking in email content | +| `IOrganizationService` (`Bit.Core.Services`) | Saving the organization's Privileged Controls seats and flags after the first purchase, a seat change, or removal | +| `IOrganizationReplaceCommand` (`Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces`) | Saving the organization's Privileged Controls seat count after an autoscale or a revert | +| `IOrganizationUserRepository` (`Bit.Core.Repositories`) | Counting the Privileged Controls seats in use before a seat decrease and before an autoscale; finding the Owners to email when the autoscale limit is reached or a joining member needs a seat | +| `IMailer` (`Bit.Core.Platform.Mail.Mailer`), `PrivilegedControlsSeatLimitReachedMail`, `PrivilegedControlsSeatNeededMail` (`Bit.Core.Billing.Models.Mail.Mailer`) | Sending the Owners the Privileged Controls autoscale limit reached email, and the email about a joining member who needs a seat | | `ISubscriptionDiscountService`, `DiscountTierType` (`Bit.Core.Billing.Services`, `Bit.Core.Billing.Enums`) | Eligibility-checking Families purchase coupons | | `ITaxService` (`Bit.Core.Billing.Tax.Services`), `StripeConstants.TaxIdType` | Deriving the Stripe tax-id code (and the Spanish-NIF EU-VAT pairing) for `CustomerDetails.TaxIds` | | `SponsoredPlans`, `PlanSponsorshipType` (`Bit.Core.Billing.Models`, `Bit.Core.Enums`) | The Families-for-Enterprise sponsored price | diff --git a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs index 9f10160f5099..1f17e96c43ff 100644 --- a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs +++ b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Bit.Api.AdminConsole.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Invoicing; @@ -42,9 +43,11 @@ public static IServiceCollection AddOrganizationSubscriptions(this IServiceColle } /// - /// Registers the Privileged Controls seat autoscale and revert commands and the services they use. A host that - /// resolves or - /// without mapping the endpoints calls this. + /// Registers the Privileged Controls seat reserve, autoscale, and revert commands and the services they use. A + /// host that resolves , + /// , + /// , or + /// without mapping the endpoints calls this. /// public static IServiceCollection AddPrivilegedControlsSeatAutoscaling(this IServiceCollection services) { @@ -52,6 +55,8 @@ public static IServiceCollection AddPrivilegedControlsSeatAutoscaling(this IServ services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); return services; } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/PrivilegedControlsSeatOccupancyTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/PrivilegedControlsSeatOccupancyTests.cs new file mode 100644 index 000000000000..5cb834612565 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/PrivilegedControlsSeatOccupancyTests.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Enums; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; + +public class PrivilegedControlsSeatOccupancyTests +{ + [Theory] + [InlineData(OrganizationUserStatusType.Invited, true)] + [InlineData(OrganizationUserStatusType.Accepted, true)] + [InlineData(OrganizationUserStatusType.Confirmed, true)] + [InlineData(OrganizationUserStatusType.Revoked, false)] + [InlineData(OrganizationUserStatusType.Staged, false)] + public void OccupiesSeat_TrueOnlyForInvitedAcceptedConfirmed(OrganizationUserStatusType status, bool expected) + { + Assert.Equal(expected, PrivilegedControlsSeatOccupancy.OccupiesSeat(status)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs deleted file mode 100644 index a14cfe5443f2..000000000000 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; -using Bit.Core.Billing.Models.Mail.Mailer; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Exceptions; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Platform.Mail.Mailer; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; - -[SutProviderCustomize] -public class ReservePrivilegedControlsSeatForJoiningMemberCommandTests -{ - private const string _memberEmail = "member@example.com"; - - [Theory] - [BitAutoData] - public async Task RunAsync_MemberWithoutAccess_KeepsWithoutReserving( - Organization organization, OrganizationUser organizationUser, - SutProvider sutProvider) - { - organization.UsePam = true; - organizationUser.AccessPam = false; - - var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().RunAsync(default!, default); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_PamOff_KeepsAccessWithoutReserving( - Organization organization, OrganizationUser organizationUser, - SutProvider sutProvider) - { - organization.UsePam = false; - organizationUser.AccessPam = true; - - var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); - Assert.True(organizationUser.AccessPam); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().RunAsync(default!, default); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_Reserved_KeepsAccess( - Organization organization, OrganizationUser organizationUser, - SutProvider sutProvider) - { - SetUpReservation(PrivilegedControlsSeatReservation.Reserved, organization, organizationUser, sutProvider); - - var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); - Assert.True(organizationUser.AccessPam); - } - - [Theory] - [BitAutoData(PrivilegedControlsSeatReservation.LimitReached)] - [BitAutoData(PrivilegedControlsSeatReservation.SelfHostedShortfall)] - public async Task RunAsync_NoSeatAvailable_RemovesAccessAtSeatLimit( - PrivilegedControlsSeatReservation reservation, Organization organization, OrganizationUser organizationUser, - SutProvider sutProvider) - { - SetUpReservation(reservation, organization, organizationUser, sutProvider); - - var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit, result); - Assert.False(organizationUser.AccessPam); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_ReservationThrows_RemovesAccessAfterError( - Organization organization, OrganizationUser organizationUser, - SutProvider sutProvider) - { - organization.UsePam = true; - organizationUser.AccessPam = true; - sutProvider.GetDependency() - .RunAsync(organization, 1).ThrowsAsync(new GatewayException("Stripe is down.")); - - var result = await sutProvider.Sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.RemovedAfterError, result); - Assert.False(organizationUser.AccessPam); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_NoSeatAvailable_SendsNoEmail( - Organization organization, OrganizationUser organizationUser, - SutProvider sutProvider) - { - SetUpReservation(PrivilegedControlsSeatReservation.LimitReached, organization, organizationUser, sutProvider); - - await sutProvider.Sut.RunAsync(organization, organizationUser); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().SendEmail(default!); - } - - [Theory] - [BitAutoData] - public async Task NotifyOwnersAsync_Cloud_EmailsEachOwnerOnce( - Organization organization, SutProvider sutProvider) - { - SetUpOwners(organization, sutProvider, ["owner1@example.com", "owner2@example.com", "owner1@example.com"]); - - await sutProvider.Sut.NotifyOwnersAsync(organization, _memberEmail); - - await sutProvider.GetDependency().Received(1).SendEmail(Arg.Is(mail => - mail.ToEmails.SequenceEqual(new[] { "owner1@example.com", "owner2@example.com" }) - && mail.View.MemberEmail == CoreHelpers.PreventEmailAutoLinking(_memberEmail) - && mail.View.OrganizationId == organization.Id)); - } - - [Theory] - [BitAutoData] - public async Task NotifyOwnersAsync_SelfHosted_SendsNoEmail( - Organization organization, SutProvider sutProvider) - { - SetUpOwners(organization, sutProvider, ["owner@example.com"]); - sutProvider.GetDependency().SelfHosted.Returns(true); - - await sutProvider.Sut.NotifyOwnersAsync(organization, _memberEmail); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().SendEmail(default!); - } - - [Theory] - [BitAutoData] - public async Task NotifyOwnersAsync_EmailFails_DoesNotThrow( - Organization organization, SutProvider sutProvider) - { - SetUpOwners(organization, sutProvider, ["owner@example.com"]); - sutProvider.GetDependency().SendEmail(default!) - .ThrowsAsyncForAnyArgs(new Exception("smtp down")); - - var exception = await Record.ExceptionAsync(() => sutProvider.Sut.NotifyOwnersAsync(organization, _memberEmail)); - - Assert.Null(exception); - } - - private static void SetUpReservation(PrivilegedControlsSeatReservation reservation, Organization organization, - OrganizationUser organizationUser, SutProvider sutProvider) - { - organization.UsePam = true; - organizationUser.AccessPam = true; - sutProvider.GetDependency() - .RunAsync(organization, 1).Returns(reservation); - } - - private static void SetUpOwners(Organization organization, - SutProvider sutProvider, string[] ownerEmails) => - sutProvider.GetDependency() - .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) - .Returns(ownerEmails.Select(email => new OrganizationUserUserDetails { Email = email }).ToList()); -} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs deleted file mode 100644 index 2ca6a9f9be7f..000000000000 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/ReservePrivilegedControlsSeatsCommandTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; -using Bit.Core.Billing.Organizations.Commands; -using Bit.Core.Billing.Organizations.Models; -using Bit.Core.Billing.Organizations.Queries; -using Bit.Core.Enums; -using Bit.Core.Settings; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; - -[SutProviderCustomize] -public class ReservePrivilegedControlsSeatsCommandTests -{ - [Theory] - [BitAutoData(0)] - [BitAutoData(-1)] - public async Task RunAsync_NoMembers_ReturnsReservedWithoutCallingAutoscale(int membersTakingSeats, - Organization organization, SutProvider sutProvider) - { - organization.UsePam = true; - organization.PamSeats = 5; - - var result = await sutProvider.Sut.RunAsync(organization, membersTakingSeats); - - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().Run(default!, default); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_PamOff_ReturnsReservedWithoutCallingAutoscale( - Organization organization, SutProvider sutProvider) - { - organization.UsePam = false; - organization.PamSeats = 5; - - var result = await sutProvider.Sut.RunAsync(organization, 2); - - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().Run(default!, default); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_PamSeatsNull_ReturnsReservedWithoutCallingAutoscale( - Organization organization, SutProvider sutProvider) - { - organization.UsePam = true; - organization.PamSeats = null; - - var result = await sutProvider.Sut.RunAsync(organization, 2); - - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().Run(default!, default); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_PamSeatsZero_ReturnsReservedWithoutCallingAutoscale( - Organization organization, SutProvider sutProvider) - { - organization.UsePam = true; - organization.PamSeats = 0; - - var result = await sutProvider.Sut.RunAsync(organization, 2); - - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().Run(default!, default); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_SelfHosted_Shortfall_ReturnsSelfHostedShortfall( - Organization organization, SutProvider sutProvider) - { - organization.UsePam = true; - organization.PamSeats = 5; - sutProvider.GetDependency().SelfHosted.Returns(true); - sutProvider.GetDependency() - .CountNewPamSeatsRequiredAsync(organization, 2).Returns(1); - - var result = await sutProvider.Sut.RunAsync(organization, 2); - - Assert.Equal(PrivilegedControlsSeatReservation.SelfHostedShortfall, result); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs().Run(default!, default); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_SelfHosted_NoShortfall_ReturnsReserved( - Organization organization, SutProvider sutProvider) - { - organization.UsePam = true; - organization.PamSeats = 5; - sutProvider.GetDependency().SelfHosted.Returns(true); - sutProvider.GetDependency() - .CountNewPamSeatsRequiredAsync(organization, 2).Returns(0); - - var result = await sutProvider.Sut.RunAsync(organization, 2); - - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_Cloud_AutoscaleSucceeds_ReturnsReserved( - Organization organization, SutProvider sutProvider) - { - organization.UsePam = true; - organization.PamSeats = 5; - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .Run(organization, 3).Returns(new AutoscalePrivilegedControlsSeatsResult(true, null)); - - var result = await sutProvider.Sut.RunAsync(organization, 3); - - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); - await sutProvider.GetDependency() - .Received(1).Run(organization, 3); - } - - [Theory] - [BitAutoData] - public async Task RunAsync_Cloud_AutoscaleAtLimit_ReturnsLimitReached( - Organization organization, SutProvider sutProvider) - { - organization.UsePam = true; - organization.PamSeats = 5; - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .Run(organization, 3).Returns(new AutoscalePrivilegedControlsSeatsResult(false, 6)); - - var result = await sutProvider.Sut.RunAsync(organization, 3); - - Assert.Equal(PrivilegedControlsSeatReservation.LimitReached, result); - } - - [Theory] - [BitAutoData(OrganizationUserStatusType.Invited, true)] - [BitAutoData(OrganizationUserStatusType.Accepted, true)] - [BitAutoData(OrganizationUserStatusType.Confirmed, true)] - [BitAutoData(OrganizationUserStatusType.Revoked, false)] - [BitAutoData(OrganizationUserStatusType.Staged, false)] - public void OccupiesSeat_TrueOnlyForInvitedAcceptedConfirmed(OrganizationUserStatusType status, bool expected) - { - Assert.Equal(expected, PrivilegedControlsSeatOccupancy.OccupiesSeat(status)); - } -} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommandTests.cs new file mode 100644 index 000000000000..45b7d4d7059e --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommandTests.cs @@ -0,0 +1,76 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class OrganizationReplaceCommandTests +{ + [Theory, BitAutoData] + public async Task ReplaceAndUpdateCacheAsync_SavesThenRefreshesTheCache( + Organization organization, + SutProvider sutProvider) + { + await sutProvider.Sut.ReplaceAndUpdateCacheAsync(organization); + + var organizationRepository = sutProvider.GetDependency(); + var organizationAbilityCacheService = sutProvider.GetDependency(); + Received.InOrder(() => + { + organizationRepository.ReplaceAsync(organization); + organizationAbilityCacheService.UpsertOrganizationAbilityAsync(organization); + }); + } + + [Theory, BitAutoData] + public async Task ReplaceAndUpdateCacheAsync_NoEvent_LogsNothing( + Organization organization, + SutProvider sutProvider) + { + await sutProvider.Sut.ReplaceAndUpdateCacheAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationEventAsync(default(Organization)!, default); + } + + [Theory, BitAutoData] + public async Task ReplaceAndUpdateCacheAsync_WithEvent_LogsTheEvent( + Organization organization, + SutProvider sutProvider) + { + await sutProvider.Sut.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationEventAsync(organization, EventType.Organization_Updated); + } + + [Theory, BitAutoData] + public async Task ReplaceAndUpdateCacheAsync_SaveFails_RethrowsWithoutRefreshingTheCache( + Organization organization, + SutProvider sutProvider) + { + var saveFailure = new InvalidOperationException("The save failed."); + sutProvider.GetDependency() + .ReplaceAsync(organization) + .ThrowsAsync(saveFailure); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ReplaceAndUpdateCacheAsync(organization)); + + Assert.Same(saveFailure, exception); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpsertOrganizationAbilityAsync(default!); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index dae553f29e7d..511af7abe3a4 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -1,11 +1,12 @@ using System.Text.Json; -using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; @@ -1263,9 +1264,8 @@ public async Task UpdateAsync_WhenValidOrganization_AndUpdateBillingIsTrue_Updat { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationAbilityCacheService = sutProvider.GetDependency(); + var organizationReplaceCommand = sutProvider.GetDependency(); var stripeAdapter = sutProvider.GetDependency(); - var eventService = sutProvider.GetDependency(); var requestOptionsReturned = new CustomerUpdateOptions { @@ -1303,16 +1303,9 @@ await stripeAdapter && options.Description == requestOptionsReturned.Description && options.InvoiceSettings.CustomFields.First().Name == requestOptionsReturned.InvoiceSettings.CustomFields.First().Name && options.InvoiceSettings.CustomFields.First().Value == requestOptionsReturned.InvoiceSettings.CustomFields.First().Value)); ; - await organizationRepository - .Received(1) - .ReplaceAsync(Arg.Is(org => org == organization)); - await organizationAbilityCacheService + await organizationReplaceCommand .Received(1) - .UpsertOrganizationAbilityAsync(Arg.Is(org => org == organization)); - await eventService - .Received(1) - .LogOrganizationEventAsync(Arg.Is(org => org == organization), - Arg.Is(e => e == EventType.Organization_Updated)); + .ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); } [Theory, BitAutoData] @@ -1320,9 +1313,8 @@ public async Task UpdateAsync_WhenValidOrganization_AndUpdateBillingIsFalse_Upda { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationAbilityCacheService = sutProvider.GetDependency(); + var organizationReplaceCommand = sutProvider.GetDependency(); var stripeAdapter = sutProvider.GetDependency(); - var eventService = sutProvider.GetDependency(); organizationRepository .GetByIdentifierAsync(organization.Identifier!) @@ -1338,16 +1330,9 @@ await organizationRepository await stripeAdapter .DidNotReceiveWithAnyArgs() .UpdateCustomerAsync(Arg.Any(), Arg.Any()); - await organizationRepository + await organizationReplaceCommand .Received(1) - .ReplaceAsync(Arg.Is(org => org == organization)); - await organizationAbilityCacheService - .Received(1) - .UpsertOrganizationAbilityAsync(Arg.Is(org => org == organization)); - await eventService - .Received(1) - .LogOrganizationEventAsync(Arg.Is(org => org == organization), - Arg.Is(e => e == EventType.Organization_Updated)); + .ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); } [Theory, BitAutoData] @@ -2051,4 +2036,194 @@ await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(ids => !ids.Any())); } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_ReservesASeatBeforeSendingInvites( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + var reserveCommand = sutProvider.GetDependency(); + reserveCommand.RunAsync(organization, 1).Returns(PrivilegedControlsSeatReservation.Reserved); + + await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); + + Assert.True(stagedUser.AccessPam); + Received.InOrder(() => + { + reserveCommand.RunAsync(organization, 1); + sutProvider.GetDependency().SendInvitesAsync(Arg.Any()); + }); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_ReservesAfterSecretsManagerSeatsAreAdded( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + invite.AccessSecretsManager = true; + sutProvider.GetDependency() + .CountNewSmSeatsRequiredAsync(organization.Id, 1).Returns(1); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(MockPlans.Get(organization.PlanType)); + var reserveCommand = sutProvider.GetDependency(); + reserveCommand.RunAsync(organization, 1).Returns(PrivilegedControlsSeatReservation.Reserved); + + await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); + + Received.InOrder(() => + { + sutProvider.GetDependency() + .UpdateSubscriptionAsync(Arg.Any()); + reserveCommand.RunAsync(organization, 1); + }); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithoutPrivilegedControls_DoesNotReserve( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + stagedUser.AccessPam = false; + + await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_OrganizationWithoutPrivilegedControls_DoesNotReserve( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + organization.UsePam = false; + + await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_AtLimit_ThrowsSeatLimitMessageWithoutInviting( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut + .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); + + Assert.Contains(exception.InnerExceptions, + e => e is BadRequestException && e.Message == new PrivilegedControlsSeatLimitReached().Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendInvitesAsync(default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(default, default(IEnumerable)); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_SelfHostedShortfall_ThrowsSelfHostMessage( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut + .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); + + Assert.Contains(exception.InnerExceptions, + e => e is BadRequestException && e.Message == new CannotAutoscalePamSeatsOnSelfHost().Message); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_InviteFailsAfterReserving_GivesTheSeatsBack( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + organization.PamSeats = 5; + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.Reserved) + .AndDoes(_ => organization.PamSeats = 6); + sutProvider.GetDependency() + .SendInvitesAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("SMTP is down.")); + + await Assert.ThrowsAsync(() => sutProvider.Sut + .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); + + await sutProvider.GetDependency() + .Received(1) + .Run(organization, 5); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_GivingTheSeatsBackFails_StillThrowsTheOriginalError( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + organization.PamSeats = 5; + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.Reserved) + .AndDoes(_ => organization.PamSeats = 6); + sutProvider.GetDependency() + .SendInvitesAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("SMTP is down.")); + sutProvider.GetDependency() + .Run(organization, 5) + .ThrowsAsync(new GatewayException("Stripe is unavailable.")); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut + .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); + + Assert.Contains(exception.InnerExceptions, e => e.Message == "SMTP is down."); + } + + /// + /// Arranges a single-email invite that promotes a staged member who was already given Privileged Controls access, + /// in an organization that uses Privileged Controls. + /// + private void InviteUser_ArrangeStagedUserWithPrivilegedControls(Organization organization, + OrganizationUserInvite invite, OrganizationUser stagedUser, SutProvider sutProvider) + { + organization.UsePam = true; + stagedUser.Status = OrganizationUserStatusType.Staged; + stagedUser.AccessPam = true; + invite.AccessSecretsManager = false; + InviteUser_ArrangeExistingOrgUser(organization, invite, stagedUser, sutProvider); + } } diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index 38fd0c070f5b..c1c8671bae04 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Commands; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Mail.Mailer; using Bit.Core.Billing.Organizations.Commands; @@ -9,7 +10,6 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Subscriptions.Organization.Commands; @@ -32,7 +32,7 @@ public class AutoscalePrivilegedControlsSeatsCommandTests Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IOrganizationReplaceCommand _organizationReplaceCommand = Substitute.For(); private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); private readonly IMailer _mailer = Substitute.For(); @@ -45,7 +45,7 @@ public AutoscalePrivilegedControlsSeatsCommandTests() => _sut = new AutoscalePrivilegedControlsSeatsCommand( _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, - _organizationService, + _organizationReplaceCommand, _organizationUserRepository, _isOnTrialQuery, _mailer, @@ -126,7 +126,7 @@ public async Task Run_OnTrial_SeatsShort_WithinLimit_SavesSeatsWithoutTouchingSt Assert.True(result.Success); Assert.Equal(7, organization.PamSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); @@ -143,7 +143,7 @@ public async Task Run_OnTrial_PastLimit_ReturnsLimitWithoutSaving() Assert.False(result.Success); Assert.Equal(6, result.MaxAutoscaleSeats); - await _organizationService.DidNotReceive() + await _organizationReplaceCommand.DidNotReceive() .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); } @@ -183,7 +183,7 @@ public async Task Run_BoughtSeatsAlreadyCoverTheTotal_SucceedsWithoutChangingAny await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationService.DidNotReceive() + await _organizationReplaceCommand.DidNotReceive() .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); } @@ -235,7 +235,7 @@ public async Task Run_TotalExceedsBoughtSeats_SavesTheNewSeatCount() Assert.Equal(15, organization.PamSeats); Assert.Equal(20, organization.MaxAutoscalePamSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); } [Fact] @@ -274,7 +274,7 @@ public async Task Run_TotalExceedsTheLimit_ChangesSavesAndEmailsNothing() await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationService.DidNotReceive() + await _organizationReplaceCommand.DidNotReceive() .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); await _mailer.DidNotReceive().SendEmail(Arg.Any()); } @@ -336,7 +336,7 @@ public async Task Run_EmailFails_StillSucceedsAndKeepsTheSavedSeats() Assert.True(result.Success); Assert.Equal(15, organization.PamSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); } [Fact] @@ -351,7 +351,7 @@ public async Task Run_SeatChangeStepRejectsTheRequest_ThrowsItsExceptionAndChang Assert.Equal("Organization's plan does not support Privileged Controls.", exception.Message); Assert.Equal(_boughtSeats, organization.PamSeats); - await _organizationService.DidNotReceive() + await _organizationReplaceCommand.DidNotReceive() .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); } @@ -368,7 +368,7 @@ public async Task Run_SubscriptionUpdateRejected_ThrowsBadRequestAndLeavesTheOrg Assert.Equal("Your card was declined.", exception.Message); Assert.Equal(_boughtSeats, organization.PamSeats); - await _organizationService.DidNotReceive() + await _organizationReplaceCommand.DidNotReceive() .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); await _mailer.DidNotReceive().SendEmail(Arg.Any()); } diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs new file mode 100644 index 000000000000..d95b8e7d6b76 --- /dev/null +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs @@ -0,0 +1,179 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Billing.Models.Mail.Mailer; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Subscriptions.Organization.Commands; +using Bitwarden.Server.Sdk.Environment; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Subscriptions.Organization.Test.Commands; + +public class ReservePrivilegedControlsSeatForJoiningMemberCommandTests +{ + private const string _memberEmail = "member@example.com"; + + private readonly IReservePrivilegedControlsSeatsCommand _reservePrivilegedControlsSeatsCommand = + Substitute.For(); + private readonly IOrganizationUserRepository _organizationUserRepository = + Substitute.For(); + private readonly IMailer _mailer = Substitute.For(); + private readonly GlobalSettings _globalSettings = new(); + private readonly IBitwardenEnvironment _environment = Substitute.For(); + private readonly ReservePrivilegedControlsSeatForJoiningMemberCommand _sut; + + public ReservePrivilegedControlsSeatForJoiningMemberCommandTests() => + _sut = new ReservePrivilegedControlsSeatForJoiningMemberCommand( + _reservePrivilegedControlsSeatsCommand, + _organizationUserRepository, + _mailer, + _globalSettings, + _environment, + new RecordingLogger()); + + [Fact] + public async Task RunAsync_MemberWithoutAccess_KeepsWithoutReserving() + { + var organization = CreateOrganization(); + var organizationUser = CreateOrganizationUser(accessPam: false); + + var result = await _sut.RunAsync(organization, organizationUser); + + Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); + await _reservePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); + } + + [Fact] + public async Task RunAsync_PamOff_KeepsAccessWithoutReserving() + { + var organization = CreateOrganization(usePam: false); + var organizationUser = CreateOrganizationUser(); + + var result = await _sut.RunAsync(organization, organizationUser); + + Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); + Assert.True(organizationUser.AccessPam); + await _reservePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); + } + + [Fact] + public async Task RunAsync_Reserved_KeepsAccess() + { + var organization = CreateOrganization(); + var organizationUser = CreateOrganizationUser(); + _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1).Returns(PrivilegedControlsSeatReservation.Reserved); + + var result = await _sut.RunAsync(organization, organizationUser); + + Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); + Assert.True(organizationUser.AccessPam); + } + + [Theory] + [InlineData(PrivilegedControlsSeatReservation.LimitReached)] + [InlineData(PrivilegedControlsSeatReservation.SelfHostedShortfall)] + public async Task RunAsync_NoSeatAvailable_RemovesAccessAtSeatLimit(PrivilegedControlsSeatReservation reservation) + { + var organization = CreateOrganization(); + var organizationUser = CreateOrganizationUser(); + _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1).Returns(reservation); + + var result = await _sut.RunAsync(organization, organizationUser); + + Assert.Equal(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit, result); + Assert.False(organizationUser.AccessPam); + } + + [Fact] + public async Task RunAsync_ReservationThrows_RemovesAccessAfterError() + { + var organization = CreateOrganization(); + var organizationUser = CreateOrganizationUser(); + _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1) + .ThrowsAsync(new GatewayException("Stripe is down.")); + + var result = await _sut.RunAsync(organization, organizationUser); + + Assert.Equal(JoiningMemberPrivilegedControlsAccess.RemovedAfterError, result); + Assert.False(organizationUser.AccessPam); + } + + [Fact] + public async Task RunAsync_NoSeatAvailable_SendsNoEmail() + { + var organization = CreateOrganization(); + var organizationUser = CreateOrganizationUser(); + _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1) + .Returns(PrivilegedControlsSeatReservation.LimitReached); + + await _sut.RunAsync(organization, organizationUser); + + await _mailer.DidNotReceiveWithAnyArgs().SendEmail(default!); + } + + [Fact] + public async Task NotifyOwnersAsync_Cloud_EmailsEachOwnerOnce() + { + var organization = CreateOrganization(); + SetUpOwners(organization, ["owner1@example.com", "owner2@example.com", "owner1@example.com"]); + + await _sut.NotifyOwnersAsync(organization, _memberEmail); + + await _mailer.Received(1).SendEmail(Arg.Is(mail => + mail.ToEmails.SequenceEqual(new[] { "owner1@example.com", "owner2@example.com" }) + && mail.View.MemberEmail == CoreHelpers.PreventEmailAutoLinking(_memberEmail) + && mail.View.OrganizationId == organization.Id)); + } + + [Fact] + public async Task NotifyOwnersAsync_SelfHosted_SendsNoEmail() + { + var organization = CreateOrganization(); + SetUpOwners(organization, ["owner@example.com"]); + _environment.SelfHosted.Returns(true); + + await _sut.NotifyOwnersAsync(organization, _memberEmail); + + await _mailer.DidNotReceiveWithAnyArgs().SendEmail(default!); + } + + [Fact] + public async Task NotifyOwnersAsync_EmailFails_DoesNotThrow() + { + var organization = CreateOrganization(); + SetUpOwners(organization, ["owner@example.com"]); + _mailer.SendEmail(default!) + .ThrowsAsyncForAnyArgs(new Exception("smtp down")); + + var exception = await Record.ExceptionAsync(() => _sut.NotifyOwnersAsync(organization, _memberEmail)); + + Assert.Null(exception); + } + + private void SetUpOwners(OrganizationEntity organization, string[] ownerEmails) => + _organizationUserRepository + .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) + .Returns(ownerEmails.Select(email => new OrganizationUserUserDetails { Email = email }).ToList()); + + private static OrganizationEntity CreateOrganization(bool usePam = true) => new() + { + Id = Guid.NewGuid(), + Name = "Acme.Corp", + UsePam = usePam, + PamSeats = 5 + }; + + private static OrganizationUser CreateOrganizationUser(bool accessPam = true) => new() + { + Id = Guid.NewGuid(), + AccessPam = accessPam + }; +} diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs new file mode 100644 index 000000000000..69a41835883f --- /dev/null +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs @@ -0,0 +1,121 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Subscriptions.Organization.Commands; +using Bitwarden.Server.Sdk.Environment; +using NSubstitute; +using Xunit; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Subscriptions.Organization.Test.Commands; + +public class ReservePrivilegedControlsSeatsCommandTests +{ + private readonly ICountNewPamSeatsRequiredQuery _countNewPamSeatsRequiredQuery = + Substitute.For(); + private readonly IAutoscalePrivilegedControlsSeatsCommand _autoscalePrivilegedControlsSeatsCommand = + Substitute.For(); + private readonly IBitwardenEnvironment _environment = Substitute.For(); + private readonly ReservePrivilegedControlsSeatsCommand _sut; + + public ReservePrivilegedControlsSeatsCommandTests() => + _sut = new ReservePrivilegedControlsSeatsCommand( + _countNewPamSeatsRequiredQuery, + _autoscalePrivilegedControlsSeatsCommand, + _environment); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task RunAsync_NoMembers_ReturnsReservedWithoutCallingAutoscale(int membersTakingSeats) + { + var organization = CreateOrganization(); + + var result = await _sut.RunAsync(organization, membersTakingSeats); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + } + + [Fact] + public async Task RunAsync_PamOff_ReturnsReservedWithoutCallingAutoscale() + { + var organization = CreateOrganization(usePam: false); + + var result = await _sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + public async Task RunAsync_NoPamSeats_ReturnsReservedWithoutCallingAutoscale(int? pamSeats) + { + var organization = CreateOrganization(pamSeats: pamSeats); + + var result = await _sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + } + + [Fact] + public async Task RunAsync_SelfHosted_Shortfall_ReturnsSelfHostedShortfall() + { + var organization = CreateOrganization(); + _environment.SelfHosted.Returns(true); + _countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, 2).Returns(1); + + var result = await _sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.SelfHostedShortfall, result); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + } + + [Fact] + public async Task RunAsync_SelfHosted_NoShortfall_ReturnsReserved() + { + var organization = CreateOrganization(); + _environment.SelfHosted.Returns(true); + _countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, 2).Returns(0); + + var result = await _sut.RunAsync(organization, 2); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + } + + [Fact] + public async Task RunAsync_Cloud_AutoscaleSucceeds_ReturnsReserved() + { + var organization = CreateOrganization(); + _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3) + .Returns(new AutoscalePrivilegedControlsSeatsResult(true, null)); + + var result = await _sut.RunAsync(organization, 3); + + Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + await _autoscalePrivilegedControlsSeatsCommand.Received(1).Run(organization, 3); + } + + [Fact] + public async Task RunAsync_Cloud_AutoscaleAtLimit_ReturnsLimitReached() + { + var organization = CreateOrganization(); + _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3) + .Returns(new AutoscalePrivilegedControlsSeatsResult(false, 6)); + + var result = await _sut.RunAsync(organization, 3); + + Assert.Equal(PrivilegedControlsSeatReservation.LimitReached, result); + } + + private static OrganizationEntity CreateOrganization(bool usePam = true, int? pamSeats = 5) => new() + { + Id = Guid.NewGuid(), + UsePam = usePam, + PamSeats = pamSeats + }; +} diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs index 85cc6c569f53..13aae2d72bd7 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bit.Subscriptions.Organization.Commands; using Bitwarden.Server.Sdk.Environment; using NSubstitute; @@ -19,7 +19,7 @@ public class RevertPrivilegedControlsSeatsCommandTests Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IOrganizationReplaceCommand _organizationReplaceCommand = Substitute.For(); private readonly FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = new(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly RevertPrivilegedControlsSeatsCommand _sut; @@ -28,7 +28,7 @@ public RevertPrivilegedControlsSeatsCommandTests() => _sut = new RevertPrivilegedControlsSeatsCommand( _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, - _organizationService, + _organizationReplaceCommand, _isOnTrialQuery, _environment); @@ -42,7 +42,7 @@ public async Task Run_SeatsUnchanged_DoesNothing() await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _organizationReplaceCommand.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); } [Fact] @@ -54,7 +54,7 @@ public async Task Run_OnTrial_ResetsSeatsOnOrganizationOnly() await _sut.Run(organization, 5); Assert.Equal(5, organization.PamSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); @@ -79,7 +79,7 @@ public async Task Run_Paid_UpdatesSubscriptionThenSaves() await _seatChangeSetFactory.Received(1).CreateAsync(organization, 5); await _updateOrganizationSubscriptionCommand.Received(1).Run(organization, changeSet); Assert.Equal(5, organization.PamSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); } [Fact] diff --git a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs index 5a43458213c5..36dcdc6bf1da 100644 --- a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Services; @@ -115,12 +118,13 @@ public void AddOrganizationSubscriptions_RegistersRemovePrivilegedControlsHandle } [Fact] - public void AddPrivilegedControlsSeatAutoscaling_ResolvesAutoscaleAndRevertCommands() + public void AddPrivilegedControlsSeatAutoscaling_ResolvesReserveAutoscaleAndRevertCommands() { var services = new ServiceCollection(); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); @@ -135,5 +139,9 @@ public void AddPrivilegedControlsSeatAutoscaling_ResolvesAutoscaleAndRevertComma scope.ServiceProvider.GetService()); Assert.IsType( scope.ServiceProvider.GetService()); + Assert.IsType( + scope.ServiceProvider.GetService()); + Assert.IsType( + scope.ServiceProvider.GetService()); } } From 32d904d99b8f159d19f3db5de6c9b1362ffe5f7d Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:57:58 -0500 Subject: [PATCH 23/44] [PM-45107] fix: Require a subscription only when Privileged Controls seats must be added Every cloud grant of Privileged Controls access now goes through the autoscale step, including grants the organization's bought seats already cover. Rejecting an organization without a Stripe subscription before checking whether any seats were needed blocked those admin grants and stripped access from joining members for no reason. The check now runs only when seats must be added, so an organization without billing still never gets seats it isn't charged for. --- ...AutoscalePrivilegedControlsSeatsCommand.cs | 4 +-- ...AutoscalePrivilegedControlsSeatsCommand.cs | 10 +++---- ...calePrivilegedControlsSeatsCommandTests.cs | 28 ++++++++++++++++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs index 8a071958969d..1ec509746fa6 100644 --- a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs @@ -26,8 +26,8 @@ public interface IAutoscalePrivilegedControlsSeatsCommand /// /// /// Thrown on a self-hosted instance, when is below 1, when the organization does - /// not have Privileged Controls or has no subscription, or when the seat change or - /// subscription update is rejected. + /// not have Privileged Controls, when seats must be added and it has no subscription, or when the seat change + /// or subscription update is rejected. /// /// Thrown when the organization's subscription is in a state that prevents the change. Task Run(OrganizationEntity organization, int seatsToAdd); diff --git a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs index 46ab0c7c4521..5c07921aedea 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs @@ -44,16 +44,16 @@ public async Task Run(OrganizationEntity throw new BadRequestException("Your organization doesn't have Privileged Controls."); } - if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) - { - throw new BadRequestException("No subscription found."); - } - var seatsNeeded = await organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + seatsToAdd; if (seatsNeeded > organization.PamSeats) { + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + throw new BadRequestException("No subscription found."); + } + if (organization.MaxAutoscalePamSeats < seatsNeeded) { return new AutoscalePrivilegedControlsSeatsResult(false, organization.MaxAutoscalePamSeats); diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index c1c8671bae04..a718956bbabb 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs @@ -103,16 +103,36 @@ public async Task Run_OrganizationUsesPrivilegedControlsWithNoSeats_ThrowsBadReq [InlineData(null)] [InlineData("")] [InlineData(" ")] - public async Task Run_NoGatewaySubscription_ThrowsBadRequestWithoutReadingSeatsInUse(string? gatewaySubscriptionId) + public async Task Run_NoGatewaySubscriptionAndSeatsShort_ThrowsBadRequestAndChangesNothing(string? gatewaySubscriptionId) { var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); + SetSeatsInUse(organization, _seatsInUse); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); + var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 3)); Assert.Equal("No subscription found.", exception.Message); - await _organizationUserRepository.DidNotReceive() - .GetOccupiedPamSeatCountByOrganizationIdAsync(Arg.Any()); + Assert.Equal(_boughtSeats, organization.PamSeats); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _organizationReplaceCommand.DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Run_NoGatewaySubscriptionAndSeatsAlreadyCovered_SucceedsWithoutChangingAnything( + string? gatewaySubscriptionId) + { + var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); + SetSeatsInUse(organization, _seatsInUse); + + var result = await _sut.Run(organization, 2); + + Assert.True(result.Success); + Assert.Equal(_boughtSeats, organization.PamSeats); + await _organizationReplaceCommand.DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); } [Fact] From 66cf762424beca254c3a50f208e7d136887e3dd7 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:10:48 -0500 Subject: [PATCH 24/44] [PM-45107] fix: Give back seats when the Privileged Controls step fails while inviting staged members Inviting staged members buys Password Manager and Secrets Manager seats before reserving Privileged Controls seats. Only a bad request or gateway failure from that reservation handed the earlier seats back, so a ConflictException, which the subscription update raises when the subscription is unpaid or has a conflicting schedule, left those seats billed for members who were never invited. Any failure from the reservation now hands back every seat the invite added before the error propagates. --- .../InviteStagedOrganizationUsersCommand.cs | 14 ++++++- ...viteStagedOrganizationUsersCommandTests.cs | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index 5dd7582db5a4..051fcc95566b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -85,7 +85,19 @@ public async Task>> RunAsync(Invite return secretsManagerSeatReservationError; } - var pamSeatReservationError = await ReservePrivilegedControlsSeatsAsync(organization, eligible); + Error? pamSeatReservationError; + try + { + pamSeatReservationError = await ReservePrivilegedControlsSeatsAsync(organization, eligible); + } + catch + { + await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); + await ReleaseSeatsAsync(organization, seatsToAdd); + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); + throw; + } + if (pamSeatReservationError is not null) { await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index a70a9d2aba5a..bc31059e5114 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -849,6 +849,45 @@ await sutProvider.GetDependency() .AdjustSeatsAsync(organization.Id, -(organizationUsers.Count - 1)); } + [Theory, BitAutoData] + public async Task RunAsync_PamReservationThrowsConflict_ReleasesPasswordManagerSeatsAndRethrows( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + organization.MaxAutoscaleSeats = 100; + var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 9); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .ThrowsAsync(new ConflictException("The subscription is unpaid.")); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + + await sutProvider.GetDependency() + .Received(1) + .AdjustSeatsAsync(organization.Id, -(organizationUsers.Count - 1)); + Assert.All(organizationUsers, organizationUser => + Assert.Equal(OrganizationUserStatusType.Staged, organizationUser.Status)); + } + + [Theory, BitAutoData] + public async Task RunAsync_PamReservationThrowsConflict_ReleasesSecretsManagerSeatsAndRethrows( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = ArrangeWithSecretsManager(sutProvider, organization, organizationUsers, performedBy); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .ThrowsAsync(new ConflictException("The subscription is unpaid.")); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + + await sutProvider.GetDependency() + .Received(1) + .UpdateSubscriptionAsync(Arg.Is(update => update.SmSeats == 5)); + } + [Theory, BitAutoData] public async Task RunAsync_PamSelfHostedShortfall_ReturnsCannotAutoscaleOnSelfHost( Organization organization, List organizationUsers, Guid performedBy) From 4b6da19dbe4e46ed8793f823b678ace394f8e4c8 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:38:08 -0500 Subject: [PATCH 25/44] [PM-45107] refactor: Group Subscriptions.Organization commands into feature folders The commands folder had grown to cover three unrelated areas: invoice previews, Privileged Controls seats, and the new-subscription purchase. Each now lives in its own folder with a matching namespace. The invoice preview folder is named InvoicePreviews, matching Bit.Invoicing.InvoicePreviews, because a namespace named InvoicePreview hides the InvoicePreview result type the preview commands return. --- .../PreviewOrganizationPlanChangeCommand.cs | 2 +- .../PreviewOrganizationRestartCommand.cs | 2 +- .../AddPrivilegedControlsToExistingSubscriptionCommand.cs | 2 +- .../AutoscalePrivilegedControlsSeatsCommand.cs | 2 +- .../ChangePrivilegedControlsSeatsCommand.cs | 2 +- .../RemovePrivilegedControlsCommand.cs | 2 +- .../ReservePrivilegedControlsSeatForJoiningMemberCommand.cs | 2 +- .../ReservePrivilegedControlsSeatsCommand.cs | 3 ++- .../RevertPrivilegedControlsSeatsCommand.cs | 2 +- .../{ => Purchase}/CreateOrganizationSubscriptionCommand.cs | 2 +- .../Handlers/ChangePrivilegedControlsSeatsHandler.cs | 2 +- .../Handlers/PreviewOrganizationPlanChangeHandler.cs | 2 +- .../Handlers/PreviewOrganizationRestartHandler.cs | 2 +- .../SubscriptionsOrganizationServiceCollectionExtensions.cs | 4 +++- ...AddPrivilegedControlsToExistingSubscriptionCommandTests.cs | 2 +- .../Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs | 2 +- .../Commands/ChangePrivilegedControlsSeatsCommandTests.cs | 2 +- .../Commands/CreateOrganizationSubscriptionCommandTests.cs | 2 +- .../Commands/PreviewOrganizationPlanChangeCommandTests.cs | 2 +- .../Commands/PreviewOrganizationRestartCommandTests.cs | 2 +- .../Commands/RemovePrivilegedControlsCommandTests.cs | 2 +- ...servePrivilegedControlsSeatForJoiningMemberCommandTests.cs | 2 +- .../Commands/ReservePrivilegedControlsSeatsCommandTests.cs | 2 +- .../Commands/RevertPrivilegedControlsSeatsCommandTests.cs | 2 +- .../Handlers/ChangePrivilegedControlsSeatsHandlerTests.cs | 2 +- .../Handlers/PreviewOrganizationPlanChangeHandlerTests.cs | 2 +- .../Handlers/PreviewOrganizationRestartHandlerTests.cs | 2 +- ...anizationPrivilegedControlsEndpointsRequestBindingTests.cs | 2 +- .../OrganizationSubscriptionEndpointsRequestBindingTests.cs | 2 +- ...bscriptionsOrganizationServiceCollectionExtensionsTests.cs | 2 +- 30 files changed, 33 insertions(+), 30 deletions(-) rename src/Libraries/Subscriptions.Organization/Commands/{ => InvoicePreviews}/PreviewOrganizationPlanChangeCommand.cs (99%) rename src/Libraries/Subscriptions.Organization/Commands/{ => InvoicePreviews}/PreviewOrganizationRestartCommand.cs (99%) rename src/Libraries/Subscriptions.Organization/Commands/{ => PrivilegedControls}/AddPrivilegedControlsToExistingSubscriptionCommand.cs (97%) rename src/Libraries/Subscriptions.Organization/Commands/{ => PrivilegedControls}/AutoscalePrivilegedControlsSeatsCommand.cs (98%) rename src/Libraries/Subscriptions.Organization/Commands/{ => PrivilegedControls}/ChangePrivilegedControlsSeatsCommand.cs (97%) rename src/Libraries/Subscriptions.Organization/Commands/{ => PrivilegedControls}/RemovePrivilegedControlsCommand.cs (98%) rename src/Libraries/Subscriptions.Organization/Commands/{ => PrivilegedControls}/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs (97%) rename src/Libraries/Subscriptions.Organization/Commands/{ => PrivilegedControls}/ReservePrivilegedControlsSeatsCommand.cs (95%) rename src/Libraries/Subscriptions.Organization/Commands/{ => PrivilegedControls}/RevertPrivilegedControlsSeatsCommand.cs (95%) rename src/Libraries/Subscriptions.Organization/Commands/{ => Purchase}/CreateOrganizationSubscriptionCommand.cs (99%) diff --git a/src/Libraries/Subscriptions.Organization/Commands/PreviewOrganizationPlanChangeCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/InvoicePreviews/PreviewOrganizationPlanChangeCommand.cs similarity index 99% rename from src/Libraries/Subscriptions.Organization/Commands/PreviewOrganizationPlanChangeCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/InvoicePreviews/PreviewOrganizationPlanChangeCommand.cs index 5e4f106be93a..faa447e03a76 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PreviewOrganizationPlanChangeCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/InvoicePreviews/PreviewOrganizationPlanChangeCommand.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging; using Stripe; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.InvoicePreviews; using static StripeConstants; using OrganizationEntity = Core.AdminConsole.Entities.Organization; diff --git a/src/Libraries/Subscriptions.Organization/Commands/PreviewOrganizationRestartCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/InvoicePreviews/PreviewOrganizationRestartCommand.cs similarity index 99% rename from src/Libraries/Subscriptions.Organization/Commands/PreviewOrganizationRestartCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/InvoicePreviews/PreviewOrganizationRestartCommand.cs index 4065e4d669be..0231bf41b9c8 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PreviewOrganizationRestartCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/InvoicePreviews/PreviewOrganizationRestartCommand.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging; using Stripe; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.InvoicePreviews; using static StripeConstants; using OrganizationEntity = Core.AdminConsole.Entities.Organization; diff --git a/src/Libraries/Subscriptions.Organization/Commands/AddPrivilegedControlsToExistingSubscriptionCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs similarity index 97% rename from src/Libraries/Subscriptions.Organization/Commands/AddPrivilegedControlsToExistingSubscriptionCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs index c0d6e93a12ae..15c12e44b7fc 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/AddPrivilegedControlsToExistingSubscriptionCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs @@ -6,7 +6,7 @@ using Bitwarden.Server.Sdk.Environment; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal interface IAddPrivilegedControlsToExistingSubscriptionCommand { diff --git a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs similarity index 98% rename from src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs index 5c07921aedea..16a69f2f6946 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.Logging; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal sealed class AutoscalePrivilegedControlsSeatsCommand( IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, diff --git a/src/Libraries/Subscriptions.Organization/Commands/ChangePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs similarity index 97% rename from src/Libraries/Subscriptions.Organization/Commands/ChangePrivilegedControlsSeatsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs index febbf40c9475..8a3d564b5062 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/ChangePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs @@ -6,7 +6,7 @@ using Bitwarden.Server.Sdk.Environment; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal interface IChangePrivilegedControlsSeatsCommand { diff --git a/src/Libraries/Subscriptions.Organization/Commands/RemovePrivilegedControlsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs similarity index 98% rename from src/Libraries/Subscriptions.Organization/Commands/RemovePrivilegedControlsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs index 91a84931cd82..40b43bc3f5fa 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/RemovePrivilegedControlsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal interface IRemovePrivilegedControlsCommand { diff --git a/src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs similarity index 97% rename from src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs index d33990a0e804..07dead6fe6e3 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal sealed class ReservePrivilegedControlsSeatForJoiningMemberCommand( IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, diff --git a/src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs similarity index 95% rename from src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs index c6567f489899..324b72ddf522 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/ReservePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs @@ -4,13 +4,14 @@ using Bitwarden.Server.Sdk.Environment; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal sealed class ReservePrivilegedControlsSeatsCommand( ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, IAutoscalePrivilegedControlsSeatsCommand autoscalePrivilegedControlsSeatsCommand, IBitwardenEnvironment environment) : IReservePrivilegedControlsSeatsCommand { + public async Task RunAsync(OrganizationEntity organization, int membersTakingSeats) { if (membersTakingSeats < 1 || !organization.UsePam || organization.PamSeats is null or < 1) diff --git a/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs similarity index 95% rename from src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs index 2aca941101e8..e7b11b692e39 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/RevertPrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs @@ -6,7 +6,7 @@ using Bitwarden.Server.Sdk.Environment; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal sealed class RevertPrivilegedControlsSeatsCommand( IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, diff --git a/src/Libraries/Subscriptions.Organization/Commands/CreateOrganizationSubscriptionCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/Purchase/CreateOrganizationSubscriptionCommand.cs similarity index 99% rename from src/Libraries/Subscriptions.Organization/Commands/CreateOrganizationSubscriptionCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/Purchase/CreateOrganizationSubscriptionCommand.cs index 59d39c40dce8..a2de8be19f7d 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/CreateOrganizationSubscriptionCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/Purchase/CreateOrganizationSubscriptionCommand.cs @@ -19,7 +19,7 @@ using StripeConstants = Bit.Core.Billing.Constants.StripeConstants; using Subscription = Stripe.Subscription; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.Purchase; internal sealed class CreateOrganizationSubscriptionCommand( IBraintreeGateway braintreeGateway, diff --git a/src/Libraries/Subscriptions.Organization/Handlers/ChangePrivilegedControlsSeatsHandler.cs b/src/Libraries/Subscriptions.Organization/Handlers/ChangePrivilegedControlsSeatsHandler.cs index aec62cca34f1..4f9f4481b8b9 100644 --- a/src/Libraries/Subscriptions.Organization/Handlers/ChangePrivilegedControlsSeatsHandler.cs +++ b/src/Libraries/Subscriptions.Organization/Handlers/ChangePrivilegedControlsSeatsHandler.cs @@ -1,6 +1,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Models.Requests; using Bit.Subscriptions.Organization.Queries; diff --git a/src/Libraries/Subscriptions.Organization/Handlers/PreviewOrganizationPlanChangeHandler.cs b/src/Libraries/Subscriptions.Organization/Handlers/PreviewOrganizationPlanChangeHandler.cs index d0df64afcd26..6dea06f8566a 100644 --- a/src/Libraries/Subscriptions.Organization/Handlers/PreviewOrganizationPlanChangeHandler.cs +++ b/src/Libraries/Subscriptions.Organization/Handlers/PreviewOrganizationPlanChangeHandler.cs @@ -1,7 +1,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Invoicing.InvoicePreviews.Models; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.InvoicePreviews; using Bit.Subscriptions.Organization.Models.Requests; namespace Bit.Subscriptions.Organization.Handlers; diff --git a/src/Libraries/Subscriptions.Organization/Handlers/PreviewOrganizationRestartHandler.cs b/src/Libraries/Subscriptions.Organization/Handlers/PreviewOrganizationRestartHandler.cs index 9ccac6b674f9..5a4a360b68ac 100644 --- a/src/Libraries/Subscriptions.Organization/Handlers/PreviewOrganizationRestartHandler.cs +++ b/src/Libraries/Subscriptions.Organization/Handlers/PreviewOrganizationRestartHandler.cs @@ -1,7 +1,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Invoicing.InvoicePreviews.Models; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.InvoicePreviews; using Bit.Subscriptions.Organization.Models.Requests; namespace Bit.Subscriptions.Organization.Handlers; diff --git a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs index 1f17e96c43ff..babe32bd0b63 100644 --- a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs +++ b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs @@ -3,7 +3,9 @@ using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Invoicing; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.InvoicePreviews; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; +using Bit.Subscriptions.Organization.Commands.Purchase; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Helpers; using Bit.Subscriptions.Organization.Queries; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs index 99202e4e6ed6..58228d33d889 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs @@ -5,7 +5,7 @@ using Bit.Core.Billing.Organizations.Models; using Bit.Core.Exceptions; using Bit.Core.Services; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index a718956bbabb..58e74249bf5a 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs @@ -12,7 +12,7 @@ using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs index 91049aac8833..23630b126e76 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs @@ -5,7 +5,7 @@ using Bit.Core.Billing.Organizations.Models; using Bit.Core.Exceptions; using Bit.Core.Services; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/CreateOrganizationSubscriptionCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/CreateOrganizationSubscriptionCommandTests.cs index 02442277f655..25170971fcea 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/CreateOrganizationSubscriptionCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/CreateOrganizationSubscriptionCommandTests.cs @@ -11,7 +11,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.Purchase; using Braintree; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/PreviewOrganizationPlanChangeCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/PreviewOrganizationPlanChangeCommandTests.cs index 9374cf840901..f64068f196aa 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/PreviewOrganizationPlanChangeCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/PreviewOrganizationPlanChangeCommandTests.cs @@ -6,7 +6,7 @@ using Bit.Core.Exceptions; using Bit.Invoicing.InvoicePreviews; using Bit.Invoicing.InvoicePreviews.Models; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.InvoicePreviews; using Bit.Subscriptions.Organization.Models.Requests; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/PreviewOrganizationRestartCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/PreviewOrganizationRestartCommandTests.cs index b94d19dd97c7..43f58e2eef58 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/PreviewOrganizationRestartCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/PreviewOrganizationRestartCommandTests.cs @@ -7,7 +7,7 @@ using Bit.Core.Exceptions; using Bit.Invoicing.InvoicePreviews; using Bit.Invoicing.InvoicePreviews.Models; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.InvoicePreviews; using Bit.Subscriptions.Organization.Models.Requests; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs index 18ed3ca15bb7..797a2aac8f1c 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs @@ -5,7 +5,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Services; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs index d95b8e7d6b76..767aacd62441 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs @@ -8,7 +8,7 @@ using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs index 69a41835883f..43fe0f21cfd5 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Queries; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using Xunit; diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs index 13aae2d72bd7..26674b74333f 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs @@ -4,7 +4,7 @@ using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Exceptions; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using Stripe; diff --git a/test/Libraries/Subscriptions.Organization.Test/Handlers/ChangePrivilegedControlsSeatsHandlerTests.cs b/test/Libraries/Subscriptions.Organization.Test/Handlers/ChangePrivilegedControlsSeatsHandlerTests.cs index 11ef7595c708..0dd9f61edb4f 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Handlers/ChangePrivilegedControlsSeatsHandlerTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Handlers/ChangePrivilegedControlsSeatsHandlerTests.cs @@ -1,6 +1,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Models.Requests; using NSubstitute; diff --git a/test/Libraries/Subscriptions.Organization.Test/Handlers/PreviewOrganizationPlanChangeHandlerTests.cs b/test/Libraries/Subscriptions.Organization.Test/Handlers/PreviewOrganizationPlanChangeHandlerTests.cs index 368319c20c3b..f066c54d3aa7 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Handlers/PreviewOrganizationPlanChangeHandlerTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Handlers/PreviewOrganizationPlanChangeHandlerTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Invoicing.InvoicePreviews.Models; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.InvoicePreviews; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Models.Requests; using NSubstitute; diff --git a/test/Libraries/Subscriptions.Organization.Test/Handlers/PreviewOrganizationRestartHandlerTests.cs b/test/Libraries/Subscriptions.Organization.Test/Handlers/PreviewOrganizationRestartHandlerTests.cs index b472f6651889..aeff45ab6ab3 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Handlers/PreviewOrganizationRestartHandlerTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Handlers/PreviewOrganizationRestartHandlerTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Invoicing.InvoicePreviews.Models; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.InvoicePreviews; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Models.Requests; using NSubstitute; diff --git a/test/Libraries/Subscriptions.Organization.Test/OrganizationPrivilegedControlsEndpointsRequestBindingTests.cs b/test/Libraries/Subscriptions.Organization.Test/OrganizationPrivilegedControlsEndpointsRequestBindingTests.cs index b32904d00616..6a8d2594b801 100644 --- a/test/Libraries/Subscriptions.Organization.Test/OrganizationPrivilegedControlsEndpointsRequestBindingTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/OrganizationPrivilegedControlsEndpointsRequestBindingTests.cs @@ -3,7 +3,7 @@ using System.Text.Json; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Queries; using Microsoft.AspNetCore.Builder; diff --git a/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs b/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs index d99fc756a129..8ff2f891edfe 100644 --- a/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs @@ -8,7 +8,7 @@ using Bit.Core.Models.Api; using Bit.Core.Repositories; using Bit.Invoicing.InvoicePreviews.Models; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.InvoicePreviews; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Models.Requests; using Microsoft.AspNetCore.Builder; diff --git a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs index 36dcdc6bf1da..9efd9c08b0a7 100644 --- a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs @@ -9,7 +9,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Queries; using Bitwarden.Server.Sdk.Environment; From d2113ed83c81d62816f8b84be2728fe958c0b5e2 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:21:19 -0500 Subject: [PATCH 26/44] [PM-45107] refactor: Return Privileged Controls seat reservation failures as errors The reserve command now returns a result carrying the Admin Console error instead of an enum, and catches its own failures so callers no longer need try/catch around it. Business failures map to a 400 and anything unexpected maps to a 500, with the exception attached for callers that throw. An organization with no Privileged Controls seat count still skips the check, while an organization with zero seats is treated as having none free. The joining-member wrapper is removed: self-service callers clear access themselves based on the result, and the Owner email moves to its own command. Seat contracts, errors and helpers move under Core's Billing namespace, and the organization replace command is renamed to describe that it also refreshes the cache. --- .../src/Sso/Controllers/AccountController.cs | 43 +++-- .../Controllers/AccountControllerTest.cs | 56 +++--- .../AcceptOrganizationInviteLinkCommand.cs | 21 +- .../ConfirmOrganizationInviteLinkCommand.cs | 21 +- .../BulkEnablePam/BulkEnablePamCommand.cs | 11 +- .../PrivilegedControlsSeats/Errors.cs | 7 - ...egedControlsSeatForJoiningMemberCommand.cs | 40 ---- .../IReservePrivilegedControlsSeatsCommand.cs | 45 ----- .../v1/RestoreOrganizationUserCommand.cs | 47 ++--- .../OrganizationUsers/StagedUsers/Errors.cs | 3 - .../InviteStagedOrganizationUsersCommand.cs | 49 +---- .../v2/UpdateOrganizationUserCommand.cs | 11 +- ...placeOrganizationAndUpdateCacheCommand.cs} | 4 +- ...placeOrganizationAndUpdateCacheCommand.cs} | 8 +- .../Implementations/OrganizationService.cs | 21 +- .../IReservePrivilegedControlsSeatsCommand.cs | 21 ++ ...rivilegedControlsSeatNeededEmailCommand.cs | 16 ++ .../PrivilegedControlsSeatOccupancy.cs | 14 ++ ...PrivilegedControlsSeatReservationResult.cs | 18 ++ .../Organizations/PrivilegedControlsErrors.cs | 8 + .../{Errors.cs => SecretsManagerErrors.cs} | 0 ...OrganizationServiceCollectionExtensions.cs | 2 +- ...AutoscalePrivilegedControlsSeatsCommand.cs | 4 +- ...egedControlsSeatForJoiningMemberCommand.cs | 86 --------- .../ReservePrivilegedControlsSeatsCommand.cs | 52 ++++- .../RevertPrivilegedControlsSeatsCommand.cs | 4 +- ...rivilegedControlsSeatNeededEmailCommand.cs | 54 ++++++ .../Subscriptions.Organization/README.md | 13 +- ...OrganizationServiceCollectionExtensions.cs | 12 +- .../OrganizationUserControllerPutTests.cs | 4 +- ...cceptOrganizationInviteLinkCommandTests.cs | 71 ++++--- ...nfirmOrganizationInviteLinkCommandTests.cs | 53 +++--- .../BulkEnablePamCommandTests.cs | 56 +++++- .../RestoreOrganizationUserCommandTests.cs | 147 +++++++++----- ...viteStagedOrganizationUsersCommandTests.cs | 77 +++++--- .../v2/UpdateOrganizationUserCommandTests.cs | 45 ++++- ...OrganizationAndUpdateCacheCommandTests.cs} | 26 +-- .../Services/OrganizationServiceTests.cs | 74 ++++++-- .../PrivilegedControlsSeatOccupancyTests.cs | 4 +- ...calePrivilegedControlsSeatsCommandTests.cs | 39 ++-- ...ontrolsSeatForJoiningMemberCommandTests.cs | 179 ------------------ ...ervePrivilegedControlsSeatsCommandTests.cs | 94 +++++++-- ...vertPrivilegedControlsSeatsCommandTests.cs | 11 +- ...egedControlsSeatNeededEmailCommandTests.cs | 87 +++++++++ ...izationServiceCollectionExtensionsTests.cs | 9 +- 45 files changed, 897 insertions(+), 770 deletions(-) delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs rename src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/{IOrganizationReplaceCommand.cs => IReplaceOrganizationAndUpdateCacheCommand.cs} (76%) rename src/Core/AdminConsole/OrganizationFeatures/Organizations/{OrganizationReplaceCommand.cs => ReplaceOrganizationAndUpdateCacheCommand.cs} (75%) create mode 100644 src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs create mode 100644 src/Core/Billing/Organizations/Commands/ISendPrivilegedControlsSeatNeededEmailCommand.cs create mode 100644 src/Core/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancy.cs create mode 100644 src/Core/Billing/Organizations/Models/PrivilegedControlsSeatReservationResult.cs create mode 100644 src/Core/Billing/Organizations/PrivilegedControlsErrors.cs rename src/Core/Billing/Organizations/{Errors.cs => SecretsManagerErrors.cs} (100%) delete mode 100644 src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs create mode 100644 src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/SendPrivilegedControlsSeatNeededEmailCommand.cs rename test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/{OrganizationReplaceCommandTests.cs => ReplaceOrganizationAndUpdateCacheCommandTests.cs} (68%) rename test/Core.Test/{AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats => Billing/Organizations/Helpers}/PrivilegedControlsSeatOccupancyTests.cs (75%) delete mode 100644 test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs create mode 100644 test/Libraries/Subscriptions.Organization.Test/Commands/SendPrivilegedControlsSeatNeededEmailCommandTests.cs diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 9e783ac8f4dc..120e49a7c764 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -13,6 +12,9 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Registration; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; @@ -60,7 +62,8 @@ public class AccountController : Controller private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IRegisterUserCommand _registerUserCommand; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; - private readonly IReservePrivilegedControlsSeatForJoiningMemberCommand _reservePrivilegedControlsSeatForJoiningMemberCommand; + private readonly IReservePrivilegedControlsSeatsCommand _reservePrivilegedControlsSeatsCommand; + private readonly ISendPrivilegedControlsSeatNeededEmailCommand _sendPrivilegedControlsSeatNeededEmailCommand; public AccountController( IAuthenticationSchemeProvider schemeProvider, @@ -83,7 +86,8 @@ public AccountController( IOrganizationDomainRepository organizationDomainRepository, IRegisterUserCommand registerUserCommand, ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, - IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand) + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + ISendPrivilegedControlsSeatNeededEmailCommand sendPrivilegedControlsSeatNeededEmailCommand) { _schemeProvider = schemeProvider; _clientStore = clientStore; @@ -105,7 +109,8 @@ public AccountController( _organizationDomainRepository = organizationDomainRepository; _registerUserCommand = registerUserCommand; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; - _reservePrivilegedControlsSeatForJoiningMemberCommand = reservePrivilegedControlsSeatForJoiningMemberCommand; + _reservePrivilegedControlsSeatsCommand = reservePrivilegedControlsSeatsCommand; + _sendPrivilegedControlsSeatNeededEmailCommand = sendPrivilegedControlsSeatNeededEmailCommand; } [HttpGet] @@ -697,7 +702,7 @@ SsoConfigurationData ssoConfigData // won't complete. Staged rows aren't seat-counted; a Staged→Invited promotion // consumes a seat and must gate here alongside the fresh-JIT case. var willPromoteStagedOrgUser = possibleOrgUser?.Status == OrganizationUserStatusType.Staged; - var privilegedControlsAccess = JoiningMemberPrivilegedControlsAccess.Kept; + var pamSeatReservation = new PrivilegedControlsSeatReservationResult(); if (possibleOrgUser == null || willPromoteStagedOrgUser) { @@ -705,8 +710,12 @@ SsoConfigurationData ssoConfigData if (willPromoteStagedOrgUser) { - privilegedControlsAccess = - await _reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, possibleOrgUser!); + pamSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync( + organization, possibleOrgUser!.AccessPam ? 1 : 0); + if (!pamSeatReservation.Success) + { + possibleOrgUser.AccessPam = false; + } } } @@ -784,8 +793,8 @@ SsoConfigurationData ssoConfigData if (possibleOrgUser.Status == OrganizationUserStatusType.Staged) { // The member seat and the Privileged Controls seat were both reserved up-front before user - // creation, so it is safe to consume them here. The Privileged Controls step may have cleared - // AccessPam on this instance, which the ReplaceAsync below saves. + // creation, so it is safe to consume them here. AccessPam may have been cleared on this instance + // when no Privileged Controls seat was available, which the ReplaceAsync below saves. possibleOrgUser.Status = OrganizationUserStatusType.Invited; } @@ -793,9 +802,9 @@ SsoConfigurationData ssoConfigData possibleOrgUser.RevisionDate = DateTime.UtcNow; await _organizationUserRepository.ReplaceAsync(possibleOrgUser); - if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) + if (pamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) { - await _reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync( + await _sendPrivilegedControlsSeatNeededEmailCommand.RunAsync( organization, possibleOrgUser.Email ?? newUser.Email); } } @@ -872,8 +881,12 @@ private async Task PromoteStagedOrgUserAndSendInviteAsync( var previousRevisionDate = orgUser.RevisionDate; var previousAccessPam = orgUser.AccessPam; - var privilegedControlsAccess = - await _reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync(organization, orgUser); + var pamSeatReservation = + await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, orgUser.AccessPam ? 1 : 0); + if (!pamSeatReservation.Success) + { + orgUser.AccessPam = false; + } orgUser.Status = OrganizationUserStatusType.Invited; orgUser.RevisionDate = DateTime.UtcNow; @@ -902,9 +915,9 @@ await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( throw; } - if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) + if (pamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) { - await _reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync(organization, orgUser.Email!); + await _sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, orgUser.Email!); } } diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index f4add3f0f5d3..e0cc5c3a9c54 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -5,7 +5,6 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.IdentityServer; @@ -13,6 +12,9 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Registration; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -36,6 +38,7 @@ using NSubstitute.ExceptionExtensions; using Xunit.Abstractions; using AuthenticationOptions = Duende.IdentityServer.Configuration.AuthenticationOptions; +using CommandError = Bit.Core.AdminConsole.Utilities.v2.Error; namespace Bit.SSO.Test.Controllers; @@ -1073,8 +1076,8 @@ public async Task ExternalCallback_ExistingUserStagedWithPam_AtLimit_PromotesWit var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; var orgUser = BuildStagedOrgUserWithPam(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); - var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( - sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatLimitReachedError()); var organizationUserRepository = sutProvider.GetDependency(); await Assert.ThrowsAsync( @@ -1086,7 +1089,7 @@ await Assert.ThrowsAsync ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Invited && !ou.AccessPam)); - joiningMemberCommand.NotifyOwnersAsync(organization, email); + sendSeatNeededEmailCommand.RunAsync(organization, email); }); } @@ -1102,8 +1105,8 @@ public async Task ExternalCallback_NewUserStagedWithPam_AtLimit_ProvisionsWithou sutProvider.GetDependency() .RunAsync(orgId, PolicyType.TwoFactorAuthentication) .Returns(new PolicyStatus(orgId, PolicyType.TwoFactorAuthentication)); - var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( - sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatLimitReachedError()); var organizationUserRepository = sutProvider.GetDependency(); await InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email); @@ -1114,7 +1117,7 @@ public async Task ExternalCallback_NewUserStagedWithPam_AtLimit_ProvisionsWithou ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Invited && !ou.AccessPam)); - joiningMemberCommand.NotifyOwnersAsync(organization, email); + sendSeatNeededEmailCommand.RunAsync(organization, email); }); } @@ -1128,8 +1131,8 @@ public async Task ExternalCallback_ExistingUserStagedWithPam_ReservationFailed_P var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; var orgUser = BuildStagedOrgUserWithPam(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); - var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( - sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAfterError); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatReservationUnexpectedError()); await Assert.ThrowsAsync( () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); @@ -1140,7 +1143,7 @@ await sutProvider.GetDependency() ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Invited && !ou.AccessPam)); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sendSeatNeededEmailCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default!); } [Theory, BitAutoData] @@ -1155,8 +1158,8 @@ public async Task ExternalCallback_NewUserStagedWithPam_ReservationFailed_Provis sutProvider.GetDependency() .RunAsync(orgId, PolicyType.TwoFactorAuthentication) .Returns(new PolicyStatus(orgId, PolicyType.TwoFactorAuthentication)); - var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( - sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAfterError); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatReservationUnexpectedError()); await InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email); @@ -1166,7 +1169,7 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(ou => ou.Id == orgUser.Id && !ou.AccessPam)); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sendSeatNeededEmailCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default!); } [Theory, BitAutoData] @@ -1179,8 +1182,8 @@ public async Task ExternalCallback_ExistingUserStagedWithPam_InviteSendFails_Res var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; var orgUser = BuildStagedOrgUserWithPam(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); - var joiningMemberCommand = ArrangePrivilegedControlsAccessRemoved( - sutProvider, organization, orgUser, JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatLimitReachedError()); sutProvider.GetDependency() .SendInvitesAsync(Arg.Any()) .ThrowsAsync(new InvalidOperationException("mail failure")); @@ -1195,24 +1198,18 @@ await sutProvider.GetDependency() ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Staged && ou.AccessPam)); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sendSeatNeededEmailCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default!); } - private static IReservePrivilegedControlsSeatForJoiningMemberCommand ArrangePrivilegedControlsAccessRemoved( + private static ISendPrivilegedControlsSeatNeededEmailCommand ArrangePrivilegedControlsSeatUnavailable( SutProvider sutProvider, Organization organization, - OrganizationUser orgUser, - JoiningMemberPrivilegedControlsAccess outcome) + CommandError error) { - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, orgUser) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return Task.FromResult(outcome); - }); - return joiningMemberCommand; + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(error)); + return sutProvider.GetDependency(); } private static OrganizationUser BuildStagedOrgUserWithPam(Guid orgId, string email) => @@ -1246,6 +1243,9 @@ private static void ArrangeStagedMember( } sutProvider.GetDependency() .GetByOrganizationEmailAsync(organization.Id, email).Returns(stagedOrgUser); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); } private static async Task InvokeCreateUserAndOrgUserConditionallyAsync( diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs index 6833f7a0290b..107dbc92323f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs @@ -1,12 +1,14 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.AdminConsole.Utilities; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -27,7 +29,8 @@ public class AcceptOrganizationInviteLinkCommand( IMailService mailService, IPushAutoConfirmNotificationCommand pushAutoConfirmNotificationCommand, IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, - IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + ISendPrivilegedControlsSeatNeededEmailCommand sendPrivilegedControlsSeatNeededEmailCommand, ILogger logger, IEventService eventService) : IAcceptOrganizationInviteLinkCommand @@ -72,7 +75,7 @@ private async Task> AcceptExistingOrgUserAsync( { var organization = validationResult.Organization; - var privilegedControlsAccess = JoiningMemberPrivilegedControlsAccess.Kept; + var pamSeatReservation = new PrivilegedControlsSeatReservationResult(); if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -81,8 +84,12 @@ private async Task> AcceptExistingOrgUserAsync( return seatReservationError; } - privilegedControlsAccess = await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync( - organization, existingOrganizationUser); + pamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync( + organization, existingOrganizationUser.AccessPam ? 1 : 0); + if (!pamSeatReservation.Success) + { + existingOrganizationUser.AccessPam = false; + } } var memberEmail = existingOrganizationUser.Email ?? user.Email; @@ -98,9 +105,9 @@ private async Task> AcceptExistingOrgUserAsync( await organizationUserRepository.ReplaceAsync(existingOrganizationUser); - if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) + if (pamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) { - await reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); + await sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, memberEmail); } return existingOrganizationUser; diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index b91b154a621e..21e1a51d96b5 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -1,12 +1,14 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -38,7 +40,8 @@ public class ConfirmOrganizationInviteLinkCommand( IStripePaymentService stripePaymentService, IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, IPushNotificationService pushNotificationService, - IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + ISendPrivilegedControlsSeatNeededEmailCommand sendPrivilegedControlsSeatNeededEmailCommand, IEventService eventService, ILogger logger) : IConfirmOrganizationInviteLinkCommand @@ -117,7 +120,7 @@ private async Task> AddUserToOrganizationAsync(C private async Task> ConfirmExistingMembershipAsync( Organization organization, OrganizationUser existingOrganizationUser, User user, string orgUserKey) { - var privilegedControlsAccess = JoiningMemberPrivilegedControlsAccess.Kept; + var pamSeatReservation = new PrivilegedControlsSeatReservationResult(); if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -126,8 +129,12 @@ private async Task> ConfirmExistingMembershipAsy return seatReservationError; } - privilegedControlsAccess = await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync( - organization, existingOrganizationUser); + pamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync( + organization, existingOrganizationUser.AccessPam ? 1 : 0); + if (!pamSeatReservation.Success) + { + existingOrganizationUser.AccessPam = false; + } } var memberEmail = existingOrganizationUser.Email ?? user.Email; @@ -139,9 +146,9 @@ private async Task> ConfirmExistingMembershipAsy await organizationUserRepository.ReplaceAsync(existingOrganizationUser); - if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) + if (pamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) { - await reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); + await sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, memberEmail); } return existingOrganizationUser; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs index e1e9c9036c14..dc045186f602 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs @@ -1,8 +1,9 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Repositories; using OneOf.Types; @@ -29,15 +30,11 @@ public async Task BulkEnablePamAsync(Organization organization, I return new PamNotEnabled(); } - // Revoked and staged members are given access without occupying a seat. var membersTakingSeats = organizationUsers.Count(u => PrivilegedControlsSeatOccupancy.OccupiesSeat(u.Status)); var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); - switch (reservation) + if (!reservation.Success) { - case PrivilegedControlsSeatReservation.LimitReached: - return new PrivilegedControlsSeatLimitReached(); - case PrivilegedControlsSeatReservation.SelfHostedShortfall: - return new CannotAutoscalePamSeatsOnSelfHost(); + return reservation.Error!; } foreach (var organizationUser in organizationUsers) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs deleted file mode 100644 index 785a97d31ab8..000000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/Errors.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Bit.Core.AdminConsole.Utilities.v2; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; - -public record PrivilegedControlsSeatLimitReached() : BadRequestError("Privileged Controls seat limit has been reached. Ask an Owner to add seats or raise the limit."); - -public record CannotAutoscalePamSeatsOnSelfHost() : BadRequestError("Cannot autoscale Privileged Controls seats on a self-hosted instance."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs deleted file mode 100644 index 5bbe2e002692..000000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatForJoiningMemberCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; - -public enum JoiningMemberPrivilegedControlsAccess -{ - /// - /// The member keeps Privileged Controls access. - /// - Kept, - /// - /// The organization has no Privileged Controls seat left, so the member joins without access. - /// - RemovedAtSeatLimit, - /// - /// Reserving the seat failed, so the member joins without access. - /// - RemovedAfterError -} - -/// -/// Makes sure a member who is joining an organization with Privileged Controls access has a seat for it. -/// -public interface IReservePrivilegedControlsSeatForJoiningMemberCommand -{ - /// - /// Reserves a Privileged Controls seat for the member. When no seat can be reserved, sets - /// to false in memory so the member can still join. The caller saves - /// the member in its existing save. - /// - Task RunAsync(Organization organization, OrganizationUser organizationUser); - - /// - /// Emails the organization's Owners that the member joined without Privileged Controls access. Call after the - /// member is saved, when returned . - /// Does nothing on self-hosted. - /// - Task NotifyOwnersAsync(Organization organization, string memberEmail); -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs deleted file mode 100644 index 2fe21adeaf70..000000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/IReservePrivilegedControlsSeatsCommand.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Enums; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; - -public enum PrivilegedControlsSeatReservation -{ - /// - /// Seats are available, or the organization is not subject to a Privileged Controls seat limit (it has no - /// Privileged Controls seats at all). - /// - Reserved, - /// - /// A cloud organization cannot add the seats because it would pass its autoscale limit. - /// - LimitReached, - /// - /// A self-hosted organization does not have enough seats on its license, and seats cannot be autoscaled. - /// - SelfHostedShortfall -} - -/// -/// Makes sure an organization has Privileged Controls seats for members who are about to be given access. -/// -public interface IReservePrivilegedControlsSeatsCommand -{ - /// - /// Reserves seats for members who are about to be granted Privileged - /// Controls access and do not already occupy a seat. On cloud, seats are autoscaled up to the organization's limit. - /// The caller grants the access. - /// - Task RunAsync(Organization organization, int membersTakingSeats); -} - -public static class PrivilegedControlsSeatOccupancy -{ - /// - /// Whether a member with this status occupies a Privileged Controls seat once granted access. - /// - public static bool OccupiesSeat(OrganizationUserStatusType status) => - status is OrganizationUserStatusType.Invited - or OrganizationUserStatusType.Accepted - or OrganizationUserStatusType.Confirmed; -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index b520203090e7..62549c554783 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -3,14 +3,18 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; +using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Helpers; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -37,7 +41,7 @@ public class RestoreOrganizationUserCommand( IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, TimeProvider timeProvider, IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, - IReservePrivilegedControlsSeatForJoiningMemberCommand reservePrivilegedControlsSeatForJoiningMemberCommand, + ISendPrivilegedControlsSeatNeededEmailCommand sendPrivilegedControlsSeatNeededEmailCommand, ILogger logger) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName) @@ -117,7 +121,7 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled); var status = organizationUser.GetPriorActiveOrganizationUserStatusType(); - var privilegedControlsAccess = JoiningMemberPrivilegedControlsAccess.Kept; + var scimPamSeatReservation = new PrivilegedControlsSeatReservationResult(); if (organization.UsePam && organizationUser.AccessPam && PrivilegedControlsSeatOccupancy.OccupiesSeat(status)) { @@ -127,10 +131,10 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, } else if (systemUser == EventSystemUser.SCIM) { - privilegedControlsAccess = await reservePrivilegedControlsSeatForJoiningMemberCommand.RunAsync( - organization, organizationUser); - if (privilegedControlsAccess != JoiningMemberPrivilegedControlsAccess.Kept) + scimPamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!scimPamSeatReservation.Success) { + organizationUser.AccessPam = false; // RestoreAsync only writes the status, so the removed access has to be saved separately. await organizationUserRepository.ReplaceAsync(organizationUser); } @@ -145,11 +149,11 @@ await organizationUserRepository.StartInviteReminderSequencesAsync( [organizationUser.Id], timeProvider.GetUtcNow().UtcDateTime); } - if (privilegedControlsAccess == JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit) + if (scimPamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) { var memberEmail = organizationUser.Email ?? (await userRepository.GetByIdAsync(organizationUser.UserId!.Value)).Email; - await reservePrivilegedControlsSeatForJoiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); + await sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, memberEmail); } if (organizationUser.UserId.HasValue @@ -171,29 +175,16 @@ await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.Organi private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int membersTakingSeats, int passwordManagerSeatsAdded) { - PrivilegedControlsSeatReservation reservation; - try - { - reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); - } - catch + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); + if (reservation.Success) { - await ReleasePasswordManagerSeatsAsync(organization, passwordManagerSeatsAdded); - throw; + return; } - var errorMessage = reservation switch - { - PrivilegedControlsSeatReservation.LimitReached => new PrivilegedControlsSeatLimitReached().Message, - PrivilegedControlsSeatReservation.SelfHostedShortfall => new CannotAutoscalePamSeatsOnSelfHost().Message, - _ => null - }; - - if (errorMessage is not null) - { - await ReleasePasswordManagerSeatsAsync(organization, passwordManagerSeatsAdded); - throw new BadRequestException(errorMessage); - } + await ReleasePasswordManagerSeatsAsync(organization, passwordManagerSeatsAdded); + throw reservation.Error is InternalError + ? new InvalidOperationException(reservation.Error.Message, reservation.Exception) + : new BadRequestException(reservation.Error!.Message); } private async Task ReleasePasswordManagerSeatsAsync(Organization organization, int seatsToRelease) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/Errors.cs index 8287432f93f3..532f7a72b017 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/Errors.cs @@ -16,6 +16,3 @@ public record SeatExpansionFailed(string Reason) public record SecretsManagerSeatExpansionFailed(string Reason) : BadRequestError(Reason); - -public record PrivilegedControlsSeatExpansionFailed(string Reason) - : BadRequestError(Reason); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index 051fcc95566b..09bff236dcd4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -2,7 +2,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Billing.Organizations.Commands; @@ -85,24 +84,14 @@ public async Task>> RunAsync(Invite return secretsManagerSeatReservationError; } - Error? pamSeatReservationError; - try - { - pamSeatReservationError = await ReservePrivilegedControlsSeatsAsync(organization, eligible); - } - catch + var membersWithPrivilegedControls = eligible.Count(organizationUser => organizationUser.AccessPam); + var pamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersWithPrivilegedControls); + if (!pamSeatReservation.Success) { await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); await ReleaseSeatsAsync(organization, seatsToAdd); await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); - throw; - } - - if (pamSeatReservationError is not null) - { - await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); - await ReleaseSeatsAsync(organization, seatsToAdd); - return pamSeatReservationError; + return pamSeatReservation.Error!; } try @@ -418,36 +407,6 @@ private async Task ReleaseSeatsAsync(Organization organization, int seatsToRelea } } - /// - /// Reserves a Privileged Controls seat for each eligible member who already has access to it. - /// - private async Task ReservePrivilegedControlsSeatsAsync( - Organization organization, - List organizationUsers) - { - var membersWithPrivilegedControls = organization.UsePam - ? organizationUsers.Count(organizationUser => organizationUser.AccessPam) - : 0; - - try - { - var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersWithPrivilegedControls); - return reservation switch - { - PrivilegedControlsSeatReservation.LimitReached => new PrivilegedControlsSeatLimitReached(), - PrivilegedControlsSeatReservation.SelfHostedShortfall => new CannotAutoscalePamSeatsOnSelfHost(), - _ => null - }; - } - catch (Exception ex) when (ex is BadRequestException or GatewayException) - { - logger.LogWarning(ex, - "Could not add {SeatsToAdd} Privileged Controls seat(s) while inviting staged members for organization {OrganizationId}", - membersWithPrivilegedControls, organization.Id); - return new PrivilegedControlsSeatExpansionFailed(ex.Message); - } - } - /// /// Restores the Privileged Controls seat count captured before the reservation when a later step means /// nobody will be invited. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs index bd93f2a8e28e..566a74200658 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -1,11 +1,12 @@ using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.UserFeatures.UserEmail; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; using Bit.Core.Enums; @@ -204,19 +205,13 @@ await mailer.SendEmail(new MemberEmailChangedNotificationMail private async Task TryEnablingPamAsync(UpdateOrganizationUserRequest request) { - // Only members who occupy a seat need one reserved. Revoked and staged members are given access without one. if (!PrivilegedControlsSeatOccupancy.OccupiesSeat(request.OrganizationUserToUpdate.Status)) { return null; } var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(request.Organization, 1); - return reservation switch - { - PrivilegedControlsSeatReservation.LimitReached => new PrivilegedControlsSeatLimitReached(), - PrivilegedControlsSeatReservation.SelfHostedShortfall => new CannotAutoscalePamSeatsOnSelfHost(), - _ => null - }; + return reservation.Error; } private async Task LoadUserToUpdateAsync(UpdateOrganizationUserRequest request) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationReplaceCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IReplaceOrganizationAndUpdateCacheCommand.cs similarity index 76% rename from src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationReplaceCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IReplaceOrganizationAndUpdateCacheCommand.cs index 19e849a65acc..5ea641f6298d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationReplaceCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IReplaceOrganizationAndUpdateCacheCommand.cs @@ -3,12 +3,12 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; -public interface IOrganizationReplaceCommand +public interface IReplaceOrganizationAndUpdateCacheCommand { /// /// Saves the organization and refreshes its cached abilities. /// /// The organization to save. /// When provided, an organization event of this type is logged after the save. - Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); + Task RunAsync(Organization org, EventType? orgEvent = null); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommand.cs similarity index 75% rename from src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommand.cs index 2de6a6ad968a..7b47343eb3a9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommand.cs @@ -8,13 +8,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; -public class OrganizationReplaceCommand( +public class ReplaceOrganizationAndUpdateCacheCommand( IOrganizationRepository organizationRepository, IOrganizationAbilityCacheService organizationAbilityCacheService, IEventService eventService, - ILogger logger) : IOrganizationReplaceCommand + ILogger logger) : IReplaceOrganizationAndUpdateCacheCommand { - public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) + public async Task RunAsync(Organization org, EventType? orgEvent = null) { try { @@ -28,7 +28,7 @@ public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEve } catch (Exception exception) { - logger.LogError(exception, "An error occurred while calling {Method} for Organization ({OrganizationID})", nameof(ReplaceAndUpdateCacheAsync), org.Id); + logger.LogError(exception, "An error occurred while replacing Organization ({OrganizationID}) and updating its cache", org.Id); throw; } } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 53bebbb603f2..f8847de05436 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -10,8 +10,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Constants; @@ -61,7 +61,7 @@ public class OrganizationService : IOrganizationService private readonly IStripeAdapter _stripeAdapter; private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand; private readonly TimeProvider _timeProvider; - private readonly IOrganizationReplaceCommand _organizationReplaceCommand; + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand; private readonly IReservePrivilegedControlsSeatsCommand _reservePrivilegedControlsSeatsCommand; private readonly IRevertPrivilegedControlsSeatsCommand _revertPrivilegedControlsSeatsCommand; @@ -88,7 +88,7 @@ public OrganizationService( IStripeAdapter stripeAdapter, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, TimeProvider timeProvider, - IOrganizationReplaceCommand organizationReplaceCommand, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, IRevertPrivilegedControlsSeatsCommand revertPrivilegedControlsSeatsCommand) { @@ -114,7 +114,7 @@ public OrganizationService( _stripeAdapter = stripeAdapter; _updateOrganizationSubscriptionCommand = updateOrganizationSubscriptionCommand; _timeProvider = timeProvider; - _organizationReplaceCommand = organizationReplaceCommand; + _replaceOrganizationAndUpdateCacheCommand = replaceOrganizationAndUpdateCacheCommand; _reservePrivilegedControlsSeatsCommand = reservePrivilegedControlsSeatsCommand; _revertPrivilegedControlsSeatsCommand = revertPrivilegedControlsSeatsCommand; } @@ -855,13 +855,14 @@ private async Task RestorePromotedStagedUsersAsync( private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int membersTakingSeats) { var reservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); - switch (reservation) + if (reservation.Success) { - case PrivilegedControlsSeatReservation.LimitReached: - throw new BadRequestException(new PrivilegedControlsSeatLimitReached().Message); - case PrivilegedControlsSeatReservation.SelfHostedShortfall: - throw new BadRequestException(new CannotAutoscalePamSeatsOnSelfHost().Message); + return; } + + throw reservation.Error is InternalError + ? new InvalidOperationException(reservation.Error.Message, reservation.Exception) + : new BadRequestException(reservation.Error.Message); } /// @@ -1036,7 +1037,7 @@ await _eventService.LogOrganizationUserEventAsync(organizationUser, public Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) => - _organizationReplaceCommand.ReplaceAndUpdateCacheAsync(org, orgEvent); + _replaceOrganizationAndUpdateCacheCommand.RunAsync(org, orgEvent); private async Task GetOrgById(Guid id) { diff --git a/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs new file mode 100644 index 000000000000..2bdc0d7d9f54 --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs @@ -0,0 +1,21 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Core.Billing.Organizations.Commands; + +/// +/// Makes sure an organization has Privileged Controls seats for members who are about to be given access. +/// +public interface IReservePrivilegedControlsSeatsCommand +{ + /// + /// Reserves seats for members who are about to be granted Privileged + /// Controls access and do not already occupy a seat. On cloud, seats are autoscaled up to the organization's limit. + /// The caller grants the access. + /// + /// + /// Succeeds without checking seats when no members are taking seats, the organization doesn't use Privileged + /// Controls, or it has no Privileged Controls seat count. Never throws: every failure is returned as an error. + /// + Task RunAsync(Organization organization, int membersTakingSeats); +} diff --git a/src/Core/Billing/Organizations/Commands/ISendPrivilegedControlsSeatNeededEmailCommand.cs b/src/Core/Billing/Organizations/Commands/ISendPrivilegedControlsSeatNeededEmailCommand.cs new file mode 100644 index 000000000000..6b3bc0587b2c --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/ISendPrivilegedControlsSeatNeededEmailCommand.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.Billing.Organizations.Commands; + +/// +/// Tells an organization's Owners that a member joined without Privileged Controls access because the organization +/// reached its Privileged Controls seat limit. +/// +public interface ISendPrivilegedControlsSeatNeededEmailCommand +{ + /// + /// Emails the organization's Owners naming the member. Call after the member is saved. Does nothing on + /// self-hosted. A failure to send is logged, not thrown. + /// + Task RunAsync(Organization organization, string memberEmail); +} diff --git a/src/Core/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancy.cs b/src/Core/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancy.cs new file mode 100644 index 000000000000..231be0eb4179 --- /dev/null +++ b/src/Core/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancy.cs @@ -0,0 +1,14 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Billing.Organizations.Helpers; + +public static class PrivilegedControlsSeatOccupancy +{ + /// + /// Whether a member with this status occupies a Privileged Controls seat once granted access. + /// + public static bool OccupiesSeat(OrganizationUserStatusType status) => + status is OrganizationUserStatusType.Invited + or OrganizationUserStatusType.Accepted + or OrganizationUserStatusType.Confirmed; +} diff --git a/src/Core/Billing/Organizations/Models/PrivilegedControlsSeatReservationResult.cs b/src/Core/Billing/Organizations/Models/PrivilegedControlsSeatReservationResult.cs new file mode 100644 index 000000000000..e1a04dc5b9b4 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/PrivilegedControlsSeatReservationResult.cs @@ -0,0 +1,18 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.Billing.Organizations.Models; + +/// +/// The outcome of reserving Privileged Controls seats for members who are about to be given access. +/// +/// +/// Why the seats could not be reserved: , +/// , +/// , or +/// . Null when the reservation succeeded. +/// +/// The exception that caused the error, when one was thrown. +public sealed record PrivilegedControlsSeatReservationResult(Error? Error = null, Exception? Exception = null) +{ + public bool Success => Error is null; +} diff --git a/src/Core/Billing/Organizations/PrivilegedControlsErrors.cs b/src/Core/Billing/Organizations/PrivilegedControlsErrors.cs new file mode 100644 index 000000000000..7b4d96ad732a --- /dev/null +++ b/src/Core/Billing/Organizations/PrivilegedControlsErrors.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.Billing.Organizations; + +public record PrivilegedControlsSeatLimitReachedError() : BadRequestError("Privileged Controls seat limit has been reached. Ask an Owner to add seats or raise the limit."); +public record CannotAutoscalePrivilegedControlsSeatsOnSelfHostError() : BadRequestError("Cannot autoscale Privileged Controls seats on a self-hosted instance."); +public record PrivilegedControlsSeatExpansionFailedError(string Reason) : BadRequestError(Reason); +public record PrivilegedControlsSeatReservationUnexpectedError() : InternalError("An unexpected error occurred while reserving Privileged Controls seats."); diff --git a/src/Core/Billing/Organizations/Errors.cs b/src/Core/Billing/Organizations/SecretsManagerErrors.cs similarity index 100% rename from src/Core/Billing/Organizations/Errors.cs rename to src/Core/Billing/Organizations/SecretsManagerErrors.cs diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 204cb2e69e90..4c1bf32a501f 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -112,7 +112,7 @@ private static void AddOrganizationUpdateCommands(this IServiceCollection servic services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationEnableCommands(this IServiceCollection services) => diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs index 16a69f2f6946..9e2075173083 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs @@ -19,7 +19,7 @@ namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal sealed class AutoscalePrivilegedControlsSeatsCommand( IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - IOrganizationReplaceCommand organizationReplaceCommand, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IOrganizationUserRepository organizationUserRepository, IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, IMailer mailer, @@ -66,7 +66,7 @@ public async Task Run(OrganizationEntity } organization.PamSeats = seatsNeeded; - await organizationReplaceCommand.ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } if (organization.MaxAutoscalePamSeats == seatsNeeded) diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs deleted file mode 100644 index 07dead6fe6e3..000000000000 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatForJoiningMemberCommand.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; -using Bit.Core.Billing.Models.Mail.Mailer; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Platform.Mail.Mailer; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Bitwarden.Server.Sdk.Environment; -using Microsoft.Extensions.Logging; -using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; - -namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; - -internal sealed class ReservePrivilegedControlsSeatForJoiningMemberCommand( - IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, - IOrganizationUserRepository organizationUserRepository, - IMailer mailer, - IGlobalSettings globalSettings, - IBitwardenEnvironment environment, - ILogger logger) - : IReservePrivilegedControlsSeatForJoiningMemberCommand -{ - public async Task RunAsync(OrganizationEntity organization, OrganizationUser organizationUser) - { - if (!organization.UsePam || !organizationUser.AccessPam) - { - return JoiningMemberPrivilegedControlsAccess.Kept; - } - - PrivilegedControlsSeatReservation reservation; - try - { - reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); - } - catch (Exception e) - { - logger.LogError(e, "Could not reserve a Privileged Controls seat for a member joining organization {OrganizationId}; the member joins without Privileged Controls access.", - organization.Id); - organizationUser.AccessPam = false; - return JoiningMemberPrivilegedControlsAccess.RemovedAfterError; - } - - if (reservation == PrivilegedControlsSeatReservation.Reserved) - { - return JoiningMemberPrivilegedControlsAccess.Kept; - } - - organizationUser.AccessPam = false; - return JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit; - } - - public async Task NotifyOwnersAsync(OrganizationEntity organization, string memberEmail) - { - if (environment.SelfHosted) - { - return; - } - - try - { - var ownerEmails = (await organizationUserRepository.GetManyByMinimumRoleAsync( - organization.Id, OrganizationUserType.Owner)) - .Select(u => u.Email) - .Distinct(); - - var organizationName = organization.DisplayName(); - - await mailer.SendEmail(new PrivilegedControlsSeatNeededMail - { - ToEmails = ownerEmails, - Subject = $"{organizationName} needs more Privileged Controls seats", - View = new PrivilegedControlsSeatNeededView(globalSettings) - { - OrganizationId = organization.Id, - OrganizationName = CoreHelpers.PreventEmailAutoLinking(organizationName), - MemberEmail = CoreHelpers.PreventEmailAutoLinking(memberEmail) - } - }); - } - catch (Exception e) - { - logger.LogError(e, "Error encountered notifying organization owners that a member needs a Privileged Controls seat."); - } - } -} diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs index 324b72ddf522..d584beec29f8 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs @@ -1,7 +1,10 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Billing.Organizations; using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Exceptions; using Bitwarden.Server.Sdk.Environment; +using Microsoft.Extensions.Logging; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; @@ -9,30 +12,59 @@ namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal sealed class ReservePrivilegedControlsSeatsCommand( ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, IAutoscalePrivilegedControlsSeatsCommand autoscalePrivilegedControlsSeatsCommand, - IBitwardenEnvironment environment) : IReservePrivilegedControlsSeatsCommand + IBitwardenEnvironment environment, + ILogger logger) : IReservePrivilegedControlsSeatsCommand { - - public async Task RunAsync(OrganizationEntity organization, int membersTakingSeats) + public async Task RunAsync(OrganizationEntity organization, int membersTakingSeats) { - if (membersTakingSeats < 1 || !organization.UsePam || organization.PamSeats is null or < 1) + // A null seat count means no seat count applies to the organization yet. + if (membersTakingSeats < 1 || !organization.UsePam || organization.PamSeats is null) { - return PrivilegedControlsSeatReservation.Reserved; + return new PrivilegedControlsSeatReservationResult(); } + try + { + return await ReserveAsync(organization, membersTakingSeats); + } + catch (BadRequestException e) + { + logger.LogWarning(e, + "Could not reserve {SeatsToReserve} Privileged Controls seat(s) for organization {OrganizationId}", + membersTakingSeats, organization.Id); + return new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatExpansionFailedError(e.Message), e); + } + catch (Exception e) + { + logger.LogError(e, + "Unexpected error reserving {SeatsToReserve} Privileged Controls seat(s) for organization {OrganizationId}", + membersTakingSeats, organization.Id); + return new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatReservationUnexpectedError(), e); + } + } + + private async Task ReserveAsync(OrganizationEntity organization, int membersTakingSeats) + { if (environment.SelfHosted) { var seatsRequired = await countNewPamSeatsRequiredQuery .CountNewPamSeatsRequiredAsync(organization, membersTakingSeats); return seatsRequired > 0 - ? PrivilegedControlsSeatReservation.SelfHostedShortfall - : PrivilegedControlsSeatReservation.Reserved; + ? new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError()) + : new PrivilegedControlsSeatReservationResult(); + } + + // The autoscale command can't add seats to an organization that has none, so zero seats means none are free. + if (organization.PamSeats < 1) + { + return new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError()); } var result = await autoscalePrivilegedControlsSeatsCommand.Run(organization, membersTakingSeats); return result.Success - ? PrivilegedControlsSeatReservation.Reserved - : PrivilegedControlsSeatReservation.LimitReached; + ? new PrivilegedControlsSeatReservationResult() + : new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError()); } } diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs index e7b11b692e39..56d7d48ffc9f 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs @@ -11,7 +11,7 @@ namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal sealed class RevertPrivilegedControlsSeatsCommand( IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - IOrganizationReplaceCommand organizationReplaceCommand, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, IBitwardenEnvironment environment) : IRevertPrivilegedControlsSeatsCommand { @@ -34,6 +34,6 @@ public async Task Run(OrganizationEntity organization, int seats) } organization.PamSeats = seats; - await organizationReplaceCommand.ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } } diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/SendPrivilegedControlsSeatNeededEmailCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/SendPrivilegedControlsSeatNeededEmailCommand.cs new file mode 100644 index 000000000000..c54852575de7 --- /dev/null +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/SendPrivilegedControlsSeatNeededEmailCommand.cs @@ -0,0 +1,54 @@ +using Bit.Core.Billing.Models.Mail.Mailer; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Enums; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bitwarden.Server.Sdk.Environment; +using Microsoft.Extensions.Logging; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; + +internal sealed class SendPrivilegedControlsSeatNeededEmailCommand( + IOrganizationUserRepository organizationUserRepository, + IMailer mailer, + IGlobalSettings globalSettings, + IBitwardenEnvironment environment, + ILogger logger) : ISendPrivilegedControlsSeatNeededEmailCommand +{ + public async Task RunAsync(OrganizationEntity organization, string memberEmail) + { + if (environment.SelfHosted) + { + return; + } + + try + { + var ownerEmails = (await organizationUserRepository.GetManyByMinimumRoleAsync( + organization.Id, OrganizationUserType.Owner)) + .Select(u => u.Email) + .Distinct(); + + var organizationName = organization.DisplayName(); + + await mailer.SendEmail(new PrivilegedControlsSeatNeededMail + { + ToEmails = ownerEmails, + Subject = $"{organizationName} needs more Privileged Controls seats", + View = new PrivilegedControlsSeatNeededView(globalSettings) + { + OrganizationId = organization.Id, + OrganizationName = CoreHelpers.PreventEmailAutoLinking(organizationName), + MemberEmail = CoreHelpers.PreventEmailAutoLinking(memberEmail) + } + }); + } + catch (Exception e) + { + logger.LogError(e, "Error encountered notifying organization owners that a member needs a Privileged Controls seat."); + } + } +} diff --git a/src/Libraries/Subscriptions.Organization/README.md b/src/Libraries/Subscriptions.Organization/README.md index a2f4f17bbd12..35d491e143c7 100644 --- a/src/Libraries/Subscriptions.Organization/README.md +++ b/src/Libraries/Subscriptions.Organization/README.md @@ -27,10 +27,11 @@ implementation stays internal here. `IAutoscalePrivilegedControlsSeatsCommand` a `AutoscalePrivilegedControlsSeatsResult` follow the same split: they live in `Core` so hosts and Admin Console operations can resolve the command, and this library implements it. `IRevertPrivilegedControlsSeatsCommand`, `IReservePrivilegedControlsSeatsCommand`, and -`IReservePrivilegedControlsSeatForJoiningMemberCommand` follow the same split: their contracts live in `Core` and this +`ISendPrivilegedControlsSeatNeededEmailCommand` follow the same split: their contracts live in `Core` and this library implements them. A host that resolves any of these commands without mapping the endpoints calls -`AddPrivilegedControlsSeatAutoscaling()`, which registers the reserve, autoscale, and revert commands, the trial query, -and the seat change set factory. `AddOrganizationSubscriptions()` calls it, so the Api host needs only that one. +`AddPrivilegedControlsSeatAutoscaling()`, which registers the reserve, autoscale, revert, and seat-needed email +commands, the trial query, and the seat change set factory. `AddOrganizationSubscriptions()` calls it, so the Api host +needs only that one. `MapOrganizationSubscriptionEndpoints()` attaches the group's cross-cutting chain and maps its endpoints to an empty group; the host owns the route prefix and mounts it at @@ -328,12 +329,12 @@ This library depends on `Core` as a documented deviation from the rule restricti | `IPrivilegedControlsSeatChangeSetFactory` (`Bit.Core.Billing.Organizations.Helpers`) | The factory contract Core owns so Admin Console can inject it; this library implements it | | `IAutoscalePrivilegedControlsSeatsCommand` (`Bit.Core.Billing.Organizations.Commands`), `AutoscalePrivilegedControlsSeatsResult` (`Bit.Core.Billing.Organizations.Models`) | The autoscale command contract and its result, which Core owns so hosts and Admin Console can resolve the command; this library implements it | | `IRevertPrivilegedControlsSeatsCommand` (`Bit.Core.Billing.Organizations.Commands`) | The contract for handing back autoscaled Privileged Controls seats, which Core owns so Admin Console can undo a reservation when the rest of an action fails; this library implements it | -| `IReservePrivilegedControlsSeatsCommand`, `IReservePrivilegedControlsSeatForJoiningMemberCommand`, `PrivilegedControlsSeatReservation`, `JoiningMemberPrivilegedControlsAccess` (`Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats`) | The reserve command contracts and their results, which Core owns so Admin Console member actions can reserve seats before granting access; this library implements them | +| `IReservePrivilegedControlsSeatsCommand`, `ISendPrivilegedControlsSeatNeededEmailCommand` (`Bit.Core.Billing.Organizations.Commands`), `PrivilegedControlsSeatReservationResult` (`Bit.Core.Billing.Organizations.Models`) | The reserve and seat-needed email command contracts and the reservation result, which Core owns so Admin Console member actions can reserve seats before granting access and email Owners when a joining member is left without one; this library implements them | +| `PrivilegedControlsSeatLimitReachedError`, `CannotAutoscalePrivilegedControlsSeatsOnSelfHostError`, `PrivilegedControlsSeatExpansionFailedError`, `PrivilegedControlsSeatReservationUnexpectedError` (`Bit.Core.Billing.Organizations`), `Error` (`Bit.Core.AdminConsole.Utilities.v2`) | The errors a reservation returns, which Admin Console member actions pass straight to the client | | `ICountNewPamSeatsRequiredQuery` (`Bit.Core.Billing.Organizations.Queries`) | Checking a self-hosted license has the Privileged Controls seats a reservation needs | -| `OrganizationUser` (`Bit.Core.Entities`) | The joining member whose Privileged Controls access is kept or removed | | `IGlobalSettings` (`Bit.Core.Settings`), `CoreHelpers` (`Bit.Core.Utilities`) | Building the Owner email links and preventing auto-linking in email content | | `IOrganizationService` (`Bit.Core.Services`) | Saving the organization's Privileged Controls seats and flags after the first purchase, a seat change, or removal | -| `IOrganizationReplaceCommand` (`Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces`) | Saving the organization's Privileged Controls seat count after an autoscale or a revert | +| `IReplaceOrganizationAndUpdateCacheCommand` (`Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces`) | Saving the organization's Privileged Controls seat count after an autoscale or a revert | | `IOrganizationUserRepository` (`Bit.Core.Repositories`) | Counting the Privileged Controls seats in use before a seat decrease and before an autoscale; finding the Owners to email when the autoscale limit is reached or a joining member needs a seat | | `IMailer` (`Bit.Core.Platform.Mail.Mailer`), `PrivilegedControlsSeatLimitReachedMail`, `PrivilegedControlsSeatNeededMail` (`Bit.Core.Billing.Models.Mail.Mailer`) | Sending the Owners the Privileged Controls autoscale limit reached email, and the email about a joining member who needs a seat | | `ISubscriptionDiscountService`, `DiscountTierType` (`Bit.Core.Billing.Services`, `Bit.Core.Billing.Enums`) | Eligibility-checking Families purchase coupons | diff --git a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs index babe32bd0b63..07328eb71453 100644 --- a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs +++ b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Bit.Api.AdminConsole.Authorization; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Invoicing; @@ -45,11 +44,10 @@ public static IServiceCollection AddOrganizationSubscriptions(this IServiceColle } /// - /// Registers the Privileged Controls seat reserve, autoscale, and revert commands and the services they use. A - /// host that resolves , - /// , - /// , or - /// without mapping the endpoints calls this. + /// Registers the Privileged Controls seat reserve, autoscale, and revert commands, the seat-needed email command, + /// and the services they use. A host that resolves , + /// , , or + /// without mapping the endpoints calls this. /// public static IServiceCollection AddPrivilegedControlsSeatAutoscaling(this IServiceCollection services) { @@ -58,7 +56,7 @@ public static IServiceCollection AddPrivilegedControlsSeatAutoscaling(this IServ services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); return services; } } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs index c748a783b384..54debc33c04d 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs @@ -6,11 +6,11 @@ using Bit.Api.Models.Request; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -438,7 +438,7 @@ public async Task Put_GrantingPamAtSeatLimit_ReturnsBadRequestAndDoesNotGrant() UpdateRequest(accessPam: true)); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Contains(new PrivilegedControlsSeatLimitReached().Message, await response.Content.ReadAsStringAsync()); + Assert.Contains(new PrivilegedControlsSeatLimitReachedError().Message, await response.Content.ReadAsStringAsync()); await AssertAccessPamAsync(member, false); var reloadedOrganization = await _factory.GetService().GetByIdAsync(_organization.Id); Assert.Equal(1, reloadedOrganization!.PamSeats); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs index 1f92217af4cc..d6da603a625c 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs @@ -2,9 +2,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AcceptMembership; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -259,15 +261,11 @@ public async Task AcceptAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); stagedOrganizationUser.AccessPam = true; var memberEmail = stagedOrganizationUser.Email!; - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, stagedOrganizationUser) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return Task.FromResult(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); - }); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); var organizationUserRepository = sutProvider.GetDependency(); + var sendSeatNeededEmailCommand = sutProvider.GetDependency(); var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); @@ -278,7 +276,7 @@ public async Task AcceptAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( ou.Id == stagedOrganizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted && !ou.AccessPam)); - joiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); + sendSeatNeededEmailCommand.RunAsync(organization, memberEmail); }); } @@ -293,14 +291,10 @@ public async Task AcceptAsync_StagedMemberWithPam_ReservationFailed_JoinsWithout SetupHappyPath(organization, inviteLink, user, sutProvider); SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); stagedOrganizationUser.AccessPam = true; - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, stagedOrganizationUser) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return Task.FromResult(JoiningMemberPrivilegedControlsAccess.RemovedAfterError); - }); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), new TimeoutException("Stripe is unreachable."))); var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); @@ -308,7 +302,9 @@ public async Task AcceptAsync_StagedMemberWithPam_ReservationFailed_JoinsWithout await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(ou => ou.Id == stagedOrganizationUser.Id && !ou.AccessPam)); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); } [Theory, BitAutoData] @@ -322,25 +318,42 @@ public async Task AcceptAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( SetupHappyPath(organization, inviteLink, user, sutProvider); SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); stagedOrganizationUser.AccessPam = true; - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, stagedOrganizationUser) - .Returns(JoiningMemberPrivilegedControlsAccess.Kept); var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); Assert.True(result.IsSuccess); + await sutProvider.GetDependency().Received(1).RunAsync(organization, 1); await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(ou => ou.Id == stagedOrganizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted && ou.AccessPam)); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); + } + + [Theory, BitAutoData] + public async Task AcceptAsync_StagedMemberWithoutPam_ReservesNoPamSeat( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, sutProvider); + SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); + stagedOrganizationUser.AccessPam = false; + + var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency().Received(1).RunAsync(organization, 0); } [Theory, BitAutoData] - public async Task AcceptAsync_NotStaged_DoesNotCallJoiningCommand( + public async Task AcceptAsync_NotStaged_DoesNotReservePamSeat( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -358,9 +371,9 @@ public async Task AcceptAsync_NotStaged_DoesNotCallJoiningCommand( var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); Assert.True(result.IsSuccess); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .RunAsync(Arg.Any(), Arg.Any()); + .RunAsync(default!, default); } private static AcceptOrganizationInviteLinkRequest BuildRequest( @@ -732,6 +745,10 @@ private static void SetupHappyPath( SetupValidationResult(sutProvider, new AcceptInviteLinkMembershipValidationResult { Organization = org }); + sutProvider.GetDependency() + .RunAsync(org, Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); + sutProvider.GetDependency() .HasSecretsManagerStandalone(org) .Returns(false); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs index 4f975a37bb8f..abb90bfbdd2d 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -3,10 +3,12 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -331,15 +333,11 @@ public async Task ConfirmAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( SetupStagedMembership(user, stagedOrganizationUser); stagedOrganizationUser.AccessPam = true; var memberEmail = stagedOrganizationUser.Email!; - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, stagedOrganizationUser) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return Task.FromResult(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); - }); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); var organizationUserRepository = sutProvider.GetDependency(); + var sendSeatNeededEmailCommand = sutProvider.GetDependency(); var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); @@ -350,7 +348,7 @@ public async Task ConfirmAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( ou.Id == stagedOrganizationUser.Id && ou.Status == OrganizationUserStatusType.Confirmed && !ou.AccessPam)); - joiningMemberCommand.NotifyOwnersAsync(organization, memberEmail); + sendSeatNeededEmailCommand.RunAsync(organization, memberEmail); }); } @@ -365,14 +363,10 @@ public async Task ConfirmAsync_StagedMemberWithPam_ReservationFailed_JoinsWithou SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); SetupStagedMembership(user, stagedOrganizationUser); stagedOrganizationUser.AccessPam = true; - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, stagedOrganizationUser) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return Task.FromResult(JoiningMemberPrivilegedControlsAccess.RemovedAfterError); - }); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), new TimeoutException("Stripe is unreachable."))); var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); @@ -380,7 +374,9 @@ public async Task ConfirmAsync_StagedMemberWithPam_ReservationFailed_JoinsWithou await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(ou => ou.Id == stagedOrganizationUser.Id && !ou.AccessPam)); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); } [Theory, BitAutoData] @@ -394,25 +390,24 @@ public async Task ConfirmAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); SetupStagedMembership(user, stagedOrganizationUser); stagedOrganizationUser.AccessPam = true; - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, stagedOrganizationUser) - .Returns(JoiningMemberPrivilegedControlsAccess.Kept); var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); Assert.True(result.IsSuccess); + await sutProvider.GetDependency().Received(1).RunAsync(organization, 1); await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(ou => ou.Id == stagedOrganizationUser.Id && ou.Status == OrganizationUserStatusType.Confirmed && ou.AccessPam)); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); } [Theory, BitAutoData] - public async Task ConfirmAsync_NotStaged_DoesNotCallJoiningCommand( + public async Task ConfirmAsync_NotStaged_DoesNotReservePamSeat( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -424,9 +419,9 @@ public async Task ConfirmAsync_NotStaged_DoesNotCallJoiningCommand( var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); Assert.True(result.IsSuccess); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .RunAsync(Arg.Any(), Arg.Any()); + .RunAsync(default!, default); } [Theory, BitAutoData] @@ -598,6 +593,10 @@ private static void SetupValidatedConfirmation( .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) .Returns(new OrganizationSeatCounts { Users = 0, Sponsored = 0 }); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); + sutProvider.GetDependency() .GetAsync(user.Id) .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs index 7909c2132695..17f188bb799d 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.BulkEnablePam; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -31,6 +33,9 @@ public async Task BulkEnablePamAsync_GrantsAccessToMembersWithoutIt(Organization sutProvider.GetDependency().GetManyAsync(organizationUserIds) .Returns(organizationUsers); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -55,6 +60,9 @@ public async Task BulkEnablePamAsync_SkipsMembersOfOtherOrganizationsAndThoseWit sutProvider.GetDependency().GetManyAsync(organizationUserIds) .Returns([targetUser, alreadyEnabledUser, otherOrgUser]); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -133,12 +141,12 @@ public async Task BulkEnablePamAsync_WhenSelfHostedAndSeatsRequired_ReturnsCanno .Returns(organizationUsers); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); + .Returns(new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError())); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .ReplaceManyAsync(default!); @@ -161,7 +169,7 @@ public async Task BulkEnablePamAsync_WhenCloudAndSeatsRequired_Grants(Organizati .Returns(organizationUsers); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .Returns(PrivilegedControlsSeatReservation.Reserved); + .Returns(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -188,7 +196,7 @@ public async Task BulkEnablePamAsync_WhenSelfHostedAndNoSeatsRequired_Grants(Org .Returns(organizationUsers); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .Returns(PrivilegedControlsSeatReservation.Reserved); + .Returns(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -220,7 +228,7 @@ public async Task BulkEnablePamAsync_MixedStatuses_ReservesOnlySeatUsingMembers( .Returns(organizationUsers); sutProvider.GetDependency() .RunAsync(organization, 3) - .Returns(PrivilegedControlsSeatReservation.Reserved); + .Returns(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -252,12 +260,42 @@ public async Task BulkEnablePamAsync_AtLimit_ReturnsSeatLimitReachedAndSavesNoth .Returns(organizationUsers); sutProvider.GetDependency() .RunAsync(organization, organizationUsers.Count) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceManyAsync(default!); + } + + [Theory] + [BitAutoData] + public async Task BulkEnablePamAsync_ReservationFailsUnexpectedly_ReturnsTheReservationErrorAndSavesNothing( + Organization organization, List organizationUserIds, List organizationUsers, + SutProvider sutProvider) + { + organization.UsePam = true; + foreach (var organizationUser in organizationUsers) + { + organizationUser.OrganizationId = organization.Id; + organizationUser.AccessPam = false; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + } + + sutProvider.GetDependency().GetManyAsync(organizationUserIds) + .Returns(organizationUsers); + sutProvider.GetDependency() + .RunAsync(organization, organizationUsers.Count) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), new InvalidOperationException("Database down."))); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .ReplaceManyAsync(default!); @@ -281,7 +319,7 @@ public async Task BulkEnablePamAsync_OnlyRevokedAndStaged_ReservesZero(Organizat .Returns(organizationUsers); sutProvider.GetDependency() .RunAsync(organization, 0) - .Returns(PrivilegedControlsSeatReservation.Reserved); + .Returns(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 08f29c007b24..6ad3d47b9a1e 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -2,7 +2,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; @@ -11,6 +10,9 @@ using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -1269,12 +1271,12 @@ public async Task RestoreUser_Admin_MemberWithPam_AtLimit_ThrowsSeatLimitMessage RestoreUser_Setup(organization, owner, organizationUser, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); - Assert.Equal(new PrivilegedControlsSeatLimitReached().Message, exception.Message); + Assert.Equal(new PrivilegedControlsSeatLimitReachedError().Message, exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .RestoreAsync(Arg.Any(), Arg.Any()); @@ -1293,12 +1295,12 @@ public async Task RestoreUser_Admin_MemberWithPam_SelfHostedShortfall_ThrowsSelf RestoreUser_Setup(organization, owner, organizationUser, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); + .Returns(new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError())); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); - Assert.Equal(new CannotAutoscalePamSeatsOnSelfHost().Message, exception.Message); + Assert.Equal(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError().Message, exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .RestoreAsync(Arg.Any(), Arg.Any()); @@ -1318,7 +1320,7 @@ public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivesBackThePasswordMa RestoreUser_Setup(organization, owner, organizationUser, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); @@ -1332,7 +1334,7 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_ReservationThrows_GivesBackThePasswordManagerSeatAndRethrows( + public async Task RestoreUser_Admin_MemberWithPam_ReservationFails_GivesBackThePasswordManagerSeatAndThrowsBadRequest( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1345,15 +1347,41 @@ public async Task RestoreUser_Admin_MemberWithPam_ReservationThrows_GivesBackThe RestoreUser_Setup(organization, owner, organizationUser, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) - .ThrowsAsync(new GatewayException("Stripe rejected the update.")); + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatExpansionFailedError("Stripe rejected the update."), + new BadRequestException("Stripe rejected the update."))); - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Equal("Stripe rejected the update.", exception.Message); await sutProvider.GetDependency().Received(1).AdjustSeatsAsync(organization.Id, -1); } + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPam_ReservationFailsUnexpectedly_GivesBackThePasswordManagerSeatAndThrowsWithTheOriginalFailure( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organization.Seats = 1; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + var failure = new TimeoutException("Stripe is unreachable."); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatReservationUnexpectedError(), failure)); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Same(failure, exception.InnerException); + await sutProvider.GetDependency().Received(1).AdjustSeatsAsync(organization.Id, -1); + } + [Theory, BitAutoData] public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivingBackTheSeatFails_StillThrowsSeatLimitMessage( Organization organization, @@ -1368,7 +1396,7 @@ public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivingBackTheSeatFails RestoreUser_Setup(organization, owner, organizationUser, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); sutProvider.GetDependency() .AdjustSeatsAsync(organization.Id, -1) .ThrowsAsync(new GatewayException("Stripe is unavailable.")); @@ -1376,7 +1404,7 @@ public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivingBackTheSeatFails var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); - Assert.Equal(new PrivilegedControlsSeatLimitReached().Message, exception.Message); + Assert.Equal(new PrivilegedControlsSeatLimitReachedError().Message, exception.Message); } [Theory, BitAutoData] @@ -1393,7 +1421,7 @@ public async Task RestoreUser_Admin_MemberWithPam_AtLimitWithFreePasswordManager RestoreUser_Setup(organization, owner, organizationUser, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); @@ -1433,26 +1461,46 @@ public async Task RestoreUser_Scim_AtLimit_RestoresWithoutPamAndSaves( organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; organizationUser.Email = "member@example.com"; RestoreUser_Setup(organization, null, organizationUser, sutProvider); - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, organizationUser) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit; - }); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); var organizationUserRepository = sutProvider.GetDependency(); + var sendSeatNeededEmailCommand = sutProvider.GetDependency(); Received.InOrder(() => { organizationUserRepository.ReplaceAsync(Arg.Is(ou => ou.Id == organizationUser.Id && !ou.AccessPam)); organizationUserRepository.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); - joiningMemberCommand.NotifyOwnersAsync(organization, "member@example.com"); + sendSeatNeededEmailCommand.RunAsync(organization, "member@example.com"); }); } + [Theory, BitAutoData] + public async Task RestoreUser_Scim_SelfHostedShortfall_RestoresWithoutPamOrEmail( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.UsePam = true; + organizationUser.AccessPam = true; + organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + RestoreUser_Setup(organization, null, organizationUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError())); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); + + Assert.False(organizationUser.AccessPam); + await sutProvider.GetDependency().Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); + } + [Theory, BitAutoData] public async Task RestoreUser_Scim_AtLimit_LinkedMember_EmailsOwnersWithAccountEmail( Organization organization, @@ -1467,14 +1515,15 @@ public async Task RestoreUser_Scim_AtLimit_LinkedMember_EmailsOwnersWithAccountE organizationUser.UserId = user.Id; RestoreUser_Setup(organization, null, organizationUser, sutProvider); sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, organizationUser) - .Returns(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); - await joiningMemberCommand.Received(1).NotifyOwnersAsync(organization, user.Email); + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, user.Email); } [Theory, BitAutoData] @@ -1487,14 +1536,10 @@ public async Task RestoreUser_Scim_ReservationFailed_RestoresWithoutPamOrEmail( organizationUser.AccessPam = true; organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; RestoreUser_Setup(organization, null, organizationUser, sutProvider); - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, organizationUser) - .Returns(callInfo => - { - callInfo.Arg().AccessPam = false; - return JoiningMemberPrivilegedControlsAccess.RemovedAfterError; - }); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), new TimeoutException("Stripe is unreachable."))); await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); @@ -1502,7 +1547,9 @@ public async Task RestoreUser_Scim_ReservationFailed_RestoresWithoutPamOrEmail( await organizationUserRepository.Received(1) .ReplaceAsync(Arg.Is(ou => ou.Id == organizationUser.Id && !ou.AccessPam)); await organizationUserRepository.Received(1).RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); } [Theory, BitAutoData] @@ -1515,17 +1562,16 @@ public async Task RestoreUser_Scim_SeatAvailable_RestoresWithPam( organizationUser.AccessPam = true; organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; RestoreUser_Setup(organization, null, organizationUser, sutProvider); - var joiningMemberCommand = sutProvider.GetDependency(); - joiningMemberCommand - .RunAsync(organization, organizationUser) - .Returns(JoiningMemberPrivilegedControlsAccess.Kept); await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); var organizationUserRepository = sutProvider.GetDependency(); + await sutProvider.GetDependency().Received(1).RunAsync(organization, 1); await organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); await organizationUserRepository.Received(1).RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); - await joiningMemberCommand.DidNotReceiveWithAnyArgs().NotifyOwnersAsync(default!, default!); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); Assert.True(organizationUser.AccessPam); } @@ -1545,9 +1591,6 @@ public async Task RestoreUser_PublicApi_MemberWithPam_DoesNotTouchSeats( await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .RunAsync(default!, default); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RunAsync(default!, default!); Assert.True(organizationUser.AccessPam); } @@ -1605,12 +1648,12 @@ public async Task RestoreUsers_Bulk_AtLimit_ThrowsBeforeRestoringAnyone( .Returns([orgUser1]); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null)); - Assert.Equal(new PrivilegedControlsSeatLimitReached().Message, exception.Message); + Assert.Equal(new PrivilegedControlsSeatLimitReachedError().Message, exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .RestoreAsync(Arg.Any(), Arg.Any()); @@ -1640,7 +1683,7 @@ public async Task RestoreUsers_Bulk_AtLimit_GivesBackThePasswordManagerSeatsItBo .Returns(orgUsers); sutProvider.GetDependency() .RunAsync(organization, 2) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id], owner.Id, userService, null)); @@ -1654,7 +1697,7 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task RestoreUsers_Bulk_ReservationThrows_GivesBackThePasswordManagerSeatsAndRethrows( + public async Task RestoreUsers_Bulk_ReservationFailsUnexpectedly_GivesBackThePasswordManagerSeatsAndThrows( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, @@ -1671,9 +1714,10 @@ public async Task RestoreUsers_Bulk_ReservationThrows_GivesBackThePasswordManage .Returns([orgUser1]); sutProvider.GetDependency() .RunAsync(organization, 1) - .ThrowsAsync(new GatewayException("Stripe rejected the update.")); + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), new TimeoutException("Stripe is unreachable."))); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null)); await sutProvider.GetDependency().Received(1).AdjustSeatsAsync(organization.Id, -1); @@ -1697,7 +1741,7 @@ public async Task RestoreUsers_Bulk_AtLimitWithFreePasswordManagerSeats_GivesNot .Returns([orgUser1]); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null)); @@ -1726,6 +1770,9 @@ private static void RestoreUser_Setup( Sponsored = 0, Users = 1 }); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner); sutProvider.GetDependency().OrganizationAdmin(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index bc31059e5114..f9abb0b1f0ce 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -2,12 +2,13 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations; using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -76,6 +77,9 @@ private static InviteStagedOrganizationUsersRequest Arrange( sutProvider.GetDependency() .GetManyAsync(Arg.Any>()) .Returns(organizationUsers); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); return new InviteStagedOrganizationUsersRequest { @@ -744,7 +748,7 @@ private static void AutoscalePamSeatsTo( .Returns(callInfo => { organization.PamSeats = newPamSeats; - return PrivilegedControlsSeatReservation.Reserved; + return new PrivilegedControlsSeatReservationResult(); }); [Theory, BitAutoData] @@ -792,11 +796,11 @@ public async Task RunAsync_PamAtLimit_ReturnsSeatLimitReachedAndReleasesPassword ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); var result = await sutProvider.Sut.RunAsync(request); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .Received(1) .AdjustSeatsAsync(organization.Id, -(organizationUsers.Count - 1)); @@ -813,36 +817,33 @@ public async Task RunAsync_PamAtLimit_ReleasesSecretsManagerSeats( ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); var result = await sutProvider.Sut.RunAsync(request); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .Received(1) .UpdateSubscriptionAsync(Arg.Is(update => update.SmSeats == 5)); } - [Theory] - [BitAutoData(typeof(BadRequestException))] - [BitAutoData(typeof(GatewayException))] + [Theory, BitAutoData] public async Task RunAsync_PamReservationFails_ReturnsSeatExpansionFailedAndReleasesPasswordManagerSeats( - Type exceptionType, Organization organization, List organizationUsers, Guid performedBy) + Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); organization.MaxAutoscaleSeats = 100; var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 9); ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); - Exception failure = exceptionType == typeof(BadRequestException) - ? new BadRequestException("Stripe rejected the subscription change.") - : new GatewayException("Stripe rejected the subscription change."); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .ThrowsAsync(failure); + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatExpansionFailedError("Stripe rejected the subscription change."), + new BadRequestException("Stripe rejected the subscription change."))); var result = await sutProvider.Sut.RunAsync(request); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); Assert.Equal("Stripe rejected the subscription change.", result.AsError.Message); await sutProvider.GetDependency() .Received(1) @@ -850,7 +851,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamReservationThrowsConflict_ReleasesPasswordManagerSeatsAndRethrows( + public async Task RunAsync_PamReservationFailsUnexpectedly_ReturnsInternalErrorAndReleasesPasswordManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -859,10 +860,13 @@ public async Task RunAsync_PamReservationThrowsConflict_ReleasesPasswordManagerS ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .ThrowsAsync(new ConflictException("The subscription is unpaid.")); + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), + new ConflictException("The subscription is unpaid."))); - await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + var result = await sutProvider.Sut.RunAsync(request); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .Received(1) .AdjustSeatsAsync(organization.Id, -(organizationUsers.Count - 1)); @@ -871,7 +875,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamReservationThrowsConflict_ReleasesSecretsManagerSeatsAndRethrows( + public async Task RunAsync_PamReservationFailsUnexpectedly_ReleasesSecretsManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -879,15 +883,42 @@ public async Task RunAsync_PamReservationThrowsConflict_ReleasesSecretsManagerSe ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .ThrowsAsync(new ConflictException("The subscription is unpaid.")); + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), + new ConflictException("The subscription is unpaid."))); - await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + var result = await sutProvider.Sut.RunAsync(request); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .Received(1) .UpdateSubscriptionAsync(Arg.Is(update => update.SmSeats == 5)); } + [Theory, BitAutoData] + public async Task RunAsync_PamReservationFailsAfterSeatsChanged_RevertsPamSeats( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = Arrange(sutProvider, organization, organizationUsers, performedBy); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(_ => + { + organization.PamSeats = 7; + return new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), + new InvalidOperationException("The save failed.")); + }); + + await sutProvider.Sut.RunAsync(request); + + await sutProvider.GetDependency() + .Received(1) + .Run(organization, 5); + } + [Theory, BitAutoData] public async Task RunAsync_PamSelfHostedShortfall_ReturnsCannotAutoscaleOnSelfHost( Organization organization, List organizationUsers, Guid performedBy) @@ -897,11 +928,11 @@ public async Task RunAsync_PamSelfHostedShortfall_ReturnsCannotAutoscaleOnSelfHo ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2); sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) - .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); + .Returns(new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError())); var result = await sutProvider.Sut.RunAsync(request); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await AssertNothingHappenedAsync(sutProvider); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index dd71bf993fbc..34342cdc6ece 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -1,11 +1,13 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.UserFeatures.UserEmail; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -115,12 +117,12 @@ public async Task UpdateUserAsync_WhenGrantingPamOnSelfHostWithoutSeats_ReturnsC var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); + .Returns(new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError())); var result = await sutProvider.Sut.UpdateUserAsync(request); Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .ReplaceAsync(Arg.Any(), Arg.Any>()); @@ -137,7 +139,7 @@ public async Task UpdateUserAsync_WhenGrantingPamOnCloudWithoutSeats_PersistsAcc var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.Reserved); + .Returns(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.UpdateUserAsync(request); @@ -159,7 +161,7 @@ public async Task UpdateUserAsync_WhenGrantingPamOnSelfHostWithSeatsAvailable_Pe var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.Reserved); + .Returns(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.UpdateUserAsync(request); @@ -199,12 +201,37 @@ public async Task UpdateUserAsync_EnablingPam_AtLimit_ReturnsSeatLimitReachedAnd var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); var result = await sutProvider.Sut.UpdateUserAsync(request); Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any(), Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_EnablingPam_ReservationFails_ReturnsTheReservationErrorAndDoesNotSave( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = false; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatExpansionFailedError("No subscription found."), + new BadRequestException("No subscription found."))); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsError); + var error = Assert.IsType(result.AsError); + Assert.Equal("No subscription found.", error.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .ReplaceAsync(Arg.Any(), Arg.Any>()); @@ -641,6 +668,10 @@ private static UpdateOrganizationUserRequest Setup( ? ValidationResultHelpers.Valid(ci.Arg()) : ValidationResultHelpers.Invalid(ci.Arg(), new MustHaveConfirmedOwner())); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); + return new UpdateOrganizationUserRequest( organizationUser, organization, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommandTests.cs similarity index 68% rename from test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommandTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommandTests.cs index 45b7d4d7059e..59698ca09a1c 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationReplaceCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommandTests.cs @@ -13,14 +13,14 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; [SutProviderCustomize] -public class OrganizationReplaceCommandTests +public class ReplaceOrganizationAndUpdateCacheCommandTests { [Theory, BitAutoData] - public async Task ReplaceAndUpdateCacheAsync_SavesThenRefreshesTheCache( + public async Task RunAsync_SavesThenRefreshesTheCache( Organization organization, - SutProvider sutProvider) + SutProvider sutProvider) { - await sutProvider.Sut.ReplaceAndUpdateCacheAsync(organization); + await sutProvider.Sut.RunAsync(organization); var organizationRepository = sutProvider.GetDependency(); var organizationAbilityCacheService = sutProvider.GetDependency(); @@ -32,11 +32,11 @@ public async Task ReplaceAndUpdateCacheAsync_SavesThenRefreshesTheCache( } [Theory, BitAutoData] - public async Task ReplaceAndUpdateCacheAsync_NoEvent_LogsNothing( + public async Task RunAsync_NoEvent_LogsNothing( Organization organization, - SutProvider sutProvider) + SutProvider sutProvider) { - await sutProvider.Sut.ReplaceAndUpdateCacheAsync(organization); + await sutProvider.Sut.RunAsync(organization); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -44,11 +44,11 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task ReplaceAndUpdateCacheAsync_WithEvent_LogsTheEvent( + public async Task RunAsync_WithEvent_LogsTheEvent( Organization organization, - SutProvider sutProvider) + SutProvider sutProvider) { - await sutProvider.Sut.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + await sutProvider.Sut.RunAsync(organization, EventType.Organization_Updated); await sutProvider.GetDependency() .Received(1) @@ -56,9 +56,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task ReplaceAndUpdateCacheAsync_SaveFails_RethrowsWithoutRefreshingTheCache( + public async Task RunAsync_SaveFails_RethrowsWithoutRefreshingTheCache( Organization organization, - SutProvider sutProvider) + SutProvider sutProvider) { var saveFailure = new InvalidOperationException("The save failed."); sutProvider.GetDependency() @@ -66,7 +66,7 @@ public async Task ReplaceAndUpdateCacheAsync_SaveFails_RethrowsWithoutRefreshing .ThrowsAsync(saveFailure); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ReplaceAndUpdateCacheAsync(organization)); + () => sutProvider.Sut.RunAsync(organization)); Assert.Same(saveFailure, exception); await sutProvider.GetDependency() diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 511af7abe3a4..26f64f3fba03 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -6,10 +6,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; @@ -1264,7 +1264,7 @@ public async Task UpdateAsync_WhenValidOrganization_AndUpdateBillingIsTrue_Updat { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationReplaceCommand = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var stripeAdapter = sutProvider.GetDependency(); var requestOptionsReturned = new CustomerUpdateOptions @@ -1303,9 +1303,9 @@ await stripeAdapter && options.Description == requestOptionsReturned.Description && options.InvoiceSettings.CustomFields.First().Name == requestOptionsReturned.InvoiceSettings.CustomFields.First().Name && options.InvoiceSettings.CustomFields.First().Value == requestOptionsReturned.InvoiceSettings.CustomFields.First().Value)); ; - await organizationReplaceCommand + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + .RunAsync(organization, EventType.Organization_Updated); } [Theory, BitAutoData] @@ -1313,7 +1313,7 @@ public async Task UpdateAsync_WhenValidOrganization_AndUpdateBillingIsFalse_Upda { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationReplaceCommand = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var stripeAdapter = sutProvider.GetDependency(); organizationRepository @@ -1330,9 +1330,9 @@ await organizationRepository await stripeAdapter .DidNotReceiveWithAnyArgs() .UpdateCustomerAsync(Arg.Any(), Arg.Any()); - await organizationReplaceCommand + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + .RunAsync(organization, EventType.Organization_Updated); } [Theory, BitAutoData] @@ -2046,7 +2046,7 @@ public async Task InviteUser_StagedUserWithPrivilegedControls_ReservesASeatBefor { InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); var reserveCommand = sutProvider.GetDependency(); - reserveCommand.RunAsync(organization, 1).Returns(PrivilegedControlsSeatReservation.Reserved); + reserveCommand.RunAsync(organization, 1).Returns(new PrivilegedControlsSeatReservationResult()); await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); @@ -2072,7 +2072,7 @@ public async Task InviteUser_StagedUserWithPrivilegedControls_ReservesAfterSecre sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) .Returns(MockPlans.Get(organization.PlanType)); var reserveCommand = sutProvider.GetDependency(); - reserveCommand.RunAsync(organization, 1).Returns(PrivilegedControlsSeatReservation.Reserved); + reserveCommand.RunAsync(organization, 1).Returns(new PrivilegedControlsSeatReservationResult()); await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); @@ -2128,13 +2128,13 @@ public async Task InviteUser_StagedUserWithPrivilegedControls_AtLimit_ThrowsSeat InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); Assert.Contains(exception.InnerExceptions, - e => e is BadRequestException && e.Message == new PrivilegedControlsSeatLimitReached().Message); + e => e is BadRequestException && e.Message == new PrivilegedControlsSeatLimitReachedError().Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendInvitesAsync(default!); @@ -2153,13 +2153,57 @@ public async Task InviteUser_StagedUserWithPrivilegedControls_SelfHostedShortfal InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.SelfHostedShortfall); + .Returns(new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError())); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); Assert.Contains(exception.InnerExceptions, - e => e is BadRequestException && e.Message == new CannotAutoscalePamSeatsOnSelfHost().Message); + e => e is BadRequestException && e.Message == new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError().Message); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_ReservationFails_ThrowsTheFailureMessageWithoutInviting( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatExpansionFailedError("No subscription found."), + new BadRequestException("No subscription found."))); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut + .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); + + Assert.Contains(exception.InnerExceptions, + e => e is BadRequestException && e.Message == "No subscription found."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendInvitesAsync(default!); + } + + [Theory] + [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] + public async Task InviteUser_StagedUserWithPrivilegedControls_ReservationFailsUnexpectedly_ThrowsWithTheOriginalFailure( + Organization organization, OrganizationUserInvite invite, string externalId, OrganizationUser invitor, + OrganizationUser stagedUser, SutProvider sutProvider) + { + InviteUser_ArrangeStagedUserWithPrivilegedControls(organization, invite, stagedUser, sutProvider); + var failure = new TimeoutException("Stripe is unreachable."); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatReservationUnexpectedError(), failure)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut + .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); + + Assert.Contains(exception.InnerExceptions, + e => e is InvalidOperationException && ReferenceEquals(e.InnerException, failure)); } [Theory] @@ -2173,7 +2217,7 @@ public async Task InviteUser_StagedUserWithPrivilegedControls_InviteFailsAfterRe organization.PamSeats = 5; sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.Reserved) + .Returns(new PrivilegedControlsSeatReservationResult()) .AndDoes(_ => organization.PamSeats = 6); sutProvider.GetDependency() .SendInvitesAsync(Arg.Any()) @@ -2198,7 +2242,7 @@ public async Task InviteUser_StagedUserWithPrivilegedControls_GivingTheSeatsBack organization.PamSeats = 5; sutProvider.GetDependency() .RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.Reserved) + .Returns(new PrivilegedControlsSeatReservationResult()) .AndDoes(_ => organization.PamSeats = 6); sutProvider.GetDependency() .SendInvitesAsync(Arg.Any()) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/PrivilegedControlsSeatOccupancyTests.cs b/test/Core.Test/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancyTests.cs similarity index 75% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/PrivilegedControlsSeatOccupancyTests.cs rename to test/Core.Test/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancyTests.cs index 5cb834612565..b97a446c90a0 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PrivilegedControlsSeats/PrivilegedControlsSeatOccupancyTests.cs +++ b/test/Core.Test/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancyTests.cs @@ -1,8 +1,8 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Enums; using Xunit; -namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +namespace Bit.Core.Test.Billing.Organizations.Helpers; public class PrivilegedControlsSeatOccupancyTests { diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index 58e74249bf5a..ae19a2a851fa 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs @@ -32,7 +32,8 @@ public class AutoscalePrivilegedControlsSeatsCommandTests Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); - private readonly IOrganizationReplaceCommand _organizationReplaceCommand = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = + Substitute.For(); private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); private readonly IMailer _mailer = Substitute.For(); @@ -45,7 +46,7 @@ public AutoscalePrivilegedControlsSeatsCommandTests() => _sut = new AutoscalePrivilegedControlsSeatsCommand( _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, - _organizationReplaceCommand, + _replaceOrganizationAndUpdateCacheCommand, _organizationUserRepository, _isOnTrialQuery, _mailer, @@ -113,8 +114,8 @@ public async Task Run_NoGatewaySubscriptionAndSeatsShort_ThrowsBadRequestAndChan Assert.Equal("No subscription found.", exception.Message); Assert.Equal(_boughtSeats, organization.PamSeats); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); - await _organizationReplaceCommand.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Theory] @@ -131,8 +132,8 @@ public async Task Run_NoGatewaySubscriptionAndSeatsAlreadyCovered_SucceedsWithou Assert.True(result.Success); Assert.Equal(_boughtSeats, organization.PamSeats); - await _organizationReplaceCommand.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -146,7 +147,7 @@ public async Task Run_OnTrial_SeatsShort_WithinLimit_SavesSeatsWithoutTouchingSt Assert.True(result.Success); Assert.Equal(7, organization.PamSeats); - await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); @@ -163,8 +164,8 @@ public async Task Run_OnTrial_PastLimit_ReturnsLimitWithoutSaving() Assert.False(result.Success); Assert.Equal(6, result.MaxAutoscaleSeats); - await _organizationReplaceCommand.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -203,8 +204,8 @@ public async Task Run_BoughtSeatsAlreadyCoverTheTotal_SucceedsWithoutChangingAny await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationReplaceCommand.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -255,7 +256,7 @@ public async Task Run_TotalExceedsBoughtSeats_SavesTheNewSeatCount() Assert.Equal(15, organization.PamSeats); Assert.Equal(20, organization.MaxAutoscalePamSeats); - await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] @@ -294,8 +295,8 @@ public async Task Run_TotalExceedsTheLimit_ChangesSavesAndEmailsNothing() await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationReplaceCommand.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); await _mailer.DidNotReceive().SendEmail(Arg.Any()); } @@ -356,7 +357,7 @@ public async Task Run_EmailFails_StillSucceedsAndKeepsTheSavedSeats() Assert.True(result.Success); Assert.Equal(15, organization.PamSeats); - await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] @@ -371,8 +372,8 @@ public async Task Run_SeatChangeStepRejectsTheRequest_ThrowsItsExceptionAndChang Assert.Equal("Organization's plan does not support Privileged Controls.", exception.Message); Assert.Equal(_boughtSeats, organization.PamSeats); - await _organizationReplaceCommand.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -388,8 +389,8 @@ public async Task Run_SubscriptionUpdateRejected_ThrowsBadRequestAndLeavesTheOrg Assert.Equal("Your card was declined.", exception.Message); Assert.Equal(_boughtSeats, organization.PamSeats); - await _organizationReplaceCommand.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); await _mailer.DidNotReceive().SendEmail(Arg.Any()); } diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs deleted file mode 100644 index 767aacd62441..000000000000 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatForJoiningMemberCommandTests.cs +++ /dev/null @@ -1,179 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; -using Bit.Core.Billing.Models.Mail.Mailer; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Exceptions; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Platform.Mail.Mailer; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Bit.Subscriptions.Organization.Commands.PrivilegedControls; -using Bitwarden.Server.Sdk.Environment; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; -using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; - -namespace Bit.Subscriptions.Organization.Test.Commands; - -public class ReservePrivilegedControlsSeatForJoiningMemberCommandTests -{ - private const string _memberEmail = "member@example.com"; - - private readonly IReservePrivilegedControlsSeatsCommand _reservePrivilegedControlsSeatsCommand = - Substitute.For(); - private readonly IOrganizationUserRepository _organizationUserRepository = - Substitute.For(); - private readonly IMailer _mailer = Substitute.For(); - private readonly GlobalSettings _globalSettings = new(); - private readonly IBitwardenEnvironment _environment = Substitute.For(); - private readonly ReservePrivilegedControlsSeatForJoiningMemberCommand _sut; - - public ReservePrivilegedControlsSeatForJoiningMemberCommandTests() => - _sut = new ReservePrivilegedControlsSeatForJoiningMemberCommand( - _reservePrivilegedControlsSeatsCommand, - _organizationUserRepository, - _mailer, - _globalSettings, - _environment, - new RecordingLogger()); - - [Fact] - public async Task RunAsync_MemberWithoutAccess_KeepsWithoutReserving() - { - var organization = CreateOrganization(); - var organizationUser = CreateOrganizationUser(accessPam: false); - - var result = await _sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); - await _reservePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); - } - - [Fact] - public async Task RunAsync_PamOff_KeepsAccessWithoutReserving() - { - var organization = CreateOrganization(usePam: false); - var organizationUser = CreateOrganizationUser(); - - var result = await _sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); - Assert.True(organizationUser.AccessPam); - await _reservePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); - } - - [Fact] - public async Task RunAsync_Reserved_KeepsAccess() - { - var organization = CreateOrganization(); - var organizationUser = CreateOrganizationUser(); - _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1).Returns(PrivilegedControlsSeatReservation.Reserved); - - var result = await _sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.Kept, result); - Assert.True(organizationUser.AccessPam); - } - - [Theory] - [InlineData(PrivilegedControlsSeatReservation.LimitReached)] - [InlineData(PrivilegedControlsSeatReservation.SelfHostedShortfall)] - public async Task RunAsync_NoSeatAvailable_RemovesAccessAtSeatLimit(PrivilegedControlsSeatReservation reservation) - { - var organization = CreateOrganization(); - var organizationUser = CreateOrganizationUser(); - _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1).Returns(reservation); - - var result = await _sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.RemovedAtSeatLimit, result); - Assert.False(organizationUser.AccessPam); - } - - [Fact] - public async Task RunAsync_ReservationThrows_RemovesAccessAfterError() - { - var organization = CreateOrganization(); - var organizationUser = CreateOrganizationUser(); - _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1) - .ThrowsAsync(new GatewayException("Stripe is down.")); - - var result = await _sut.RunAsync(organization, organizationUser); - - Assert.Equal(JoiningMemberPrivilegedControlsAccess.RemovedAfterError, result); - Assert.False(organizationUser.AccessPam); - } - - [Fact] - public async Task RunAsync_NoSeatAvailable_SendsNoEmail() - { - var organization = CreateOrganization(); - var organizationUser = CreateOrganizationUser(); - _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1) - .Returns(PrivilegedControlsSeatReservation.LimitReached); - - await _sut.RunAsync(organization, organizationUser); - - await _mailer.DidNotReceiveWithAnyArgs().SendEmail(default!); - } - - [Fact] - public async Task NotifyOwnersAsync_Cloud_EmailsEachOwnerOnce() - { - var organization = CreateOrganization(); - SetUpOwners(organization, ["owner1@example.com", "owner2@example.com", "owner1@example.com"]); - - await _sut.NotifyOwnersAsync(organization, _memberEmail); - - await _mailer.Received(1).SendEmail(Arg.Is(mail => - mail.ToEmails.SequenceEqual(new[] { "owner1@example.com", "owner2@example.com" }) - && mail.View.MemberEmail == CoreHelpers.PreventEmailAutoLinking(_memberEmail) - && mail.View.OrganizationId == organization.Id)); - } - - [Fact] - public async Task NotifyOwnersAsync_SelfHosted_SendsNoEmail() - { - var organization = CreateOrganization(); - SetUpOwners(organization, ["owner@example.com"]); - _environment.SelfHosted.Returns(true); - - await _sut.NotifyOwnersAsync(organization, _memberEmail); - - await _mailer.DidNotReceiveWithAnyArgs().SendEmail(default!); - } - - [Fact] - public async Task NotifyOwnersAsync_EmailFails_DoesNotThrow() - { - var organization = CreateOrganization(); - SetUpOwners(organization, ["owner@example.com"]); - _mailer.SendEmail(default!) - .ThrowsAsyncForAnyArgs(new Exception("smtp down")); - - var exception = await Record.ExceptionAsync(() => _sut.NotifyOwnersAsync(organization, _memberEmail)); - - Assert.Null(exception); - } - - private void SetUpOwners(OrganizationEntity organization, string[] ownerEmails) => - _organizationUserRepository - .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) - .Returns(ownerEmails.Select(email => new OrganizationUserUserDetails { Email = email }).ToList()); - - private static OrganizationEntity CreateOrganization(bool usePam = true) => new() - { - Id = Guid.NewGuid(), - Name = "Acme.Corp", - UsePam = usePam, - PamSeats = 5 - }; - - private static OrganizationUser CreateOrganizationUser(bool accessPam = true) => new() - { - Id = Guid.NewGuid(), - AccessPam = accessPam - }; -} diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs index 43fe0f21cfd5..92caa7142529 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs @@ -1,10 +1,12 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; +using Bit.Core.Billing.Organizations; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Exceptions; using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; @@ -17,51 +19,78 @@ public class ReservePrivilegedControlsSeatsCommandTests private readonly IAutoscalePrivilegedControlsSeatsCommand _autoscalePrivilegedControlsSeatsCommand = Substitute.For(); private readonly IBitwardenEnvironment _environment = Substitute.For(); + private readonly RecordingLogger _logger = new(); private readonly ReservePrivilegedControlsSeatsCommand _sut; public ReservePrivilegedControlsSeatsCommandTests() => _sut = new ReservePrivilegedControlsSeatsCommand( _countNewPamSeatsRequiredQuery, _autoscalePrivilegedControlsSeatsCommand, - _environment); + _environment, + _logger); [Theory] [InlineData(0)] [InlineData(-1)] - public async Task RunAsync_NoMembers_ReturnsReservedWithoutCallingAutoscale(int membersTakingSeats) + public async Task RunAsync_NoMembers_SucceedsWithoutCallingAutoscale(int membersTakingSeats) { var organization = CreateOrganization(); var result = await _sut.RunAsync(organization, membersTakingSeats); - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + Assert.True(result.Success); await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); } [Fact] - public async Task RunAsync_PamOff_ReturnsReservedWithoutCallingAutoscale() + public async Task RunAsync_PamOff_SucceedsWithoutCallingAutoscale() { var organization = CreateOrganization(usePam: false); var result = await _sut.RunAsync(organization, 2); - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + Assert.True(result.Success); await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); } [Theory] - [InlineData(null)] - [InlineData(0)] - public async Task RunAsync_NoPamSeats_ReturnsReservedWithoutCallingAutoscale(int? pamSeats) + [InlineData(false)] + [InlineData(true)] + public async Task RunAsync_NullPamSeats_SucceedsWithoutCheckingSeats(bool selfHosted) + { + var organization = CreateOrganization(pamSeats: null); + _environment.SelfHosted.Returns(selfHosted); + + var result = await _sut.RunAsync(organization, 2); + + Assert.True(result.Success); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + await _countNewPamSeatsRequiredQuery.DidNotReceiveWithAnyArgs().CountNewPamSeatsRequiredAsync(default!, default); + } + + [Fact] + public async Task RunAsync_Cloud_ZeroPamSeats_ReturnsLimitReachedWithoutCallingAutoscale() { - var organization = CreateOrganization(pamSeats: pamSeats); + var organization = CreateOrganization(pamSeats: 0); var result = await _sut.RunAsync(organization, 2); - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + Assert.IsType(result.Error); await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); } + [Fact] + public async Task RunAsync_SelfHosted_ZeroPamSeats_ReturnsSelfHostedShortfall() + { + var organization = CreateOrganization(pamSeats: 0); + _environment.SelfHosted.Returns(true); + _countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, 2).Returns(2); + + var result = await _sut.RunAsync(organization, 2); + + Assert.IsType(result.Error); + } + [Fact] public async Task RunAsync_SelfHosted_Shortfall_ReturnsSelfHostedShortfall() { @@ -71,12 +100,12 @@ public async Task RunAsync_SelfHosted_Shortfall_ReturnsSelfHostedShortfall() var result = await _sut.RunAsync(organization, 2); - Assert.Equal(PrivilegedControlsSeatReservation.SelfHostedShortfall, result); + Assert.IsType(result.Error); await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); } [Fact] - public async Task RunAsync_SelfHosted_NoShortfall_ReturnsReserved() + public async Task RunAsync_SelfHosted_NoShortfall_Succeeds() { var organization = CreateOrganization(); _environment.SelfHosted.Returns(true); @@ -84,11 +113,11 @@ public async Task RunAsync_SelfHosted_NoShortfall_ReturnsReserved() var result = await _sut.RunAsync(organization, 2); - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + Assert.True(result.Success); } [Fact] - public async Task RunAsync_Cloud_AutoscaleSucceeds_ReturnsReserved() + public async Task RunAsync_Cloud_AutoscaleSucceeds_Succeeds() { var organization = CreateOrganization(); _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3) @@ -96,7 +125,7 @@ public async Task RunAsync_Cloud_AutoscaleSucceeds_ReturnsReserved() var result = await _sut.RunAsync(organization, 3); - Assert.Equal(PrivilegedControlsSeatReservation.Reserved, result); + Assert.True(result.Success); await _autoscalePrivilegedControlsSeatsCommand.Received(1).Run(organization, 3); } @@ -109,7 +138,38 @@ public async Task RunAsync_Cloud_AutoscaleAtLimit_ReturnsLimitReached() var result = await _sut.RunAsync(organization, 3); - Assert.Equal(PrivilegedControlsSeatReservation.LimitReached, result); + Assert.IsType(result.Error); + } + + [Fact] + public async Task RunAsync_AutoscaleThrowsBadRequest_ReturnsExpansionFailedWithTheMessageAndLogsAWarning() + { + var organization = CreateOrganization(); + var exception = new BadRequestException("No subscription found."); + _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3).ThrowsAsync(exception); + + var result = await _sut.RunAsync(organization, 3); + + var error = Assert.IsType(result.Error); + Assert.Equal("No subscription found.", error.Message); + Assert.Same(exception, result.Exception); + Assert.Single(_logger.Warnings); + Assert.Empty(_logger.Errors); + } + + [Fact] + public async Task RunAsync_AutoscaleThrowsUnexpectedly_ReturnsUnexpectedErrorAndLogsAnError() + { + var organization = CreateOrganization(); + var exception = new ConflictException("The subscription is invalid."); + _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3).ThrowsAsync(exception); + + var result = await _sut.RunAsync(organization, 3); + + Assert.IsType(result.Error); + Assert.Same(exception, result.Exception); + Assert.Single(_logger.Errors); + Assert.Empty(_logger.Warnings); } private static OrganizationEntity CreateOrganization(bool usePam = true, int? pamSeats = 5) => new() diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs index 26674b74333f..afe8de879a42 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs @@ -19,7 +19,8 @@ public class RevertPrivilegedControlsSeatsCommandTests Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); - private readonly IOrganizationReplaceCommand _organizationReplaceCommand = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = + Substitute.For(); private readonly FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = new(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly RevertPrivilegedControlsSeatsCommand _sut; @@ -28,7 +29,7 @@ public RevertPrivilegedControlsSeatsCommandTests() => _sut = new RevertPrivilegedControlsSeatsCommand( _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, - _organizationReplaceCommand, + _replaceOrganizationAndUpdateCacheCommand, _isOnTrialQuery, _environment); @@ -42,7 +43,7 @@ public async Task Run_SeatsUnchanged_DoesNothing() await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationReplaceCommand.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -54,7 +55,7 @@ public async Task Run_OnTrial_ResetsSeatsOnOrganizationOnly() await _sut.Run(organization, 5); Assert.Equal(5, organization.PamSeats); - await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); @@ -79,7 +80,7 @@ public async Task Run_Paid_UpdatesSubscriptionThenSaves() await _seatChangeSetFactory.Received(1).CreateAsync(organization, 5); await _updateOrganizationSubscriptionCommand.Received(1).Run(organization, changeSet); Assert.Equal(5, organization.PamSeats); - await _organizationReplaceCommand.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/SendPrivilegedControlsSeatNeededEmailCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/SendPrivilegedControlsSeatNeededEmailCommandTests.cs new file mode 100644 index 000000000000..6f5831c860c8 --- /dev/null +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/SendPrivilegedControlsSeatNeededEmailCommandTests.cs @@ -0,0 +1,87 @@ +using Bit.Core.Billing.Models.Mail.Mailer; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; +using Bitwarden.Server.Sdk.Environment; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Subscriptions.Organization.Test.Commands; + +public class SendPrivilegedControlsSeatNeededEmailCommandTests +{ + private const string _memberEmail = "member@example.com"; + + private readonly IOrganizationUserRepository _organizationUserRepository = + Substitute.For(); + private readonly IMailer _mailer = Substitute.For(); + private readonly GlobalSettings _globalSettings = new(); + private readonly IBitwardenEnvironment _environment = Substitute.For(); + private readonly SendPrivilegedControlsSeatNeededEmailCommand _sut; + + public SendPrivilegedControlsSeatNeededEmailCommandTests() => + _sut = new SendPrivilegedControlsSeatNeededEmailCommand( + _organizationUserRepository, + _mailer, + _globalSettings, + _environment, + new RecordingLogger()); + + [Fact] + public async Task RunAsync_Cloud_EmailsEachOwnerOnce() + { + var organization = CreateOrganization(); + SetUpOwners(organization, ["owner1@example.com", "owner2@example.com", "owner1@example.com"]); + + await _sut.RunAsync(organization, _memberEmail); + + await _mailer.Received(1).SendEmail(Arg.Is(mail => + mail.ToEmails.SequenceEqual(new[] { "owner1@example.com", "owner2@example.com" }) + && mail.View.MemberEmail == CoreHelpers.PreventEmailAutoLinking(_memberEmail) + && mail.View.OrganizationId == organization.Id)); + } + + [Fact] + public async Task RunAsync_SelfHosted_SendsNoEmail() + { + var organization = CreateOrganization(); + SetUpOwners(organization, ["owner@example.com"]); + _environment.SelfHosted.Returns(true); + + await _sut.RunAsync(organization, _memberEmail); + + await _mailer.DidNotReceiveWithAnyArgs().SendEmail(default!); + } + + [Fact] + public async Task RunAsync_EmailFails_DoesNotThrow() + { + var organization = CreateOrganization(); + SetUpOwners(organization, ["owner@example.com"]); + _mailer.SendEmail(default!) + .ThrowsAsyncForAnyArgs(new Exception("smtp down")); + + var exception = await Record.ExceptionAsync(() => _sut.RunAsync(organization, _memberEmail)); + + Assert.Null(exception); + } + + private void SetUpOwners(OrganizationEntity organization, string[] ownerEmails) => + _organizationUserRepository + .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) + .Returns(ownerEmails.Select(email => new OrganizationUserUserDetails { Email = email }).ToList()); + + private static OrganizationEntity CreateOrganization() => new() + { + Id = Guid.NewGuid(), + Name = "Acme.Corp", + UsePam = true, + PamSeats = 5 + }; +} diff --git a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs index 9efd9c08b0a7..b77a90263b93 100644 --- a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.PrivilegedControlsSeats; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Pricing; @@ -118,12 +117,12 @@ public void AddOrganizationSubscriptions_RegistersRemovePrivilegedControlsHandle } [Fact] - public void AddPrivilegedControlsSeatAutoscaling_ResolvesReserveAutoscaleAndRevertCommands() + public void AddPrivilegedControlsSeatAutoscaling_ResolvesReserveAutoscaleRevertAndSeatNeededEmailCommands() { var services = new ServiceCollection(); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); @@ -141,7 +140,7 @@ public void AddPrivilegedControlsSeatAutoscaling_ResolvesReserveAutoscaleAndReve scope.ServiceProvider.GetService()); Assert.IsType( scope.ServiceProvider.GetService()); - Assert.IsType( - scope.ServiceProvider.GetService()); + Assert.IsType( + scope.ServiceProvider.GetService()); } } From 3e6f7bb513b1a5978819ef0de1b4edcc94237b5d Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:21:26 -0500 Subject: [PATCH 27/44] [PM-45107] test: Point self-hosted and seat autoscale integration tests at the paths they cover The self-hosted bulk activate test flipped the settings flag, but Billing reads self-hosted from the environment, so the request took the cloud path. The staged invite autoscale tests sent a placeholder subscription id to the real Stripe adapter, which can't find it. Both now stub the dependency the code actually reads. --- ...zationUsersControllerBulkEnablePamTests.cs | 5 +++ ...sControllerSendInviteToStagedUsersTests.cs | 42 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs index c78a929212ef..054997c50975 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bitwarden.Server.Sdk.Environment; using NSubstitute; using Stripe; using Xunit; @@ -39,6 +40,10 @@ public OrganizationUsersControllerBulkEnablePamTests(ApiApplicationFactory apiFa { _factory = apiFactory; _factory.SubstituteService(_ => { }); + // Billing reads self-hosted from the environment, which is fixed at startup, so it follows the settings flag + // the tests flip instead. + _factory.SubstituteService(environment => + environment.SelfHosted.Returns(_ => _factory.GetService().SelfHosted)); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSendInviteToStagedUsersTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSendInviteToStagedUsersTests.cs index 023d3d26ebb1..32a64827e646 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSendInviteToStagedUsersTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSendInviteToStagedUsersTests.cs @@ -7,7 +7,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -15,6 +18,7 @@ using NSubstitute; using NSubstitute.ClearExtensions; using NSubstitute.ExceptionExtensions; +using Stripe; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; @@ -35,6 +39,9 @@ public class OrganizationUsersControllerSendInviteToStagedUsersTests private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IStripeAdapter _stripeAdapter; + + private const string _subscriptionId = "sub_integration_test"; private Organization _organization = null!; private string _ownerEmail = null!; @@ -43,12 +50,14 @@ public OrganizationUsersControllerSendInviteToStagedUsersTests(ApiApplicationFac { _factory = factory; _factory.SubstituteService(_ => { }); + _factory.SubstituteService(_ => { }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); _sendOrganizationInvitesCommand = _factory.GetService(); _organizationRepository = _factory.GetService(); _organizationUserRepository = _factory.GetService(); + _stripeAdapter = _factory.GetService(); } public async Task InitializeAsync() @@ -56,6 +65,12 @@ public async Task InitializeAsync() // The substitutes live on the shared class fixture, so a throw configured by one test would // otherwise apply to whichever test runs next. _sendOrganizationInvitesCommand.ClearSubstitute(); + _stripeAdapter.ClearSubstitute(); + + var plan = await _factory.GetService().GetPlanOrThrow(PlanType.EnterpriseAnnually); + var subscription = BuildSubscriptionWithPasswordManagerSeats(plan.PasswordManager.StripeSeatPlanId); + _stripeAdapter.GetSubscriptionAsync(_subscriptionId, Arg.Any()).Returns(subscription); + _stripeAdapter.UpdateSubscriptionAsync(_subscriptionId, Arg.Any()).Returns(subscription); _factory.GetService().SelfHosted = false; @@ -243,8 +258,33 @@ private async Task> StageMembersAsync(int count) /// /// Fixes the organization's seat headroom. Gateway ids are stood in because AutoAddSeats refuses to - /// adjust a subscription it cannot identify, and the test host's payment service is a no-op substitute. + /// adjust a subscription it cannot identify, and the substituted Stripe adapter answers for that subscription. /// private Task SetSeatsAsync(int seats, int? maxAutoscaleSeats) => OrganizationTestHelpers.SetSeatsAsync(_factory, _organization, seats, maxAutoscaleSeats); + + private static Subscription BuildSubscriptionWithPasswordManagerSeats(string passwordManagerSeatPriceId) => + new() + { + Id = _subscriptionId, + Status = StripeConstants.SubscriptionStatus.Active, + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, + Customer = new Customer { Id = "cus_integration_test" }, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Id = "si_password_manager_seats", + Quantity = 1, + Price = new Price + { + Id = passwordManagerSeatPriceId, + Recurring = new PriceRecurring { Interval = StripeConstants.Intervals.Year } + } + } + ] + } + }; } From 5c9661ffd30d3967f4701db919bc5bf3ca6b2505 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:51:00 -0500 Subject: [PATCH 28/44] [PM-45107] refactor: Leave the Privileged Controls seat reservation unset until it runs --- bitwarden_license/src/Sso/Controllers/AccountController.cs | 4 ++-- .../InviteLinks/AcceptOrganizationInviteLinkCommand.cs | 4 ++-- .../InviteLinks/ConfirmOrganizationInviteLinkCommand.cs | 4 ++-- .../RestoreUser/v1/RestoreOrganizationUserCommand.cs | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 120e49a7c764..1f28ce2701f8 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -702,7 +702,7 @@ SsoConfigurationData ssoConfigData // won't complete. Staged rows aren't seat-counted; a Staged→Invited promotion // consumes a seat and must gate here alongside the fresh-JIT case. var willPromoteStagedOrgUser = possibleOrgUser?.Status == OrganizationUserStatusType.Staged; - var pamSeatReservation = new PrivilegedControlsSeatReservationResult(); + PrivilegedControlsSeatReservationResult? pamSeatReservation = null; if (possibleOrgUser == null || willPromoteStagedOrgUser) { @@ -802,7 +802,7 @@ SsoConfigurationData ssoConfigData possibleOrgUser.RevisionDate = DateTime.UtcNow; await _organizationUserRepository.ReplaceAsync(possibleOrgUser); - if (pamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) + if (pamSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { await _sendPrivilegedControlsSeatNeededEmailCommand.RunAsync( organization, possibleOrgUser.Email ?? newUser.Email); diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs index 107dbc92323f..20a48bcd9af0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs @@ -75,7 +75,7 @@ private async Task> AcceptExistingOrgUserAsync( { var organization = validationResult.Organization; - var pamSeatReservation = new PrivilegedControlsSeatReservationResult(); + PrivilegedControlsSeatReservationResult? pamSeatReservation = null; if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -105,7 +105,7 @@ private async Task> AcceptExistingOrgUserAsync( await organizationUserRepository.ReplaceAsync(existingOrganizationUser); - if (pamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) + if (pamSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { await sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, memberEmail); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index 21e1a51d96b5..36e2e9e8036a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -120,7 +120,7 @@ private async Task> AddUserToOrganizationAsync(C private async Task> ConfirmExistingMembershipAsync( Organization organization, OrganizationUser existingOrganizationUser, User user, string orgUserKey) { - var pamSeatReservation = new PrivilegedControlsSeatReservationResult(); + PrivilegedControlsSeatReservationResult? pamSeatReservation = null; if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -146,7 +146,7 @@ private async Task> ConfirmExistingMembershipAsy await organizationUserRepository.ReplaceAsync(existingOrganizationUser); - if (pamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) + if (pamSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { await sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, memberEmail); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 62549c554783..adf8877090b7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -121,7 +121,7 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled); var status = organizationUser.GetPriorActiveOrganizationUserStatusType(); - var scimPamSeatReservation = new PrivilegedControlsSeatReservationResult(); + PrivilegedControlsSeatReservationResult scimPamSeatReservation = null; if (organization.UsePam && organizationUser.AccessPam && PrivilegedControlsSeatOccupancy.OccupiesSeat(status)) { @@ -149,7 +149,7 @@ await organizationUserRepository.StartInviteReminderSequencesAsync( [organizationUser.Id], timeProvider.GetUtcNow().UtcDateTime); } - if (scimPamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) + if (scimPamSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { var memberEmail = organizationUser.Email ?? (await userRepository.GetByIdAsync(organizationUser.UserId!.Value)).Email; From 3231fe231520f31d12d805a7a5806b2c844bd91d Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:46:03 -0500 Subject: [PATCH 29/44] [PM-45107] refactor: Give back restore seats from the captured seat count Admin restore now records the Password Manager seat count before buying seats and gives back the difference when the Privileged Controls seats are blocked, instead of recomputing how many seats the purchase added. The method that reserves and fails is renamed to say it throws, and comments explain which restores reserve seats and why bulk restore counts only revoked members. --- .../v1/RestoreOrganizationUserCommand.cs | 41 ++++++++++++------- .../RestoreOrganizationUserCommandTests.cs | 18 ++++++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index adf8877090b7..cfb4a1942549 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -95,12 +95,11 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId); var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; - var passwordManagerSeatsAdded = 0; + var initialPasswordManagerSeats = organization.Seats; if (availableSeats < 1) { await organizationService.AutoAddSeatsAsync(organization, 1); // Hooray - passwordManagerSeatsAdded = organization.Seats.HasValue ? 1 : 0; } var userTwoFactorIsEnabled = false; @@ -125,10 +124,13 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, if (organization.UsePam && organizationUser.AccessPam && PrivilegedControlsSeatOccupancy.OccupiesSeat(status)) { + // No system user means an admin is restoring this one member, so one seat is reserved. if (systemUser is null) { - await ReservePrivilegedControlsSeatsAsync(organization, 1, passwordManagerSeatsAdded); + await ReservePrivilegedControlsSeatsOrThrowAsync(organization, 1, initialPasswordManagerSeats); } + // A SCIM restore with no free Privileged Controls seat must still go through: the member is restored + // without access and the Owners are emailed. Public API restores don't check seats. else if (systemUser == EventSystemUser.SCIM) { scimPamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); @@ -169,31 +171,38 @@ await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.Organi } /// - /// Reserves Privileged Controls seats for members being restored with access. When the seats cannot be - /// reserved, the Password Manager seats bought for the same restore are handed back before the error is raised. + /// Reserves Privileged Controls seats for members an admin is restoring with access, through single or bulk + /// restore. When the seats cannot be reserved, the Password Manager seats bought for the same restore are handed + /// back and the restore fails. /// - private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int membersTakingSeats, - int passwordManagerSeatsAdded) + /// The Password Manager seat count captured before the restore bought seats. + private async Task ReservePrivilegedControlsSeatsOrThrowAsync(Organization organization, int seatsRequested, + int? initialPasswordManagerSeats) { - var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, seatsRequested); if (reservation.Success) { return; } - await ReleasePasswordManagerSeatsAsync(organization, passwordManagerSeatsAdded); + await ReleasePasswordManagerSeatsAsync(organization, initialPasswordManagerSeats); throw reservation.Error is InternalError ? new InvalidOperationException(reservation.Error.Message, reservation.Exception) : new BadRequestException(reservation.Error!.Message); } - private async Task ReleasePasswordManagerSeatsAsync(Organization organization, int seatsToRelease) + /// + /// Restores the Password Manager seat count captured before the restore bought seats. + /// + private async Task ReleasePasswordManagerSeatsAsync(Organization organization, int? initialPasswordManagerSeats) { - if (seatsToRelease <= 0) + if (initialPasswordManagerSeats is null || initialPasswordManagerSeats == organization.Seats) { return; } + var seatsToRelease = organization.Seats.GetValueOrDefault() - initialPasswordManagerSeats.Value; + try { await organizationService.AdjustSeatsAsync(organization.Id, -seatsToRelease); @@ -271,16 +280,20 @@ public async Task>> RestoreUsersAsync(Guid var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; var newSeatsRequired = organizationUserIds.Count() - availableSeats; + var initialPasswordManagerSeats = organization.Seats; await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); - var passwordManagerSeatsAdded = organization.Seats.HasValue ? Math.Max(newSeatsRequired, 0) : 0; if (organization.UsePam) { - var membersTakingPamSeats = filteredUsers.Count(u => + // Only revoked members are restored, and only those returning to a seat-using status with Privileged + // Controls access need a seat. Seats are reserved once for the whole selection before the per-member + // checks, the same way Password Manager seats are. + var privilegedControlsSeatsRequested = filteredUsers.Count(u => u.Status == OrganizationUserStatusType.Revoked && u.AccessPam && PrivilegedControlsSeatOccupancy.OccupiesSeat(u.GetPriorActiveOrganizationUserStatusType())); - await ReservePrivilegedControlsSeatsAsync(organization, membersTakingPamSeats, passwordManagerSeatsAdded); + await ReservePrivilegedControlsSeatsOrThrowAsync(organization, privilegedControlsSeatsRequested, + initialPasswordManagerSeats); } var restoringUserIsOwner = false; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 6ad3d47b9a1e..52510f5139b0 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -1318,6 +1318,7 @@ public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivesBackThePasswordMa organizationUser.AccessPam = true; organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); @@ -1345,6 +1346,7 @@ public async Task RestoreUser_Admin_MemberWithPam_ReservationFails_GivesBackTheP organizationUser.AccessPam = true; organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) .Returns(new PrivilegedControlsSeatReservationResult( @@ -1370,6 +1372,7 @@ public async Task RestoreUser_Admin_MemberWithPam_ReservationFailsUnexpectedly_G organizationUser.AccessPam = true; organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); var failure = new TimeoutException("Stripe is unreachable."); sutProvider.GetDependency() .RunAsync(organization, 1) @@ -1394,6 +1397,7 @@ public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivingBackTheSeatFails organizationUser.AccessPam = true; organizationUser.StatusNew = OrganizationUserStatusTypeNew.Confirmed; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); sutProvider.GetDependency() .RunAsync(organization, 1) .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); @@ -1671,6 +1675,7 @@ public async Task RestoreUsers_Bulk_AtLimit_GivesBackThePasswordManagerSeatsItBo organization.Seats = 1; var userService = Substitute.For(); RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); OrganizationUser[] orgUsers = [orgUser1, orgUser2]; foreach (var orgUser in orgUsers) { @@ -1707,6 +1712,7 @@ public async Task RestoreUsers_Bulk_ReservationFailsUnexpectedly_GivesBackThePas organization.Seats = 1; var userService = Substitute.For(); RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); orgUser1.AccessPam = true; orgUser1.StatusNew = OrganizationUserStatusTypeNew.Confirmed; sutProvider.GetDependency() @@ -1751,6 +1757,18 @@ await sutProvider.GetDependency() .AdjustSeatsAsync(default, default); } + /// + /// Raises the organization's seat count when seats are auto-added, as the real does. + /// + private static void SetupAutoAddSeatsRaisesSeatCount( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .When(service => service.AutoAddSeatsAsync(organization, Arg.Is(seats => seats > 0))) + .Do(callInfo => organization.Seats += callInfo.ArgAt(1)); + } + private static void RestoreUser_Setup( Organization organization, OrganizationUser? requestingOrganizationUser, From 71a83e81fa6030663bdb114ba98394346492c0da Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:46:03 -0500 Subject: [PATCH 30/44] [PM-45107] refactor: Clarify the Privileged Controls seat count and check the limit first The reserve and autoscale commands take the number of members about to start using a seat, which the old names read as the number of seats to buy. Both parameters are now seatsRequested, and the autoscale and revert commands use RunAsync to match the other Privileged Controls seat commands. The autoscale command now checks the limit before the subscription, so an organization without a subscription that is over its limit gets the limit-reached result. Admins see the ask-an-Owner error, and self-service joins still email the Owners. --- .../BulkEnablePam/BulkEnablePamCommand.cs | 5 +- .../InviteStagedOrganizationUsersCommand.cs | 2 +- .../Implementations/OrganizationService.cs | 6 +- ...AutoscalePrivilegedControlsSeatsCommand.cs | 19 +-- .../IReservePrivilegedControlsSeatsCommand.cs | 14 ++- .../IRevertPrivilegedControlsSeatsCommand.cs | 2 +- ...AutoscalePrivilegedControlsSeatsCommand.cs | 14 +-- .../ReservePrivilegedControlsSeatsCommand.cs | 16 +-- .../RevertPrivilegedControlsSeatsCommand.cs | 2 +- ...viteStagedOrganizationUsersCommandTests.cs | 8 +- .../Services/OrganizationServiceTests.cs | 4 +- ...calePrivilegedControlsSeatsCommandTests.cs | 108 ++++++++++-------- ...ervePrivilegedControlsSeatsCommandTests.cs | 24 ++-- ...vertPrivilegedControlsSeatsCommandTests.cs | 16 +-- 14 files changed, 129 insertions(+), 111 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs index dc045186f602..d05dadc6752e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs @@ -30,8 +30,9 @@ public async Task BulkEnablePamAsync(Organization organization, I return new PamNotEnabled(); } - var membersTakingSeats = organizationUsers.Count(u => PrivilegedControlsSeatOccupancy.OccupiesSeat(u.Status)); - var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); + var privilegedControlsSeatsRequested = + organizationUsers.Count(u => PrivilegedControlsSeatOccupancy.OccupiesSeat(u.Status)); + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, privilegedControlsSeatsRequested); if (!reservation.Success) { return reservation.Error!; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index 09bff236dcd4..d715b8c2b3d6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -420,7 +420,7 @@ private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization try { - await revertPrivilegedControlsSeatsCommand.Run(organization, initialPamSeats.Value); + await revertPrivilegedControlsSeatsCommand.RunAsync(organization, initialPamSeats.Value); } catch (Exception ex) { diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f8847de05436..82d4f061016a 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -852,9 +852,9 @@ private async Task RestorePromotedStagedUsersAsync( await _organizationUserRepository.ReplaceManyAsync(promotedStagedUsers); } - private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int membersTakingSeats) + private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int seatsRequested) { - var reservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersTakingSeats); + var reservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, seatsRequested); if (reservation.Success) { return; @@ -877,7 +877,7 @@ private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization try { - await _revertPrivilegedControlsSeatsCommand.Run(organization, initialPamSeats.Value); + await _revertPrivilegedControlsSeatsCommand.RunAsync(organization, initialPamSeats.Value); } catch (Exception ex) { diff --git a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs index 1ec509746fa6..842714f3283a 100644 --- a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs @@ -12,23 +12,24 @@ namespace Bit.Core.Billing.Organizations.Commands; public interface IAutoscalePrivilegedControlsSeatsCommand { /// - /// Adds Privileged Controls seats when the seats in use plus exceed the seats the - /// organization has bought. Nothing changes when the bought seats already cover that total. When a limit is set - /// and the total equals it, the organization's Owners are emailed. On a trial the seats are raised on the + /// Adds Privileged Controls seats when the seats in use plus exceed the seats + /// the organization has bought. Nothing changes when the bought seats already cover that total. When a limit is + /// set and the total equals it, the organization's Owners are emailed. On a trial the seats are raised on the /// organization only, with no change to Stripe. /// /// The organization that already has Privileged Controls. - /// - /// The number of users about to be granted Privileged Controls access who do not already occupy a seat. + /// + /// The number of members about to start using a Privileged Controls seat. Not the new total and not the + /// shortfall: the command buys only what free seats don't cover. /// /// /// A successful result, or a failed result carrying the autoscale limit when the total would pass it. /// /// - /// Thrown on a self-hosted instance, when is below 1, when the organization does - /// not have Privileged Controls, when seats must be added and it has no subscription, or when the seat change - /// or subscription update is rejected. + /// Thrown on a self-hosted instance, when is below 1, when the organization + /// does not have Privileged Controls, when seats must be added within the limit and it has no subscription, or + /// when the seat change or subscription update is rejected. /// /// Thrown when the organization's subscription is in a state that prevents the change. - Task Run(OrganizationEntity organization, int seatsToAdd); + Task RunAsync(OrganizationEntity organization, int seatsRequested); } diff --git a/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs index 2bdc0d7d9f54..35ceee283368 100644 --- a/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs +++ b/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs @@ -9,13 +9,17 @@ namespace Bit.Core.Billing.Organizations.Commands; public interface IReservePrivilegedControlsSeatsCommand { /// - /// Reserves seats for members who are about to be granted Privileged - /// Controls access and do not already occupy a seat. On cloud, seats are autoscaled up to the organization's limit. - /// The caller grants the access. + /// Reserves Privileged Controls seats for members who are about to be granted access. On cloud, seats are + /// autoscaled up to the organization's limit. The caller grants the access. /// + /// The organization the members belong to. + /// + /// The number of members about to start using a Privileged Controls seat. Not the new total and not the + /// shortfall: the command buys only what free seats don't cover. + /// /// - /// Succeeds without checking seats when no members are taking seats, the organization doesn't use Privileged + /// Succeeds without checking seats when no seats are requested, the organization doesn't use Privileged /// Controls, or it has no Privileged Controls seat count. Never throws: every failure is returned as an error. /// - Task RunAsync(Organization organization, int membersTakingSeats); + Task RunAsync(Organization organization, int seatsRequested); } diff --git a/src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs index c15309c5ad20..aac620f68489 100644 --- a/src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs +++ b/src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs @@ -19,5 +19,5 @@ public interface IRevertPrivilegedControlsSeatsCommand /// Thrown on a self-hosted instance, or when the seat change or subscription update is rejected. /// /// Thrown when the organization's subscription is in a state that prevents the change. - Task Run(OrganizationEntity organization, int seats); + Task RunAsync(OrganizationEntity organization, int seats); } diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs index 9e2075173083..0e2492b3afc5 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs @@ -27,14 +27,14 @@ internal sealed class AutoscalePrivilegedControlsSeatsCommand( IBitwardenEnvironment environment, ILogger logger) : IAutoscalePrivilegedControlsSeatsCommand { - public async Task Run(OrganizationEntity organization, int seatsToAdd) + public async Task RunAsync(OrganizationEntity organization, int seatsRequested) { if (environment.SelfHosted) { throw new BadRequestException("Cannot update subscription on a self-hosted instance."); } - if (seatsToAdd < 1) + if (seatsRequested < 1) { throw new BadRequestException("At least one Privileged Controls seat must be added."); } @@ -45,18 +45,18 @@ public async Task Run(OrganizationEntity } var seatsNeeded = - await organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + seatsToAdd; + await organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + seatsRequested; if (seatsNeeded > organization.PamSeats) { - if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + if (organization.MaxAutoscalePamSeats < seatsNeeded) { - throw new BadRequestException("No subscription found."); + return new AutoscalePrivilegedControlsSeatsResult(false, organization.MaxAutoscalePamSeats); } - if (organization.MaxAutoscalePamSeats < seatsNeeded) + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { - return new AutoscalePrivilegedControlsSeatsResult(false, organization.MaxAutoscalePamSeats); + throw new BadRequestException("No subscription found."); } if (!await isOnTrialQuery.Run(organization)) diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs index d584beec29f8..75245ae807db 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs @@ -15,40 +15,40 @@ internal sealed class ReservePrivilegedControlsSeatsCommand( IBitwardenEnvironment environment, ILogger logger) : IReservePrivilegedControlsSeatsCommand { - public async Task RunAsync(OrganizationEntity organization, int membersTakingSeats) + public async Task RunAsync(OrganizationEntity organization, int seatsRequested) { // A null seat count means no seat count applies to the organization yet. - if (membersTakingSeats < 1 || !organization.UsePam || organization.PamSeats is null) + if (seatsRequested < 1 || !organization.UsePam || organization.PamSeats is null) { return new PrivilegedControlsSeatReservationResult(); } try { - return await ReserveAsync(organization, membersTakingSeats); + return await ReserveAsync(organization, seatsRequested); } catch (BadRequestException e) { logger.LogWarning(e, "Could not reserve {SeatsToReserve} Privileged Controls seat(s) for organization {OrganizationId}", - membersTakingSeats, organization.Id); + seatsRequested, organization.Id); return new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatExpansionFailedError(e.Message), e); } catch (Exception e) { logger.LogError(e, "Unexpected error reserving {SeatsToReserve} Privileged Controls seat(s) for organization {OrganizationId}", - membersTakingSeats, organization.Id); + seatsRequested, organization.Id); return new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatReservationUnexpectedError(), e); } } - private async Task ReserveAsync(OrganizationEntity organization, int membersTakingSeats) + private async Task ReserveAsync(OrganizationEntity organization, int seatsRequested) { if (environment.SelfHosted) { var seatsRequired = await countNewPamSeatsRequiredQuery - .CountNewPamSeatsRequiredAsync(organization, membersTakingSeats); + .CountNewPamSeatsRequiredAsync(organization, seatsRequested); return seatsRequired > 0 ? new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError()) @@ -61,7 +61,7 @@ private async Task ReserveAsync(Organiz return new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError()); } - var result = await autoscalePrivilegedControlsSeatsCommand.Run(organization, membersTakingSeats); + var result = await autoscalePrivilegedControlsSeatsCommand.RunAsync(organization, seatsRequested); return result.Success ? new PrivilegedControlsSeatReservationResult() diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs index 56d7d48ffc9f..ef8dfca36281 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs @@ -15,7 +15,7 @@ internal sealed class RevertPrivilegedControlsSeatsCommand( IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, IBitwardenEnvironment environment) : IRevertPrivilegedControlsSeatsCommand { - public async Task Run(OrganizationEntity organization, int seats) + public async Task RunAsync(OrganizationEntity organization, int seats) { if (environment.SelfHosted) { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index f9abb0b1f0ce..9273c92d69eb 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -916,7 +916,7 @@ public async Task RunAsync_PamReservationFailsAfterSeatsChanged_RevertsPamSeats( await sutProvider.GetDependency() .Received(1) - .Run(organization, 5); + .RunAsync(organization, 5); } [Theory, BitAutoData] @@ -990,7 +990,7 @@ public async Task RunAsync_InviteSendFails_RevertsPamSeatsAndRethrows( await sutProvider.GetDependency() .Received(1) - .Run(organization, 5); + .RunAsync(organization, 5); } [Theory, BitAutoData] @@ -1003,7 +1003,7 @@ public async Task RunAsync_PamRevertFails_StillRethrowsTheSendFailure( AutoscalePamSeatsTo(sutProvider, organization, 7); FailInviteSend(sutProvider); sutProvider.GetDependency() - .Run(organization, 5) + .RunAsync(organization, 5) .ThrowsAsync(new GatewayException("Stripe is unreachable.")); await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); @@ -1022,7 +1022,7 @@ public async Task RunAsync_NoPamSeatsAdded_DoesNotRevert( await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .Run(default!, default); + .RunAsync(default!, default); } /// Asserts the request succeeded overall but reported for one member. diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 26f64f3fba03..2c72c247c099 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -2228,7 +2228,7 @@ await Assert.ThrowsAsync(() => sutProvider.Sut await sutProvider.GetDependency() .Received(1) - .Run(organization, 5); + .RunAsync(organization, 5); } [Theory] @@ -2248,7 +2248,7 @@ public async Task InviteUser_StagedUserWithPrivilegedControls_GivingTheSeatsBack .SendInvitesAsync(Arg.Any()) .ThrowsAsync(new InvalidOperationException("SMTP is down.")); sutProvider.GetDependency() - .Run(organization, 5) + .RunAsync(organization, 5) .ThrowsAsync(new GatewayException("Stripe is unavailable.")); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index ae19a2a851fa..77ba9ea41665 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs @@ -55,12 +55,12 @@ public AutoscalePrivilegedControlsSeatsCommandTests() => new RecordingLogger()); [Fact] - public async Task Run_SelfHosted_ThrowsBadRequest() + public async Task RunAsync_SelfHosted_ThrowsBadRequest() { var organization = CreateOrganization(); _environment.SelfHosted.Returns(true); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); + var exception = await Assert.ThrowsAsync(() => _sut.RunAsync(organization, 1)); Assert.Equal("Cannot update subscription on a self-hosted instance.", exception.Message); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); @@ -69,11 +69,11 @@ public async Task Run_SelfHosted_ThrowsBadRequest() [Theory] [InlineData(0)] [InlineData(-1)] - public async Task Run_SeatsToAddBelowOne_ThrowsBadRequestWithoutReadingSeatsInUse(int seatsToAdd) + public async Task RunAsync_SeatsRequestedBelowOne_ThrowsBadRequestWithoutReadingSeatsInUse(int seatsRequested) { var organization = CreateOrganization(); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, seatsToAdd)); + var exception = await Assert.ThrowsAsync(() => _sut.RunAsync(organization, seatsRequested)); Assert.Equal("At least one Privileged Controls seat must be added.", exception.Message); await _organizationUserRepository.DidNotReceive() @@ -82,34 +82,34 @@ await _organizationUserRepository.DidNotReceive() } [Fact] - public async Task Run_OrganizationHasNoPrivilegedControls_ThrowsBadRequest() + public async Task RunAsync_OrganizationHasNoPrivilegedControls_ThrowsBadRequest() { var organization = CreateOrganization(usePam: false, pamSeats: null); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); + var exception = await Assert.ThrowsAsync(() => _sut.RunAsync(organization, 1)); Assert.Equal("Your organization doesn't have Privileged Controls.", exception.Message); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); } [Fact] - public async Task Run_OrganizationUsesPrivilegedControlsWithNoSeats_ThrowsBadRequest() + public async Task RunAsync_OrganizationUsesPrivilegedControlsWithNoSeats_ThrowsBadRequest() { var organization = CreateOrganization(usePam: true, pamSeats: 0); - await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); + await Assert.ThrowsAsync(() => _sut.RunAsync(organization, 1)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public async Task Run_NoGatewaySubscriptionAndSeatsShort_ThrowsBadRequestAndChangesNothing(string? gatewaySubscriptionId) + public async Task RunAsync_NoGatewaySubscriptionAndSeatsShort_ThrowsBadRequestAndChangesNothing(string? gatewaySubscriptionId) { var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); SetSeatsInUse(organization, _seatsInUse); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 3)); + var exception = await Assert.ThrowsAsync(() => _sut.RunAsync(organization, 3)); Assert.Equal("No subscription found.", exception.Message); Assert.Equal(_boughtSeats, organization.PamSeats); @@ -118,17 +118,29 @@ await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() .RunAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task RunAsync_NoGatewaySubscriptionAndTotalExceedsTheLimit_ReturnsAFailedResultCarryingTheLimit() + { + var organization = CreateOrganization(maxAutoscalePamSeats: 14, gatewaySubscriptionId: null); + SetSeatsInUse(organization, _seatsInUse); + + var result = await _sut.RunAsync(organization, 5); + + Assert.False(result.Success); + Assert.Equal(14, result.MaxAutoscaleSeats); + } + [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public async Task Run_NoGatewaySubscriptionAndSeatsAlreadyCovered_SucceedsWithoutChangingAnything( + public async Task RunAsync_NoGatewaySubscriptionAndSeatsAlreadyCovered_SucceedsWithoutChangingAnything( string? gatewaySubscriptionId) { var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); SetSeatsInUse(organization, _seatsInUse); - var result = await _sut.Run(organization, 2); + var result = await _sut.RunAsync(organization, 2); Assert.True(result.Success); Assert.Equal(_boughtSeats, organization.PamSeats); @@ -137,13 +149,13 @@ await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() } [Fact] - public async Task Run_OnTrial_SeatsShort_WithinLimit_SavesSeatsWithoutTouchingStripe() + public async Task RunAsync_OnTrial_SeatsShort_WithinLimit_SavesSeatsWithoutTouchingStripe() { var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 10); SetSeatsInUse(organization, 5); _isOnTrialQuery.Result = true; - var result = await _sut.Run(organization, 2); + var result = await _sut.RunAsync(organization, 2); Assert.True(result.Success); Assert.Equal(7, organization.PamSeats); @@ -154,13 +166,13 @@ await _updateOrganizationSubscriptionCommand.DidNotReceive() } [Fact] - public async Task Run_OnTrial_PastLimit_ReturnsLimitWithoutSaving() + public async Task RunAsync_OnTrial_PastLimit_ReturnsLimitWithoutSaving() { var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 6); SetSeatsInUse(organization, 5); _isOnTrialQuery.Result = true; - var result = await _sut.Run(organization, 2); + var result = await _sut.RunAsync(organization, 2); Assert.False(result.Success); Assert.Equal(6, result.MaxAutoscaleSeats); @@ -169,35 +181,35 @@ await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() } [Fact] - public async Task Run_OnTrial_ReachesLimitExactly_EmailsOwners() + public async Task RunAsync_OnTrial_ReachesLimitExactly_EmailsOwners() { var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 7); SetSeatsInUse(organization, 5); _isOnTrialQuery.Result = true; - await _sut.Run(organization, 2); + await _sut.RunAsync(organization, 2); await _mailer.Received(1).SendEmail(Arg.Any()); } [Fact] - public async Task Run_SeatsAlreadyCovered_DoesNotQueryTrial() + public async Task RunAsync_SeatsAlreadyCovered_DoesNotQueryTrial() { var organization = CreateOrganization(pamSeats: 5); SetSeatsInUse(organization, 3); - await _sut.Run(organization, 1); + await _sut.RunAsync(organization, 1); Assert.Equal(0, _isOnTrialQuery.Calls); } [Fact] - public async Task Run_BoughtSeatsAlreadyCoverTheTotal_SucceedsWithoutChangingAnything() + public async Task RunAsync_BoughtSeatsAlreadyCoverTheTotal_SucceedsWithoutChangingAnything() { var organization = CreateOrganization(); SetSeatsInUse(organization, _seatsInUse); - var result = await _sut.Run(organization, 2); + var result = await _sut.RunAsync(organization, 2); Assert.True(result.Success); Assert.Equal(_boughtSeats, organization.PamSeats); @@ -209,50 +221,50 @@ await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() } [Fact] - public async Task Run_LimitBelowBoughtSeatsButSeatsAlreadyCoverTheTotal_Succeeds() + public async Task RunAsync_LimitBelowBoughtSeatsButSeatsAlreadyCoverTheTotal_Succeeds() { var organization = CreateOrganization(maxAutoscalePamSeats: 5); SetSeatsInUse(organization, _seatsInUse); - var result = await _sut.Run(organization, 1); + var result = await _sut.RunAsync(organization, 1); Assert.True(result.Success); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); } [Fact] - public async Task Run_TotalExceedsBoughtSeatsWithNoLimit_AsksTheSeatChangeStepForTheTotal() + public async Task RunAsync_TotalExceedsBoughtSeatsWithNoLimit_AsksTheSeatChangeStepForTheTotal() { var organization = CreateOrganization(); SetSeatsInUse(organization, _seatsInUse); SucceedWith(); - var result = await _sut.Run(organization, 5); + var result = await _sut.RunAsync(organization, 5); Assert.True(result.Success); await _seatChangeSetFactory.Received(1).CreateAsync(organization, 15); } [Fact] - public async Task Run_TotalExceedsBoughtSeats_AppliesTheChangeSetFromTheSeatChangeStep() + public async Task RunAsync_TotalExceedsBoughtSeats_AppliesTheChangeSetFromTheSeatChangeStep() { var organization = CreateOrganization(); SetSeatsInUse(organization, _seatsInUse); var changeSet = SucceedWith(); - await _sut.Run(organization, 5); + await _sut.RunAsync(organization, 5); await _updateOrganizationSubscriptionCommand.Received(1).Run(organization, changeSet); } [Fact] - public async Task Run_TotalExceedsBoughtSeats_SavesTheNewSeatCount() + public async Task RunAsync_TotalExceedsBoughtSeats_SavesTheNewSeatCount() { var organization = CreateOrganization(maxAutoscalePamSeats: 20); SetSeatsInUse(organization, _seatsInUse); SucceedWith(); - await _sut.Run(organization, 5); + await _sut.RunAsync(organization, 5); Assert.Equal(15, organization.PamSeats); Assert.Equal(20, organization.MaxAutoscalePamSeats); @@ -260,36 +272,36 @@ public async Task Run_TotalExceedsBoughtSeats_SavesTheNewSeatCount() } [Fact] - public async Task Run_TotalExceedsBoughtSeatsWithinTheLimit_DoesNotEmailOwners() + public async Task RunAsync_TotalExceedsBoughtSeatsWithinTheLimit_DoesNotEmailOwners() { var organization = CreateOrganization(maxAutoscalePamSeats: 20); SetSeatsInUse(organization, _seatsInUse); SucceedWith(); - await _sut.Run(organization, 5); + await _sut.RunAsync(organization, 5); await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact] - public async Task Run_TotalExceedsTheLimit_ReturnsAFailedResultCarryingTheLimit() + public async Task RunAsync_TotalExceedsTheLimit_ReturnsAFailedResultCarryingTheLimit() { var organization = CreateOrganization(maxAutoscalePamSeats: 14); SetSeatsInUse(organization, _seatsInUse); - var result = await _sut.Run(organization, 5); + var result = await _sut.RunAsync(organization, 5); Assert.False(result.Success); Assert.Equal(14, result.MaxAutoscaleSeats); } [Fact] - public async Task Run_TotalExceedsTheLimit_ChangesSavesAndEmailsNothing() + public async Task RunAsync_TotalExceedsTheLimit_ChangesSavesAndEmailsNothing() { var organization = CreateOrganization(maxAutoscalePamSeats: 14); SetSeatsInUse(organization, _seatsInUse); - await _sut.Run(organization, 5); + await _sut.RunAsync(organization, 5); Assert.Equal(_boughtSeats, organization.PamSeats); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); @@ -301,13 +313,13 @@ await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() } [Fact] - public async Task Run_TotalEqualsTheLimitAndSeatsAreBought_EmailsOwnersTheLimit() + public async Task RunAsync_TotalEqualsTheLimitAndSeatsAreBought_EmailsOwnersTheLimit() { var organization = CreateOrganization(maxAutoscalePamSeats: 15); SetSeatsInUse(organization, _seatsInUse); SucceedWith(); - var result = await _sut.Run(organization, 5); + var result = await _sut.RunAsync(organization, 5); Assert.True(result.Success); await _mailer.Received(1).SendEmail(Arg.Is(mail => @@ -319,12 +331,12 @@ await _mailer.Received(1).SendEmail(Arg.Is(), Arg.Any()); @@ -333,19 +345,19 @@ await _mailer.Received(1).SendEmail(Arg.Is()); } [Fact] - public async Task Run_EmailFails_StillSucceedsAndKeepsTheSavedSeats() + public async Task RunAsync_EmailFails_StillSucceedsAndKeepsTheSavedSeats() { var organization = CreateOrganization(maxAutoscalePamSeats: 15); SetSeatsInUse(organization, _seatsInUse); @@ -353,7 +365,7 @@ public async Task Run_EmailFails_StillSucceedsAndKeepsTheSavedSeats() _mailer.SendEmail(Arg.Any()) .ThrowsAsync(new InvalidOperationException("Mail is down.")); - var result = await _sut.Run(organization, 5); + var result = await _sut.RunAsync(organization, 5); Assert.True(result.Success); Assert.Equal(15, organization.PamSeats); @@ -361,14 +373,14 @@ public async Task Run_EmailFails_StillSucceedsAndKeepsTheSavedSeats() } [Fact] - public async Task Run_SeatChangeStepRejectsTheRequest_ThrowsItsExceptionAndChangesNothing() + public async Task RunAsync_SeatChangeStepRejectsTheRequest_ThrowsItsExceptionAndChangesNothing() { var organization = CreateOrganization(); SetSeatsInUse(organization, _seatsInUse); _seatChangeSetFactory.CreateAsync(organization, 15) .ThrowsAsync(new BadRequestException("Organization's plan does not support Privileged Controls.")); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 5)); + var exception = await Assert.ThrowsAsync(() => _sut.RunAsync(organization, 5)); Assert.Equal("Organization's plan does not support Privileged Controls.", exception.Message); Assert.Equal(_boughtSeats, organization.PamSeats); @@ -377,7 +389,7 @@ await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() } [Fact] - public async Task Run_SubscriptionUpdateRejected_ThrowsBadRequestAndLeavesTheOrganizationUnsaved() + public async Task RunAsync_SubscriptionUpdateRejected_ThrowsBadRequestAndLeavesTheOrganizationUnsaved() { var organization = CreateOrganization(maxAutoscalePamSeats: 15); SetSeatsInUse(organization, _seatsInUse); @@ -385,7 +397,7 @@ public async Task Run_SubscriptionUpdateRejected_ThrowsBadRequestAndLeavesTheOrg _updateOrganizationSubscriptionCommand.Run(organization, changeSet) .Returns(new BadRequest("Your card was declined.")); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 5)); + var exception = await Assert.ThrowsAsync(() => _sut.RunAsync(organization, 5)); Assert.Equal("Your card was declined.", exception.Message); Assert.Equal(_boughtSeats, organization.PamSeats); diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs index 92caa7142529..76add3b5e2e9 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs @@ -32,14 +32,14 @@ public ReservePrivilegedControlsSeatsCommandTests() => [Theory] [InlineData(0)] [InlineData(-1)] - public async Task RunAsync_NoMembers_SucceedsWithoutCallingAutoscale(int membersTakingSeats) + public async Task RunAsync_NoSeatsRequested_SucceedsWithoutCallingAutoscale(int seatsRequested) { var organization = CreateOrganization(); - var result = await _sut.RunAsync(organization, membersTakingSeats); + var result = await _sut.RunAsync(organization, seatsRequested); Assert.True(result.Success); - await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); } [Fact] @@ -50,7 +50,7 @@ public async Task RunAsync_PamOff_SucceedsWithoutCallingAutoscale() var result = await _sut.RunAsync(organization, 2); Assert.True(result.Success); - await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); } [Theory] @@ -64,7 +64,7 @@ public async Task RunAsync_NullPamSeats_SucceedsWithoutCheckingSeats(bool selfHo var result = await _sut.RunAsync(organization, 2); Assert.True(result.Success); - await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); await _countNewPamSeatsRequiredQuery.DidNotReceiveWithAnyArgs().CountNewPamSeatsRequiredAsync(default!, default); } @@ -76,7 +76,7 @@ public async Task RunAsync_Cloud_ZeroPamSeats_ReturnsLimitReachedWithoutCallingA var result = await _sut.RunAsync(organization, 2); Assert.IsType(result.Error); - await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); } [Fact] @@ -101,7 +101,7 @@ public async Task RunAsync_SelfHosted_Shortfall_ReturnsSelfHostedShortfall() var result = await _sut.RunAsync(organization, 2); Assert.IsType(result.Error); - await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().Run(default!, default); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); } [Fact] @@ -120,20 +120,20 @@ public async Task RunAsync_SelfHosted_NoShortfall_Succeeds() public async Task RunAsync_Cloud_AutoscaleSucceeds_Succeeds() { var organization = CreateOrganization(); - _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3) + _autoscalePrivilegedControlsSeatsCommand.RunAsync(organization, 3) .Returns(new AutoscalePrivilegedControlsSeatsResult(true, null)); var result = await _sut.RunAsync(organization, 3); Assert.True(result.Success); - await _autoscalePrivilegedControlsSeatsCommand.Received(1).Run(organization, 3); + await _autoscalePrivilegedControlsSeatsCommand.Received(1).RunAsync(organization, 3); } [Fact] public async Task RunAsync_Cloud_AutoscaleAtLimit_ReturnsLimitReached() { var organization = CreateOrganization(); - _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3) + _autoscalePrivilegedControlsSeatsCommand.RunAsync(organization, 3) .Returns(new AutoscalePrivilegedControlsSeatsResult(false, 6)); var result = await _sut.RunAsync(organization, 3); @@ -146,7 +146,7 @@ public async Task RunAsync_AutoscaleThrowsBadRequest_ReturnsExpansionFailedWithT { var organization = CreateOrganization(); var exception = new BadRequestException("No subscription found."); - _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3).ThrowsAsync(exception); + _autoscalePrivilegedControlsSeatsCommand.RunAsync(organization, 3).ThrowsAsync(exception); var result = await _sut.RunAsync(organization, 3); @@ -162,7 +162,7 @@ public async Task RunAsync_AutoscaleThrowsUnexpectedly_ReturnsUnexpectedErrorAnd { var organization = CreateOrganization(); var exception = new ConflictException("The subscription is invalid."); - _autoscalePrivilegedControlsSeatsCommand.Run(organization, 3).ThrowsAsync(exception); + _autoscalePrivilegedControlsSeatsCommand.RunAsync(organization, 3).ThrowsAsync(exception); var result = await _sut.RunAsync(organization, 3); diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs index afe8de879a42..7662270600b8 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs @@ -34,11 +34,11 @@ public RevertPrivilegedControlsSeatsCommandTests() => _environment); [Fact] - public async Task Run_SeatsUnchanged_DoesNothing() + public async Task RunAsync_SeatsUnchanged_DoesNothing() { var organization = CreateOrganization(pamSeats: 5); - await _sut.Run(organization, 5); + await _sut.RunAsync(organization, 5); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); await _updateOrganizationSubscriptionCommand.DidNotReceive() @@ -47,12 +47,12 @@ await _updateOrganizationSubscriptionCommand.DidNotReceive() } [Fact] - public async Task Run_OnTrial_ResetsSeatsOnOrganizationOnly() + public async Task RunAsync_OnTrial_ResetsSeatsOnOrganizationOnly() { var organization = CreateOrganization(pamSeats: 7); _isOnTrialQuery.Result = true; - await _sut.Run(organization, 5); + await _sut.RunAsync(organization, 5); Assert.Equal(5, organization.PamSeats); await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); @@ -62,7 +62,7 @@ await _updateOrganizationSubscriptionCommand.DidNotReceive() } [Fact] - public async Task Run_Paid_UpdatesSubscriptionThenSaves() + public async Task RunAsync_Paid_UpdatesSubscriptionThenSaves() { var organization = CreateOrganization(pamSeats: 7); _isOnTrialQuery.Result = false; @@ -75,7 +75,7 @@ public async Task Run_Paid_UpdatesSubscriptionThenSaves() .Run(organization, changeSet, Arg.Any()) .Returns(new Subscription()); - await _sut.Run(organization, 5); + await _sut.RunAsync(organization, 5); await _seatChangeSetFactory.Received(1).CreateAsync(organization, 5); await _updateOrganizationSubscriptionCommand.Received(1).Run(organization, changeSet); @@ -84,12 +84,12 @@ public async Task Run_Paid_UpdatesSubscriptionThenSaves() } [Fact] - public async Task Run_SelfHosted_ThrowsBadRequest() + public async Task RunAsync_SelfHosted_ThrowsBadRequest() { var organization = CreateOrganization(pamSeats: 7); _environment.SelfHosted.Returns(true); - await Assert.ThrowsAsync(() => _sut.Run(organization, 5)); + await Assert.ThrowsAsync(() => _sut.RunAsync(organization, 5)); } private static OrganizationEntity CreateOrganization(int pamSeats) => new() From 292f7c23d5f7ad625a20c562bdd8c20913b4d67e Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:46:03 -0500 Subject: [PATCH 31/44] [PM-45107] fix: Word the seat-needed email for restores as well as joins The email said the member joined, which is wrong when SCIM restores an existing member. It now says the member has been given access to the organization but not to Privileged Controls. --- .../Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs | 6 +++--- .../Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs | 2 +- .../emails/Billing/PrivilegedControlsSeatNeededView.mjml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs index a025313014ee..7b9d1928e847 100644 --- a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs @@ -195,9 +195,9 @@ -
{{ MemberEmail }} joined {{ OrganizationName }} without access to - Privileged Controls because the organization has reached its - Privileged Controls seat limit.
+
{{ MemberEmail }} has been given access to {{ OrganizationName }}, + but not to Privileged Controls, because the organization has + reached its Privileged Controls seat limit.
diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs index 44af0d83884a..119620fdb699 100644 --- a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs @@ -1,6 +1,6 @@ A member needs a Privileged Controls seat -{{{MemberEmail}}} joined {{{OrganizationName}}} without access to Privileged Controls because the organization has reached its Privileged Controls seat limit. +{{{MemberEmail}}} has been given access to {{{OrganizationName}}}, but not to Privileged Controls, because the organization has reached its Privileged Controls seat limit. An owner can add more seats by going to the Subscription page and raising the limit. diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml index 8048e7d755c1..3b08b3552aba 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml @@ -25,9 +25,9 @@ font-weight="400" line-height="24px" > - {{ MemberEmail }} joined {{ OrganizationName }} without access to - Privileged Controls because the organization has reached its - Privileged Controls seat limit. + {{ MemberEmail }} has been given access to {{ OrganizationName }}, + but not to Privileged Controls, because the organization has + reached its Privileged Controls seat limit. Date: Thu, 29 Oct 2026 17:58:12 -0500 Subject: [PATCH 32/44] [PM-45107] refactor: Point the Privileged Controls purchase handler at the grouped commands The purchase handler and its tests came from main after the Subscriptions.Organization commands were grouped into feature folders, so they now import the Privileged Controls commands from their new namespace. --- .../Handlers/PurchasePrivilegedControlsHandler.cs | 2 +- .../Handlers/PurchasePrivilegedControlsHandlerTests.cs | 2 +- .../OrganizationSubscriptionEndpointsRequestBindingTests.cs | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Subscriptions.Organization/Handlers/PurchasePrivilegedControlsHandler.cs b/src/Libraries/Subscriptions.Organization/Handlers/PurchasePrivilegedControlsHandler.cs index 8567347cb420..020f42ecadc2 100644 --- a/src/Libraries/Subscriptions.Organization/Handlers/PurchasePrivilegedControlsHandler.cs +++ b/src/Libraries/Subscriptions.Organization/Handlers/PurchasePrivilegedControlsHandler.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Models.Requests; using Bit.Subscriptions.Organization.Queries; diff --git a/test/Libraries/Subscriptions.Organization.Test/Handlers/PurchasePrivilegedControlsHandlerTests.cs b/test/Libraries/Subscriptions.Organization.Test/Handlers/PurchasePrivilegedControlsHandlerTests.cs index 29eb69d3ad44..e3a820cbbd4b 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Handlers/PurchasePrivilegedControlsHandlerTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Handlers/PurchasePrivilegedControlsHandlerTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Models.Requests; using NSubstitute; diff --git a/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs b/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs index 8ff2f891edfe..c13ffbe3f7f9 100644 --- a/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs @@ -9,6 +9,7 @@ using Bit.Core.Repositories; using Bit.Invoicing.InvoicePreviews.Models; using Bit.Subscriptions.Organization.Commands.InvoicePreviews; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Models.Requests; using Microsoft.AspNetCore.Builder; From 4424d5b27a2831eaa2668bd21a8285adb6f0c9d1 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:58:12 -0500 Subject: [PATCH 33/44] [PM-45107] fix: Give back staged invite Privileged Controls seats before Password Manager seats Password Manager seats can no longer drop below Privileged Controls seats. When a staged invite failed after both grew, giving back the Password Manager seats first was refused and left them charged. Privileged Controls seats now go back first, the same order email invite already uses. --- .../InviteStagedOrganizationUsersCommand.cs | 5 +++-- ...viteStagedOrganizationUsersCommandTests.cs | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index d715b8c2b3d6..6d9a9de36ceb 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -88,9 +88,10 @@ public async Task>> RunAsync(Invite var pamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersWithPrivilegedControls); if (!pamSeatReservation.Success) { + // Privileged Controls seats go back first: Password Manager seats can't drop below them. + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); await ReleaseSeatsAsync(organization, seatsToAdd); - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); return pamSeatReservation.Error!; } @@ -100,9 +101,9 @@ public async Task>> RunAsync(Invite } catch { + await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); await ReleaseSeatsAsync(organization, seatsToAdd); - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); throw; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index 9273c92d69eb..b8e3991540ab 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -993,6 +993,28 @@ await sutProvider.GetDependency() .RunAsync(organization, 5); } + [Theory, BitAutoData] + public async Task RunAsync_InviteSendFails_RevertsPamSeatsBeforePasswordManagerSeats( + Organization organization, List organizationUsers, Guid performedBy) + { + var sutProvider = GetSutProvider(); + var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 10); + ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); + AutoscalePamSeatsTo(sutProvider, organization, 7); + FailInviteSend(sutProvider); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + + // Password Manager seats can't drop below Privileged Controls seats, so those go back first. + Received.InOrder(() => + { + sutProvider.GetDependency() + .RunAsync(organization, 5); + sutProvider.GetDependency() + .AdjustSeatsAsync(organization.Id, -organizationUsers.Count); + }); + } + [Theory, BitAutoData] public async Task RunAsync_PamRevertFails_StillRethrowsTheSendFailure( Organization organization, List organizationUsers, Guid performedBy) From f8cd3f9185da43ad123bd7832a9889f47b2ea6b1 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:05:51 -0500 Subject: [PATCH 34/44] [PM-45107] refactor: Spell out Privileged Controls in names this branch adds Locals, test helpers and test names added for seat autoscaling used "Pam" as shorthand. They now say Privileged Controls, matching the product name used in the rest of the new code. Names that mirror existing members, such as AccessPam and BulkEnablePamAsync, are unchanged. --- .../src/Sso/Controllers/AccountController.cs | 14 ++--- .../Controllers/AccountControllerTest.cs | 22 ++++---- .../AcceptOrganizationInviteLinkCommand.cs | 8 +-- .../ConfirmOrganizationInviteLinkCommand.cs | 8 +-- .../v1/RestoreOrganizationUserCommand.cs | 8 +-- .../InviteStagedOrganizationUsersCommand.cs | 18 +++---- .../Implementations/OrganizationService.cs | 10 ++-- .../OrganizationUserControllerPutTests.cs | 2 +- ...ionUsersControllerAcceptInviteLinkTests.cs | 6 +-- ...cceptOrganizationInviteLinkCommandTests.cs | 10 ++-- ...nfirmOrganizationInviteLinkCommandTests.cs | 8 +-- .../RestoreOrganizationUserCommandTests.cs | 54 +++++++++---------- ...viteStagedOrganizationUsersCommandTests.cs | 42 +++++++-------- .../v2/UpdateOrganizationUserCommandTests.cs | 12 ++--- ...ervePrivilegedControlsSeatsCommandTests.cs | 8 +-- 15 files changed, 115 insertions(+), 115 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 1f28ce2701f8..9fb89e010768 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -702,7 +702,7 @@ SsoConfigurationData ssoConfigData // won't complete. Staged rows aren't seat-counted; a Staged→Invited promotion // consumes a seat and must gate here alongside the fresh-JIT case. var willPromoteStagedOrgUser = possibleOrgUser?.Status == OrganizationUserStatusType.Staged; - PrivilegedControlsSeatReservationResult? pamSeatReservation = null; + PrivilegedControlsSeatReservationResult? privilegedControlsSeatReservation = null; if (possibleOrgUser == null || willPromoteStagedOrgUser) { @@ -710,9 +710,9 @@ SsoConfigurationData ssoConfigData if (willPromoteStagedOrgUser) { - pamSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync( + privilegedControlsSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync( organization, possibleOrgUser!.AccessPam ? 1 : 0); - if (!pamSeatReservation.Success) + if (!privilegedControlsSeatReservation.Success) { possibleOrgUser.AccessPam = false; } @@ -802,7 +802,7 @@ SsoConfigurationData ssoConfigData possibleOrgUser.RevisionDate = DateTime.UtcNow; await _organizationUserRepository.ReplaceAsync(possibleOrgUser); - if (pamSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + if (privilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { await _sendPrivilegedControlsSeatNeededEmailCommand.RunAsync( organization, possibleOrgUser.Email ?? newUser.Email); @@ -881,9 +881,9 @@ private async Task PromoteStagedOrgUserAndSendInviteAsync( var previousRevisionDate = orgUser.RevisionDate; var previousAccessPam = orgUser.AccessPam; - var pamSeatReservation = + var privilegedControlsSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, orgUser.AccessPam ? 1 : 0); - if (!pamSeatReservation.Success) + if (!privilegedControlsSeatReservation.Success) { orgUser.AccessPam = false; } @@ -915,7 +915,7 @@ await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( throw; } - if (pamSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) + if (privilegedControlsSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) { await _sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, orgUser.Email!); } diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index e0cc5c3a9c54..d43870aa872f 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -1067,14 +1067,14 @@ public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingUserButNoOr } [Theory, BitAutoData] - public async Task ExternalCallback_ExistingUserStagedWithPam_AtLimit_PromotesWithoutPam( + public async Task ExternalCallback_ExistingUserStagedWithPrivilegedControls_AtLimit_PromotesWithoutPrivilegedControls( SutProvider sutProvider) { var orgId = Guid.NewGuid(); var email = "staged.user@example.com"; var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false }; var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; - var orgUser = BuildStagedOrgUserWithPam(orgId, email); + var orgUser = BuildStagedOrgUserWithPrivilegedControls(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( sutProvider, organization, new PrivilegedControlsSeatLimitReachedError()); @@ -1094,13 +1094,13 @@ await Assert.ThrowsAsync } [Theory, BitAutoData] - public async Task ExternalCallback_NewUserStagedWithPam_AtLimit_ProvisionsWithoutPam( + public async Task ExternalCallback_NewUserStagedWithPrivilegedControls_AtLimit_ProvisionsWithoutPrivilegedControls( SutProvider sutProvider) { var orgId = Guid.NewGuid(); var email = "staged.new.user@example.com"; var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; - var orgUser = BuildStagedOrgUserWithPam(orgId, email); + var orgUser = BuildStagedOrgUserWithPrivilegedControls(orgId, email); ArrangeStagedMember(sutProvider, organization, null, orgUser, email); sutProvider.GetDependency() .RunAsync(orgId, PolicyType.TwoFactorAuthentication) @@ -1122,14 +1122,14 @@ public async Task ExternalCallback_NewUserStagedWithPam_AtLimit_ProvisionsWithou } [Theory, BitAutoData] - public async Task ExternalCallback_ExistingUserStagedWithPam_ReservationFailed_PromotesWithoutPamOrEmail( + public async Task ExternalCallback_ExistingUserStagedWithPrivilegedControls_ReservationFailed_PromotesWithoutPrivilegedControlsOrEmail( SutProvider sutProvider) { var orgId = Guid.NewGuid(); var email = "staged.user@example.com"; var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false }; var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; - var orgUser = BuildStagedOrgUserWithPam(orgId, email); + var orgUser = BuildStagedOrgUserWithPrivilegedControls(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( sutProvider, organization, new PrivilegedControlsSeatReservationUnexpectedError()); @@ -1147,13 +1147,13 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task ExternalCallback_NewUserStagedWithPam_ReservationFailed_ProvisionsWithoutPamOrEmail( + public async Task ExternalCallback_NewUserStagedWithPrivilegedControls_ReservationFailed_ProvisionsWithoutPrivilegedControlsOrEmail( SutProvider sutProvider) { var orgId = Guid.NewGuid(); var email = "staged.new.user@example.com"; var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; - var orgUser = BuildStagedOrgUserWithPam(orgId, email); + var orgUser = BuildStagedOrgUserWithPrivilegedControls(orgId, email); ArrangeStagedMember(sutProvider, organization, null, orgUser, email); sutProvider.GetDependency() .RunAsync(orgId, PolicyType.TwoFactorAuthentication) @@ -1173,14 +1173,14 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task ExternalCallback_ExistingUserStagedWithPam_InviteSendFails_RestoresPam( + public async Task ExternalCallback_ExistingUserStagedWithPrivilegedControls_InviteSendFails_RestoresPrivilegedControls( SutProvider sutProvider) { var orgId = Guid.NewGuid(); var email = "staged.user@example.com"; var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false }; var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; - var orgUser = BuildStagedOrgUserWithPam(orgId, email); + var orgUser = BuildStagedOrgUserWithPrivilegedControls(orgId, email); ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( sutProvider, organization, new PrivilegedControlsSeatLimitReachedError()); @@ -1212,7 +1212,7 @@ private static ISendPrivilegedControlsSeatNeededEmailCommand ArrangePrivilegedCo return sutProvider.GetDependency(); } - private static OrganizationUser BuildStagedOrgUserWithPam(Guid orgId, string email) => + private static OrganizationUser BuildStagedOrgUserWithPrivilegedControls(Guid orgId, string email) => new() { Id = Guid.NewGuid(), diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs index 20a48bcd9af0..0113e287f481 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs @@ -75,7 +75,7 @@ private async Task> AcceptExistingOrgUserAsync( { var organization = validationResult.Organization; - PrivilegedControlsSeatReservationResult? pamSeatReservation = null; + PrivilegedControlsSeatReservationResult? privilegedControlsSeatReservation = null; if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -84,9 +84,9 @@ private async Task> AcceptExistingOrgUserAsync( return seatReservationError; } - pamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync( + privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync( organization, existingOrganizationUser.AccessPam ? 1 : 0); - if (!pamSeatReservation.Success) + if (!privilegedControlsSeatReservation.Success) { existingOrganizationUser.AccessPam = false; } @@ -105,7 +105,7 @@ private async Task> AcceptExistingOrgUserAsync( await organizationUserRepository.ReplaceAsync(existingOrganizationUser); - if (pamSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + if (privilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { await sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, memberEmail); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index 36e2e9e8036a..404f6e081a24 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -120,7 +120,7 @@ private async Task> AddUserToOrganizationAsync(C private async Task> ConfirmExistingMembershipAsync( Organization organization, OrganizationUser existingOrganizationUser, User user, string orgUserKey) { - PrivilegedControlsSeatReservationResult? pamSeatReservation = null; + PrivilegedControlsSeatReservationResult? privilegedControlsSeatReservation = null; if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -129,9 +129,9 @@ private async Task> ConfirmExistingMembershipAsy return seatReservationError; } - pamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync( + privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync( organization, existingOrganizationUser.AccessPam ? 1 : 0); - if (!pamSeatReservation.Success) + if (!privilegedControlsSeatReservation.Success) { existingOrganizationUser.AccessPam = false; } @@ -146,7 +146,7 @@ private async Task> ConfirmExistingMembershipAsy await organizationUserRepository.ReplaceAsync(existingOrganizationUser); - if (pamSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + if (privilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { await sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, memberEmail); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index cfb4a1942549..600cdc7ef3ae 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -120,7 +120,7 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled); var status = organizationUser.GetPriorActiveOrganizationUserStatusType(); - PrivilegedControlsSeatReservationResult scimPamSeatReservation = null; + PrivilegedControlsSeatReservationResult scimPrivilegedControlsSeatReservation = null; if (organization.UsePam && organizationUser.AccessPam && PrivilegedControlsSeatOccupancy.OccupiesSeat(status)) { @@ -133,8 +133,8 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, // without access and the Owners are emailed. Public API restores don't check seats. else if (systemUser == EventSystemUser.SCIM) { - scimPamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); - if (!scimPamSeatReservation.Success) + scimPrivilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!scimPrivilegedControlsSeatReservation.Success) { organizationUser.AccessPam = false; // RestoreAsync only writes the status, so the removed access has to be saved separately. @@ -151,7 +151,7 @@ await organizationUserRepository.StartInviteReminderSequencesAsync( [organizationUser.Id], timeProvider.GetUtcNow().UtcDateTime); } - if (scimPamSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + if (scimPrivilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { var memberEmail = organizationUser.Email ?? (await userRepository.GetByIdAsync(organizationUser.UserId!.Value)).Email; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index 6d9a9de36ceb..e53501bb246e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -74,7 +74,7 @@ public async Task>> RunAsync(Invite // Captured before the subscription grows, because a successful update writes the new total onto the entity. var initialSecretsManagerSeats = organization.SmSeats; - var initialPamSeats = organization.PamSeats; + var initialPrivilegedControlsSeats = organization.PamSeats; // Password Manager seats first: the subscription rejects more Secrets Manager seats than it has PM seats. var secretsManagerSeatReservationError = await AdjustSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, dryRun: false); @@ -85,14 +85,14 @@ public async Task>> RunAsync(Invite } var membersWithPrivilegedControls = eligible.Count(organizationUser => organizationUser.AccessPam); - var pamSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersWithPrivilegedControls); - if (!pamSeatReservation.Success) + var privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, membersWithPrivilegedControls); + if (!privilegedControlsSeatReservation.Success) { // Privileged Controls seats go back first: Password Manager seats can't drop below them. - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); + await ReleasePrivilegedControlsSeatsAsync(organization, initialPrivilegedControlsSeats); await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); await ReleaseSeatsAsync(organization, seatsToAdd); - return pamSeatReservation.Error!; + return privilegedControlsSeatReservation.Error!; } try @@ -101,7 +101,7 @@ public async Task>> RunAsync(Invite } catch { - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); + await ReleasePrivilegedControlsSeatsAsync(organization, initialPrivilegedControlsSeats); await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); await ReleaseSeatsAsync(organization, seatsToAdd); throw; @@ -412,16 +412,16 @@ private async Task ReleaseSeatsAsync(Organization organization, int seatsToRelea /// Restores the Privileged Controls seat count captured before the reservation when a later step means /// nobody will be invited. ///
- private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization, int? initialPamSeats) + private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization, int? initialPrivilegedControlsSeats) { - if (initialPamSeats is null || initialPamSeats == organization.PamSeats) + if (initialPrivilegedControlsSeats is null || initialPrivilegedControlsSeats == organization.PamSeats) { return; } try { - await revertPrivilegedControlsSeatsCommand.RunAsync(organization, initialPamSeats.Value); + await revertPrivilegedControlsSeatsCommand.RunAsync(organization, initialPrivilegedControlsSeats.Value); } catch (Exception ex) { diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 82d4f061016a..a79398a523dc 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -710,7 +710,7 @@ await _organizationUserRepository.SelectKnownEmailsAsync(organizationId, request var privilegedControlsSeatsToReserve = organization.UsePam ? stagedInvitations.Values.Count(s => s.OrgUser.AccessPam) : 0; - var initialPamSeats = organization.PamSeats; + var initialPrivilegedControlsSeats = organization.PamSeats; // Staged rows whose promotion has actually committed, and so must be put back if the batch fails. var promotedStagedUsers = new List(); @@ -776,7 +776,7 @@ await _organizationUserRepository.SelectKnownEmailsAsync(organizationId, request // Demote any staged rows that were promoted before the failure. await RestorePromotedStagedUsersAsync(promotedStagedUsers, stagedSnapshots); - await ReleasePrivilegedControlsSeatsAsync(organization, initialPamSeats); + await ReleasePrivilegedControlsSeatsAsync(organization, initialPrivilegedControlsSeats); var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id); @@ -868,16 +868,16 @@ private async Task ReservePrivilegedControlsSeatsAsync(Organization organization /// /// Restores the Privileged Controls seat count captured before the reservation when the invite fails. /// - private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization, int? initialPamSeats) + private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization, int? initialPrivilegedControlsSeats) { - if (initialPamSeats is null || initialPamSeats == organization.PamSeats) + if (initialPrivilegedControlsSeats is null || initialPrivilegedControlsSeats == organization.PamSeats) { return; } try { - await _revertPrivilegedControlsSeatsCommand.RunAsync(organization, initialPamSeats.Value); + await _revertPrivilegedControlsSeatsCommand.RunAsync(organization, initialPrivilegedControlsSeats.Value); } catch (Exception ex) { diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs index 54debc33c04d..1db77989121e 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs @@ -419,7 +419,7 @@ public async Task Put_AdminGrantingPamToSelf_Succeeds() } [Fact] - public async Task Put_GrantingPamAtSeatLimit_ReturnsBadRequestAndDoesNotGrant() + public async Task Put_GrantingPrivilegedControlsAtSeatLimit_ReturnsBadRequestAndDoesNotGrant() { await SetUsePamAsync(true); _organization.PamSeats = 1; diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs index 4f1c42d8d4f4..3e874220dc26 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs @@ -441,9 +441,9 @@ public async Task AcceptInviteLink_WithStagedMembership_WithValidRequest_Returns // Staged path: a staged member given Privileged Controls joins without it when the organization is at its // Privileged Controls seat limit. [Fact] - public async Task AcceptInviteLink_WithStagedMembershipWithPam_AtPamSeatLimit_JoinsWithoutPam() + public async Task AcceptInviteLink_WithStagedMembershipWithPrivilegedControls_AtPrivilegedControlsSeatLimit_JoinsWithoutPrivilegedControls() { - await FillPamSeatsAtLimitAsync(); + await FillPrivilegedControlsSeatsAtLimitAsync(); var created = await CreateInviteLinkAsync(); var (joinerEmail, joinerClient) = await RegisterAndLoginJoinerAsync(); await CreateStagedMembershipAsync(joinerEmail, accessPam: true); @@ -658,7 +658,7 @@ await organizationUserRepository.CreateAsync(new OrganizationUser } // Gives the organization one Privileged Controls seat, already taken, with no room to autoscale. - private async Task FillPamSeatsAtLimitAsync() + private async Task FillPrivilegedControlsSeatsAtLimitAsync() { _organization.UsePam = true; _organization.PamSeats = 1; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs index d6da603a625c..e729d2755b2d 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs @@ -250,7 +250,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task AcceptAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( + public async Task AcceptAsync_StagedMemberWithPrivilegedControls_AtLimit_JoinsWithoutPrivilegedControls( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -281,7 +281,7 @@ public async Task AcceptAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( } [Theory, BitAutoData] - public async Task AcceptAsync_StagedMemberWithPam_ReservationFailed_JoinsWithoutPamOrEmail( + public async Task AcceptAsync_StagedMemberWithPrivilegedControls_ReservationFailed_JoinsWithoutPrivilegedControlsOrEmail( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -308,7 +308,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task AcceptAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( + public async Task AcceptAsync_StagedMemberWithPrivilegedControls_SeatAvailable_JoinsWithPrivilegedControls( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -335,7 +335,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task AcceptAsync_StagedMemberWithoutPam_ReservesNoPamSeat( + public async Task AcceptAsync_StagedMemberWithoutPrivilegedControls_ReservesNoPrivilegedControlsSeat( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -353,7 +353,7 @@ public async Task AcceptAsync_StagedMemberWithoutPam_ReservesNoPamSeat( } [Theory, BitAutoData] - public async Task AcceptAsync_NotStaged_DoesNotReservePamSeat( + public async Task AcceptAsync_NotStaged_DoesNotReservePrivilegedControlsSeat( Organization organization, OrganizationInviteLink inviteLink, User user, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs index abb90bfbdd2d..d5d52f70bbbd 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -322,7 +322,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task ConfirmAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( + public async Task ConfirmAsync_StagedMemberWithPrivilegedControls_AtLimit_JoinsWithoutPrivilegedControls( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -353,7 +353,7 @@ public async Task ConfirmAsync_StagedMemberWithPam_AtLimit_JoinsWithoutPam( } [Theory, BitAutoData] - public async Task ConfirmAsync_StagedMemberWithPam_ReservationFailed_JoinsWithoutPamOrEmail( + public async Task ConfirmAsync_StagedMemberWithPrivilegedControls_ReservationFailed_JoinsWithoutPrivilegedControlsOrEmail( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -380,7 +380,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task ConfirmAsync_StagedMemberWithPam_SeatAvailable_JoinsWithPam( + public async Task ConfirmAsync_StagedMemberWithPrivilegedControls_SeatAvailable_JoinsWithPrivilegedControls( Organization organization, OrganizationInviteLink inviteLink, User user, @@ -407,7 +407,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task ConfirmAsync_NotStaged_DoesNotReservePamSeat( + public async Task ConfirmAsync_NotStaged_DoesNotReservePrivilegedControlsSeat( Organization organization, OrganizationInviteLink inviteLink, User user, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 52510f5139b0..0a503350bddc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -1237,7 +1237,7 @@ await organizationUserRepository } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_ReservesOneSeat( + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_ReservesOneSeat( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1259,7 +1259,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_AtLimit_ThrowsSeatLimitMessage( + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_AtLimit_ThrowsSeatLimitMessage( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1283,7 +1283,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_SelfHostedShortfall_ThrowsSelfHostMessage( + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_SelfHostedShortfall_ThrowsSelfHostMessage( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1307,7 +1307,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivesBackThePasswordManagerSeatItBought( + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_AtLimit_GivesBackThePasswordManagerSeatItBought( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1335,7 +1335,7 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_ReservationFails_GivesBackThePasswordManagerSeatAndThrowsBadRequest( + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_ReservationFails_GivesBackThePasswordManagerSeatAndThrowsBadRequest( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1361,7 +1361,7 @@ public async Task RestoreUser_Admin_MemberWithPam_ReservationFails_GivesBackTheP } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_ReservationFailsUnexpectedly_GivesBackThePasswordManagerSeatAndThrowsWithTheOriginalFailure( + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_ReservationFailsUnexpectedly_GivesBackThePasswordManagerSeatAndThrowsWithTheOriginalFailure( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1386,7 +1386,7 @@ public async Task RestoreUser_Admin_MemberWithPam_ReservationFailsUnexpectedly_G } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivingBackTheSeatFails_StillThrowsSeatLimitMessage( + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_AtLimit_GivingBackTheSeatFails_StillThrowsSeatLimitMessage( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1412,7 +1412,7 @@ public async Task RestoreUser_Admin_MemberWithPam_AtLimit_GivingBackTheSeatFails } [Theory, BitAutoData] - public async Task RestoreUser_Admin_MemberWithPam_AtLimitWithFreePasswordManagerSeat_GivesNothingBack( + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_AtLimitWithFreePasswordManagerSeat_GivesNothingBack( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -1455,7 +1455,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_Scim_AtLimit_RestoresWithoutPamAndSaves( + public async Task RestoreUser_Scim_AtLimit_RestoresWithoutPrivilegedControlsAndSaves( Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) @@ -1482,7 +1482,7 @@ public async Task RestoreUser_Scim_AtLimit_RestoresWithoutPamAndSaves( } [Theory, BitAutoData] - public async Task RestoreUser_Scim_SelfHostedShortfall_RestoresWithoutPamOrEmail( + public async Task RestoreUser_Scim_SelfHostedShortfall_RestoresWithoutPrivilegedControlsOrEmail( Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) @@ -1531,7 +1531,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_Scim_ReservationFailed_RestoresWithoutPamOrEmail( + public async Task RestoreUser_Scim_ReservationFailed_RestoresWithoutPrivilegedControlsOrEmail( Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) @@ -1557,7 +1557,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_Scim_SeatAvailable_RestoresWithPam( + public async Task RestoreUser_Scim_SeatAvailable_RestoresWithPrivilegedControls( Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) @@ -1580,7 +1580,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_PublicApi_MemberWithPam_DoesNotTouchSeats( + public async Task RestoreUser_PublicApi_MemberWithPrivilegedControls_DoesNotTouchSeats( Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) @@ -1599,31 +1599,31 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUsers_Bulk_CountsOnlyRevokedMembersWithPamReturningToSeatUsingStatus( + public async Task RestoreUsers_Bulk_CountsOnlyRevokedMembersWithPrivilegedControlsReturningToSeatUsingStatus( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser confirmedWithPam, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser acceptedWithPam, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser invitedWithPam, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser stagedWithPam, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser confirmedWithoutPam, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser confirmedWithPrivilegedControls, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser acceptedWithPrivilegedControls, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser invitedWithPrivilegedControls, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser stagedWithPrivilegedControls, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser confirmedWithoutPrivilegedControls, SutProvider sutProvider) { organization.UsePam = true; var userService = Substitute.For(); - RestoreUser_Setup(organization, owner, confirmedWithPam, sutProvider); - confirmedWithPam.StatusNew = OrganizationUserStatusTypeNew.Confirmed; - acceptedWithPam.StatusNew = OrganizationUserStatusTypeNew.Accepted; - invitedWithPam.StatusNew = OrganizationUserStatusTypeNew.Invited; - stagedWithPam.StatusNew = OrganizationUserStatusTypeNew.Staged; - confirmedWithoutPam.StatusNew = OrganizationUserStatusTypeNew.Confirmed; - OrganizationUser[] orgUsers = [confirmedWithPam, acceptedWithPam, invitedWithPam, stagedWithPam, confirmedWithoutPam]; + RestoreUser_Setup(organization, owner, confirmedWithPrivilegedControls, sutProvider); + confirmedWithPrivilegedControls.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + acceptedWithPrivilegedControls.StatusNew = OrganizationUserStatusTypeNew.Accepted; + invitedWithPrivilegedControls.StatusNew = OrganizationUserStatusTypeNew.Invited; + stagedWithPrivilegedControls.StatusNew = OrganizationUserStatusTypeNew.Staged; + confirmedWithoutPrivilegedControls.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + OrganizationUser[] orgUsers = [confirmedWithPrivilegedControls, acceptedWithPrivilegedControls, invitedWithPrivilegedControls, stagedWithPrivilegedControls, confirmedWithoutPrivilegedControls]; foreach (var orgUser in orgUsers) { orgUser.OrganizationId = organization.Id; orgUser.AccessPam = true; } - confirmedWithoutPam.AccessPam = false; + confirmedWithoutPrivilegedControls.AccessPam = false; sutProvider.GetDependency() .GetManyAsync(Arg.Any>()) .Returns(orgUsers); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs index b8e3991540ab..f6af8bc7726f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -741,18 +741,18 @@ private static void ArrangePrivilegedControls( /// /// Makes the reservation behave like a successful autoscale, which writes the grown total onto the entity. /// - private static void AutoscalePamSeatsTo( - SutProvider sutProvider, Organization organization, int newPamSeats) => + private static void AutoscalePrivilegedControlsSeatsTo( + SutProvider sutProvider, Organization organization, int newPrivilegedControlsSeats) => sutProvider.GetDependency() .RunAsync(organization, Arg.Any()) .Returns(callInfo => { - organization.PamSeats = newPamSeats; + organization.PamSeats = newPrivilegedControlsSeats; return new PrivilegedControlsSeatReservationResult(); }); [Theory, BitAutoData] - public async Task RunAsync_StagedMembersWithPam_ReservesTheirCount( + public async Task RunAsync_StagedMembersWithPrivilegedControls_ReservesTheirCount( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -767,7 +767,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_ReservesPasswordManagerSeatsBeforePamSeats( + public async Task RunAsync_ReservesPasswordManagerSeatsBeforePrivilegedControlsSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -787,7 +787,7 @@ public async Task RunAsync_ReservesPasswordManagerSeatsBeforePamSeats( } [Theory, BitAutoData] - public async Task RunAsync_PamAtLimit_ReturnsSeatLimitReachedAndReleasesPasswordManagerSeats( + public async Task RunAsync_PrivilegedControlsAtLimit_ReturnsSeatLimitReachedAndReleasesPasswordManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -809,7 +809,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamAtLimit_ReleasesSecretsManagerSeats( + public async Task RunAsync_PrivilegedControlsAtLimit_ReleasesSecretsManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -828,7 +828,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamReservationFails_ReturnsSeatExpansionFailedAndReleasesPasswordManagerSeats( + public async Task RunAsync_PrivilegedControlsReservationFails_ReturnsSeatExpansionFailedAndReleasesPasswordManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -851,7 +851,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamReservationFailsUnexpectedly_ReturnsInternalErrorAndReleasesPasswordManagerSeats( + public async Task RunAsync_PrivilegedControlsReservationFailsUnexpectedly_ReturnsInternalErrorAndReleasesPasswordManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -875,7 +875,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamReservationFailsUnexpectedly_ReleasesSecretsManagerSeats( + public async Task RunAsync_PrivilegedControlsReservationFailsUnexpectedly_ReleasesSecretsManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -896,7 +896,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamReservationFailsAfterSeatsChanged_RevertsPamSeats( + public async Task RunAsync_PrivilegedControlsReservationFailsAfterSeatsChanged_RevertsPrivilegedControlsSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -920,7 +920,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_PamSelfHostedShortfall_ReturnsCannotAutoscaleOnSelfHost( + public async Task RunAsync_PrivilegedControlsSelfHostedShortfall_ReturnsCannotAutoscaleOnSelfHost( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -937,7 +937,7 @@ public async Task RunAsync_PamSelfHostedShortfall_ReturnsCannotAutoscaleOnSelfHo } [Theory, BitAutoData] - public async Task RunAsync_PasswordManagerReservationFails_DoesNotReservePamSeats( + public async Task RunAsync_PasswordManagerReservationFails_DoesNotReservePrivilegedControlsSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -957,7 +957,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_SecretsManagerAdjustFails_DoesNotReservePamSeats( + public async Task RunAsync_SecretsManagerAdjustFails_DoesNotReservePrivilegedControlsSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); @@ -977,13 +977,13 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_InviteSendFails_RevertsPamSeatsAndRethrows( + public async Task RunAsync_InviteSendFails_RevertsPrivilegedControlsSeatsAndRethrows( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); var request = Arrange(sutProvider, organization, organizationUsers, performedBy); ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); - AutoscalePamSeatsTo(sutProvider, organization, 7); + AutoscalePrivilegedControlsSeatsTo(sutProvider, organization, 7); FailInviteSend(sutProvider); await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); @@ -994,13 +994,13 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RunAsync_InviteSendFails_RevertsPamSeatsBeforePasswordManagerSeats( + public async Task RunAsync_InviteSendFails_RevertsPrivilegedControlsSeatsBeforePasswordManagerSeats( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); var request = Arrange(sutProvider, organization, organizationUsers, performedBy, seats: 10, occupiedSeats: 10); ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); - AutoscalePamSeatsTo(sutProvider, organization, 7); + AutoscalePrivilegedControlsSeatsTo(sutProvider, organization, 7); FailInviteSend(sutProvider); await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); @@ -1016,13 +1016,13 @@ public async Task RunAsync_InviteSendFails_RevertsPamSeatsBeforePasswordManagerS } [Theory, BitAutoData] - public async Task RunAsync_PamRevertFails_StillRethrowsTheSendFailure( + public async Task RunAsync_PrivilegedControlsRevertFails_StillRethrowsTheSendFailure( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); var request = Arrange(sutProvider, organization, organizationUsers, performedBy); ArrangePrivilegedControls(organization, organizationUsers, membersWithAccess: 2, pamSeats: 5); - AutoscalePamSeatsTo(sutProvider, organization, 7); + AutoscalePrivilegedControlsSeatsTo(sutProvider, organization, 7); FailInviteSend(sutProvider); sutProvider.GetDependency() .RunAsync(organization, 5) @@ -1032,7 +1032,7 @@ public async Task RunAsync_PamRevertFails_StillRethrowsTheSendFailure( } [Theory, BitAutoData] - public async Task RunAsync_NoPamSeatsAdded_DoesNotRevert( + public async Task RunAsync_NoPrivilegedControlsSeatsAdded_DoesNotRevert( Organization organization, List organizationUsers, Guid performedBy) { var sutProvider = GetSutProvider(); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index 34342cdc6ece..b608fcd81a9a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -174,7 +174,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task UpdateUserAsync_EnablingPam_ConfirmedMember_ReservesOneSeat( + public async Task UpdateUserAsync_EnablingPrivilegedControls_ConfirmedMember_ReservesOneSeat( SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) @@ -192,7 +192,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task UpdateUserAsync_EnablingPam_AtLimit_ReturnsSeatLimitReachedAndDoesNotSave( + public async Task UpdateUserAsync_EnablingPrivilegedControls_AtLimit_ReturnsSeatLimitReachedAndDoesNotSave( SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) @@ -214,7 +214,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task UpdateUserAsync_EnablingPam_ReservationFails_ReturnsTheReservationErrorAndDoesNotSave( + public async Task UpdateUserAsync_EnablingPrivilegedControls_ReservationFails_ReturnsTheReservationErrorAndDoesNotSave( SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) @@ -239,7 +239,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task UpdateUserAsync_EnablingPam_RevokedMember_DoesNotReserve( + public async Task UpdateUserAsync_EnablingPrivilegedControls_RevokedMember_DoesNotReserve( SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser organizationUser) @@ -257,7 +257,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task UpdateUserAsync_EnablingPam_StagedMember_DoesNotReserve( + public async Task UpdateUserAsync_EnablingPrivilegedControls_StagedMember_DoesNotReserve( SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Staged, OrganizationUserType.User)] OrganizationUser organizationUser) @@ -275,7 +275,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task UpdateUserAsync_AlreadyHasPam_DoesNotReserve( + public async Task UpdateUserAsync_AlreadyHasPrivilegedControls_DoesNotReserve( SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs index 76add3b5e2e9..f3db2ea86b67 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs @@ -43,7 +43,7 @@ public async Task RunAsync_NoSeatsRequested_SucceedsWithoutCallingAutoscale(int } [Fact] - public async Task RunAsync_PamOff_SucceedsWithoutCallingAutoscale() + public async Task RunAsync_PrivilegedControlsOff_SucceedsWithoutCallingAutoscale() { var organization = CreateOrganization(usePam: false); @@ -56,7 +56,7 @@ public async Task RunAsync_PamOff_SucceedsWithoutCallingAutoscale() [Theory] [InlineData(false)] [InlineData(true)] - public async Task RunAsync_NullPamSeats_SucceedsWithoutCheckingSeats(bool selfHosted) + public async Task RunAsync_NullPrivilegedControlsSeats_SucceedsWithoutCheckingSeats(bool selfHosted) { var organization = CreateOrganization(pamSeats: null); _environment.SelfHosted.Returns(selfHosted); @@ -69,7 +69,7 @@ public async Task RunAsync_NullPamSeats_SucceedsWithoutCheckingSeats(bool selfHo } [Fact] - public async Task RunAsync_Cloud_ZeroPamSeats_ReturnsLimitReachedWithoutCallingAutoscale() + public async Task RunAsync_Cloud_ZeroPrivilegedControlsSeats_ReturnsLimitReachedWithoutCallingAutoscale() { var organization = CreateOrganization(pamSeats: 0); @@ -80,7 +80,7 @@ public async Task RunAsync_Cloud_ZeroPamSeats_ReturnsLimitReachedWithoutCallingA } [Fact] - public async Task RunAsync_SelfHosted_ZeroPamSeats_ReturnsSelfHostedShortfall() + public async Task RunAsync_SelfHosted_ZeroPrivilegedControlsSeats_ReturnsSelfHostedShortfall() { var organization = CreateOrganization(pamSeats: 0); _environment.SelfHosted.Returns(true); From 9eca4dec4a8c29727f8b6f7c9a8e7a75f3adb7f4 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:29:21 -0600 Subject: [PATCH 35/44] [PM-45107] refactor: Reserve a Privileged Controls seat only for joining members with access SSO and invite-link joins called the reserve command with zero seats for staged members without access. They now skip the call for those members and reserve one seat for members who have access. Behavior is unchanged. --- .../src/Sso/Controllers/AccountController.cs | 18 ++++++++++-------- .../AcceptOrganizationInviteLinkCommand.cs | 10 ++++++---- .../ConfirmOrganizationInviteLinkCommand.cs | 10 ++++++---- ...AcceptOrganizationInviteLinkCommandTests.cs | 4 +++- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 9fb89e010768..704b54269e10 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -708,10 +708,9 @@ SsoConfigurationData ssoConfigData { await EnsureSeatAvailableAsync(organization); - if (willPromoteStagedOrgUser) + if (willPromoteStagedOrgUser && possibleOrgUser!.AccessPam) { - privilegedControlsSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync( - organization, possibleOrgUser!.AccessPam ? 1 : 0); + privilegedControlsSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); if (!privilegedControlsSeatReservation.Success) { possibleOrgUser.AccessPam = false; @@ -881,11 +880,14 @@ private async Task PromoteStagedOrgUserAndSendInviteAsync( var previousRevisionDate = orgUser.RevisionDate; var previousAccessPam = orgUser.AccessPam; - var privilegedControlsSeatReservation = - await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, orgUser.AccessPam ? 1 : 0); - if (!privilegedControlsSeatReservation.Success) + PrivilegedControlsSeatReservationResult? privilegedControlsSeatReservation = null; + if (orgUser.AccessPam) { - orgUser.AccessPam = false; + privilegedControlsSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!privilegedControlsSeatReservation.Success) + { + orgUser.AccessPam = false; + } } orgUser.Status = OrganizationUserStatusType.Invited; @@ -915,7 +917,7 @@ await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( throw; } - if (privilegedControlsSeatReservation.Error is PrivilegedControlsSeatLimitReachedError) + if (privilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) { await _sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, orgUser.Email!); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs index 0113e287f481..ccc5e112069d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs @@ -84,11 +84,13 @@ private async Task> AcceptExistingOrgUserAsync( return seatReservationError; } - privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync( - organization, existingOrganizationUser.AccessPam ? 1 : 0); - if (!privilegedControlsSeatReservation.Success) + if (existingOrganizationUser.AccessPam) { - existingOrganizationUser.AccessPam = false; + privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!privilegedControlsSeatReservation.Success) + { + existingOrganizationUser.AccessPam = false; + } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs index 404f6e081a24..047e455577a4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -129,11 +129,13 @@ private async Task> ConfirmExistingMembershipAsy return seatReservationError; } - privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync( - organization, existingOrganizationUser.AccessPam ? 1 : 0); - if (!privilegedControlsSeatReservation.Success) + if (existingOrganizationUser.AccessPam) { - existingOrganizationUser.AccessPam = false; + privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!privilegedControlsSeatReservation.Success) + { + existingOrganizationUser.AccessPam = false; + } } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs index e729d2755b2d..700d681149d3 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs @@ -349,7 +349,9 @@ public async Task AcceptAsync_StagedMemberWithoutPrivilegedControls_ReservesNoPr var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); Assert.True(result.IsSuccess); - await sutProvider.GetDependency().Received(1).RunAsync(organization, 0); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); } [Theory, BitAutoData] From 3ee9862ce160ff35ed22a6008dcd13f49dddc5da Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:50:40 -0600 Subject: [PATCH 36/44] [PM-45107] refactor: Point the Privileged Controls removal handler at the grouped commands The removal handler and its tests came from main after the Subscriptions.Organization commands were grouped into feature folders, so they now import the Privileged Controls commands from their new namespace. --- .../Handlers/RemovePrivilegedControlsHandler.cs | 2 +- .../Handlers/RemovePrivilegedControlsHandlerTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Subscriptions.Organization/Handlers/RemovePrivilegedControlsHandler.cs b/src/Libraries/Subscriptions.Organization/Handlers/RemovePrivilegedControlsHandler.cs index 3f1db1521334..40d5fda575dd 100644 --- a/src/Libraries/Subscriptions.Organization/Handlers/RemovePrivilegedControlsHandler.cs +++ b/src/Libraries/Subscriptions.Organization/Handlers/RemovePrivilegedControlsHandler.cs @@ -1,6 +1,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Queries; namespace Bit.Subscriptions.Organization.Handlers; diff --git a/test/Libraries/Subscriptions.Organization.Test/Handlers/RemovePrivilegedControlsHandlerTests.cs b/test/Libraries/Subscriptions.Organization.Test/Handlers/RemovePrivilegedControlsHandlerTests.cs index 41b24e782921..2f07f6e269dc 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Handlers/RemovePrivilegedControlsHandlerTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Handlers/RemovePrivilegedControlsHandlerTests.cs @@ -1,6 +1,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Subscriptions.Organization.Commands; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Handlers; using NSubstitute; using Xunit; From e8d08e4c6a3713102531c3205110f01261d7e542 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:52:16 -0600 Subject: [PATCH 37/44] [PM-45107] test: Give the invite-link not-staged test an existing invited member Invite-link acceptance now gets the existing membership from its validator, so stubbing the repository lookup left the test on the new-membership path. The validator now returns an invited member with access, and the test checks it is accepted without reserving a seat. --- .../AcceptOrganizationInviteLinkCommandTests.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs index 700d681149d3..b42cfff6a6a8 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs @@ -363,16 +363,24 @@ public async Task AcceptAsync_NotStaged_DoesNotReservePrivilegedControlsSeat( SutProvider sutProvider) { SetupHappyPath(organization, inviteLink, user, sutProvider); + invitedOrganizationUser.OrganizationId = organization.Id; invitedOrganizationUser.Status = OrganizationUserStatusType.Invited; invitedOrganizationUser.Email = user.Email; invitedOrganizationUser.UserId = null; - sutProvider.GetDependency() - .GetByOrganizationEmailAsync(organization.Id, user.Email) - .Returns(invitedOrganizationUser); + invitedOrganizationUser.AccessPam = true; + SetupValidationResult(sutProvider, new AcceptInviteLinkMembershipValidationResult + { + Organization = organization, + ExistingOrganizationUser = invitedOrganizationUser, + }); var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => + ou.Id == invitedOrganizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .RunAsync(default!, default); From 4b75d9863e7885ea4e86bc1e87039a1c059df60a Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:23:40 -0600 Subject: [PATCH 38/44] [PM-45107] fix: Apply design-approved copy to the Privileged Controls seat-needed email Design review signed off on revised subject and body copy for the email sent to owners when a member can't be given a Privileged Controls seat. --- .../Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs | 6 +++--- .../Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs | 4 ++-- .../emails/Billing/PrivilegedControlsSeatNeededView.mjml | 6 +++--- .../SendPrivilegedControlsSeatNeededEmailCommand.cs | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs index 7b9d1928e847..069c07bc46e7 100644 --- a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs @@ -196,7 +196,7 @@
{{ MemberEmail }} has been given access to {{ OrganizationName }}, - but not to Privileged Controls, because the organization has + but not to Privileged Controls, because your organization has reached its Privileged Controls seat limit.
@@ -205,8 +205,8 @@ -
An owner can add more seats by going to the Subscription page and - raising the limit.
+
You can add more seats by going to the Subscription page and + increasing the limit.
diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs index 119620fdb699..dec23c32a50f 100644 --- a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs @@ -1,7 +1,7 @@ A member needs a Privileged Controls seat -{{{MemberEmail}}} has been given access to {{{OrganizationName}}}, but not to Privileged Controls, because the organization has reached its Privileged Controls seat limit. +{{{MemberEmail}}} has been given access to {{{OrganizationName}}}, but not to Privileged Controls, because your organization has reached its Privileged Controls seat limit. -An owner can add more seats by going to the Subscription page and raising the limit. +You can add more seats by going to the Subscription page and increasing the limit. Manage subscription: {{{VaultSubscriptionUrl}}} diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml index 3b08b3552aba..efb0c6d171f7 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatNeededView.mjml @@ -26,7 +26,7 @@ line-height="24px" > {{ MemberEmail }} has been given access to {{ OrganizationName }}, - but not to Privileged Controls, because the organization has + but not to Privileged Controls, because your organization has reached its Privileged Controls seat limit. - An owner can add more seats by going to the Subscription page and - raising the limit. + You can add more seats by going to the Subscription page and + increasing the limit. Date: Thu, 5 Nov 2026 10:44:34 -0600 Subject: [PATCH 39/44] [PM-45107] fix: Align the Privileged Controls seat email copy and subjects Design review asked for both Privileged Controls seat emails to end with the same sentence, and for each subject to follow its email's heading so owners who receive both can tell them apart. --- .../Mailer/PrivilegedControlsSeatLimitReachedView.html.hbs | 4 ++-- .../Mailer/PrivilegedControlsSeatLimitReachedView.text.hbs | 2 +- .../Billing/PrivilegedControlsSeatLimitReachedView.mjml | 4 ++-- .../AutoscalePrivilegedControlsSeatsCommand.cs | 2 +- .../SendPrivilegedControlsSeatNeededEmailCommand.cs | 2 +- .../Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatLimitReachedView.html.hbs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatLimitReachedView.html.hbs index 0b78c4b8bc3c..1961cb5e196d 100644 --- a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatLimitReachedView.html.hbs +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatLimitReachedView.html.hbs @@ -205,8 +205,8 @@ -
An owner can add more seats by going to the Subscription page and - raising the limit.
+
You can add more seats by going to the Subscription page and + increasing the limit.
diff --git a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatLimitReachedView.text.hbs b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatLimitReachedView.text.hbs index d529a5cfa543..c27cd208ef92 100644 --- a/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatLimitReachedView.text.hbs +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatLimitReachedView.text.hbs @@ -2,6 +2,6 @@ Privileged Controls seat limit reached {{{OrganizationName}}} has reached the seat limit of {{MaxSeatCount}} for Privileged Controls, which means no additional members can be given access to it. -An owner can add more seats by going to the Subscription page and raising the limit. +You can add more seats by going to the Subscription page and increasing the limit. Manage subscription: {{{VaultSubscriptionUrl}}} diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatLimitReachedView.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatLimitReachedView.mjml index 959ae38308f0..e549eb635322 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatLimitReachedView.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/PrivilegedControlsSeatLimitReachedView.mjml @@ -34,8 +34,8 @@ font-weight="400" line-height="24px" > - An owner can add more seats by going to the Subscription page and - raising the limit. + You can add more seats by going to the Subscription page and + increasing the limit. (mail => mail.ToEmails.Single() == _ownerEmail && - mail.Subject == "Acme.Corp Privileged Controls seat limit reached" && + mail.Subject == "Acme.Corp has reached its Privileged Controls seat limit" && mail.View.OrganizationId == organization.Id && mail.View.OrganizationName == CoreHelpers.PreventEmailAutoLinking("Acme.Corp") && mail.View.MaxSeatCount == 15)); From f1157884d1ee8e765e85cadbd9516005b1f8d4b4 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:11:16 -0600 Subject: [PATCH 40/44] [PM-45107] fix: Let Privileged Controls autoscale proceed without a subscription id An organization can have Privileged Controls enabled with no Stripe subscription id, as when an admin enables a sales-initiated trial. The trial query already treats a missing subscription id as a trial and skips Stripe, so the autoscale command no longer rejects these organizations up front. --- .../AutoscalePrivilegedControlsSeatsCommand.cs | 5 ----- ...toscalePrivilegedControlsSeatsCommandTests.cs | 16 +++++++++------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs index a29854f806f0..7c5415d1ebbd 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs @@ -54,11 +54,6 @@ public async Task RunAsync(OrganizationE return new AutoscalePrivilegedControlsSeatsResult(false, organization.MaxAutoscalePamSeats); } - if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) - { - throw new BadRequestException("No subscription found."); - } - if (!await isOnTrialQuery.Run(organization)) { var changeSet = await seatChangeSetFactory.CreateAsync(organization, seatsNeeded); diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index 28596c61c175..a5db41f29983 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs @@ -103,19 +103,21 @@ public async Task RunAsync_OrganizationUsesPrivilegedControlsWithNoSeats_ThrowsB [Theory] [InlineData(null)] [InlineData("")] - [InlineData(" ")] - public async Task RunAsync_NoGatewaySubscriptionAndSeatsShort_ThrowsBadRequestAndChangesNothing(string? gatewaySubscriptionId) + public async Task RunAsync_NoGatewaySubscriptionOnTrialAndSeatsShort_SavesSeatsWithoutTouchingStripe( + string? gatewaySubscriptionId) { var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); SetSeatsInUse(organization, _seatsInUse); + _isOnTrialQuery.Result = true; - var exception = await Assert.ThrowsAsync(() => _sut.RunAsync(organization, 3)); + var result = await _sut.RunAsync(organization, 3); - Assert.Equal("No subscription found.", exception.Message); - Assert.Equal(_boughtSeats, organization.PamSeats); + Assert.True(result.Success); + Assert.Equal(13, organization.PamSeats); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); - await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() - .RunAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] From bbe50b4e1f8c4a069b76384aad72d6eb5b47b956 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:34:12 -0600 Subject: [PATCH 41/44] [PM-45107] refactor: Replace OrganizationService.ReplaceAndUpdateCacheAsync with its command Moves every caller onto IReplaceOrganizationAndUpdateCacheCommand and deletes the service method, so saving an organization and refreshing its cached abilities has one implementation. This also retires the private copy in UpdateSecretsManagerSubscriptionCommand, which existed only to avoid a circular dependency on OrganizationService that the command does not have. --- .../Services/BusinessUnitConverter.cs | 5 +- .../Services/ProviderBillingService.cs | 5 +- .../RemoveOrganizationFromProviderCommand.cs | 9 +- .../Services/BusinessUnitConverterTests.cs | 7 +- .../Services/ProviderBillingServiceTests.cs | 19 ++-- ...oveOrganizationFromProviderCommandTests.cs | 11 ++- .../SubscriptionUpdatedHandler.cs | 9 +- .../Update/OrganizationUpdateCommand.cs | 7 +- .../Services/IOrganizationService.cs | 1 - .../Implementations/OrganizationService.cs | 14 +-- .../AddSecretsManagerSubscriptionCommand.cs | 6 +- .../UpdateOrganizationLicenseCommand.cs | 10 +- .../UpgradeOrganizationPlanVNextCommand.cs | 6 +- .../Commands/RestartSubscriptionCommand.cs | 6 +- ...UpdateSecretsManagerSubscriptionCommand.cs | 29 +----- .../UpgradeOrganizationPlanCommand.cs | 6 +- ...edControlsToExistingSubscriptionCommand.cs | 8 +- .../ChangePrivilegedControlsSeatsCommand.cs | 8 +- .../RemovePrivilegedControlsCommand.cs | 8 +- .../Subscriptions.Organization/README.md | 3 +- .../SubscriptionUpdatedHandlerTests.cs | 91 ++++++++++--------- .../OrganizationUpdateCommandTests.cs | 44 ++++----- ...dSecretsManagerSubscriptionCommandTests.cs | 13 +-- .../UpdateOrganizationLicenseCommandTests.cs | 26 +++--- ...pgradeOrganizationPlanVNextCommandTests.cs | 16 ++-- .../RestartSubscriptionCommandTests.cs | 24 ++--- ...eSecretsManagerSubscriptionCommandTests.cs | 12 +-- .../UpgradeOrganizationPlanCommandTests.cs | 28 +++--- ...trolsToExistingSubscriptionCommandTests.cs | 19 ++-- ...angePrivilegedControlsSeatsCommandTests.cs | 27 +++--- .../RemovePrivilegedControlsCommandTests.cs | 23 ++--- ...izationServiceCollectionExtensionsTests.cs | 6 +- 32 files changed, 249 insertions(+), 257 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 0c6a63123c27..e74a19a36328 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; @@ -31,13 +32,13 @@ public class BusinessUnitConverter( GlobalSettings globalSettings, ILogger logger, IMailService mailService, - IOrganizationService organizationService, IOrganizationUserRepository organizationUserRepository, IPricingClient pricingClient, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, IUserRepository userRepository) : IBusinessUnitConverter @@ -172,7 +173,7 @@ public async Task FinalizeConversion( // Complete database updates for provider setup. await Task.WhenAll( - organizationService.ReplaceAndUpdateCacheAsync(organization), + replaceOrganizationAndUpdateCacheCommand.RunAsync(organization), providerOrganizationRepository.ReplaceAsync(providerOrganization), providerRepository.ReplaceAsync(provider), providerUserRepository.ReplaceAsync(providerUser)); diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index ca540e37508c..5e542e082f1c 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; @@ -42,13 +43,13 @@ public class ProviderBillingService( IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, - IOrganizationService organizationService, IPriceIncreaseScheduler priceIncreaseScheduler, IPricingClient pricingClient, IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, IProviderUserRepository providerUserRepository, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, ITaxService taxService) @@ -149,7 +150,7 @@ await stripeAdapter.FinalizeInvoiceAsync(subscription.LatestInvoiceId, await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value); await Task.WhenAll( - organizationService.ReplaceAndUpdateCacheAsync(organization), + replaceOrganizationAndUpdateCacheCommand.RunAsync(organization), providerOrganizationRepository.CreateAsync(providerOrganization) ); diff --git a/bitwarden_license/src/Commercial.Core/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/Providers/RemoveOrganizationFromProviderCommand.cs index fd13738855a9..24e0a1aa98ff 100644 --- a/bitwarden_license/src/Commercial.Core/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/Providers/RemoveOrganizationFromProviderCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; @@ -26,7 +27,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv private readonly IEventService _eventService; private readonly IMailService _mailService; private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationService _organizationService; + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand; private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IStripeAdapter _stripeAdapter; private readonly Bitwarden.Server.Sdk.Features.IFeatureService _featureService; @@ -39,7 +40,7 @@ public RemoveOrganizationFromProviderCommand( IEventService eventService, IMailService mailService, IOrganizationRepository organizationRepository, - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IProviderOrganizationRepository providerOrganizationRepository, IStripeAdapter stripeAdapter, Bitwarden.Server.Sdk.Features.IFeatureService featureService, @@ -51,7 +52,7 @@ public RemoveOrganizationFromProviderCommand( _eventService = eventService; _mailService = mailService; _organizationRepository = organizationRepository; - _organizationService = organizationService; + _replaceOrganizationAndUpdateCacheCommand = replaceOrganizationAndUpdateCacheCommand; _providerOrganizationRepository = providerOrganizationRepository; _stripeAdapter = stripeAdapter; _featureService = featureService; @@ -89,7 +90,7 @@ public async Task RemoveOrganizationFromProvider( await ResetOrganizationBillingAsync(organization, provider, organizationOwnerEmails); - await _organizationService.ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); await _providerOrganizationRepository.DeleteAsync(providerOrganization); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index a2841c172675..b0cb2981dbda 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; @@ -35,13 +36,13 @@ public class BusinessUnitConverterTests private readonly GlobalSettings _globalSettings = new(); private readonly ILogger _logger = Substitute.For>(); private readonly IMailService _mailService = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For(); private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For(); private readonly IProviderRepository _providerRepository = Substitute.For(); private readonly IProviderUserRepository _providerUserRepository = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly ISubscriberService _subscriberService = Substitute.For(); private readonly IUserRepository _userRepository = Substitute.For(); @@ -51,13 +52,13 @@ public class BusinessUnitConverterTests _globalSettings, _logger, _mailService, - _organizationService, _organizationUserRepository, _pricingClient, _providerOrganizationRepository, _providerPlanRepository, _providerRepository, _providerUserRepository, + _replaceOrganizationAndUpdateCacheCommand, _stripeAdapter, _subscriberService, _userRepository); @@ -157,7 +158,7 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, Arg.Is arguments.Items[1].Price == updatedPriceId && arguments.Items[1].Quantity == organization.Seats)); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(arguments => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(arguments => arguments.PlanType == PlanType.EnterpriseAnnually && arguments.Status == OrganizationStatusType.Managed && arguments.UseRiskInsights == enterpriseAnnually.HasRiskInsights && diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 12318d3043af..d3b7bfe997a0 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; @@ -2440,7 +2441,7 @@ public async Task AddExistingOrganization_DelegatesCohortCleanupToRelease_AfterS var priceIncreaseScheduler = sutProvider.GetDependency(); var stripeAdapter = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var providerOrganizationRepository = sutProvider.GetDependency(); var customerId = organization.GatewayCustomerId; var subscriptionId = organization.GatewaySubscriptionId; @@ -2455,7 +2456,7 @@ public async Task AddExistingOrganization_DelegatesCohortCleanupToRelease_AfterS { priceIncreaseScheduler.Release(customerId, subscriptionId, orgId); stripeAdapter.CancelSubscriptionAsync(subscriptionId, Arg.Any()); - organizationService.ReplaceAndUpdateCacheAsync(organization); + replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); providerOrganizationRepository.CreateAsync(Arg.Any()); }); await priceIncreaseScheduler.Received(1).Release(customerId, subscriptionId, orgId); @@ -2475,7 +2476,7 @@ public async Task AddExistingOrganization_ContinuesEntireFlow_AfterReleaseSuccee ArrangeAddExistingOrganizationHappyPath(sutProvider, provider, organization); var priceIncreaseScheduler = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var providerOrganizationRepository = sutProvider.GetDependency(); var eventService = sutProvider.GetDependency(); // Capture before Act — AddExistingOrganization clears GatewaySubscriptionId @@ -2489,7 +2490,7 @@ public async Task AddExistingOrganization_ContinuesEntireFlow_AfterReleaseSuccee // Assert await priceIncreaseScheduler.Received(1).Release(customerId, subscriptionId, orgId); - await organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); Assert.Equal(MockPlans.Get(PlanType.EnterpriseMonthly).HasRiskInsights, organization.UseRiskInsights); await providerOrganizationRepository.Received(1).CreateAsync(Arg.Is(po => po.ProviderId == provider.Id && po.OrganizationId == organization.Id && po.Key == key)); @@ -2536,7 +2537,7 @@ public async Task AddExistingOrganization_CompletesAndDelegatesCohortCleanup_For var priceIncreaseScheduler = sutProvider.GetDependency(); var stripeAdapter = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var providerOrganizationRepository = sutProvider.GetDependency(); var customerId = organization.GatewayCustomerId; var subscriptionId = organization.GatewaySubscriptionId; @@ -2553,11 +2554,11 @@ public async Task AddExistingOrganization_CompletesAndDelegatesCohortCleanup_For priceIncreaseScheduler.Release(customerId, subscriptionId, orgId); stripeAdapter.UpdateSubscriptionAsync(subscriptionId, Arg.Any()); stripeAdapter.CancelSubscriptionAsync(subscriptionId, Arg.Any()); - organizationService.ReplaceAndUpdateCacheAsync(organization); + replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); providerOrganizationRepository.CreateAsync(Arg.Any()); }); await priceIncreaseScheduler.Received(1).Release(customerId, subscriptionId, orgId); - await organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organization.Id && o.PlanType == PlanType.TeamsMonthly)); } @@ -2577,7 +2578,7 @@ public async Task AddExistingOrganization_PropagatesAndShortCircuits_WhenRelease var priceIncreaseScheduler = sutProvider.GetDependency(); var stripeAdapter = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var providerOrganizationRepository = sutProvider.GetDependency(); var eventService = sutProvider.GetDependency(); var subscriptionId = organization.GatewaySubscriptionId; @@ -2595,7 +2596,7 @@ await stripeAdapter.Received(0) .UpdateSubscriptionAsync(subscriptionId, Arg.Any()); await stripeAdapter.Received(0) .CancelSubscriptionAsync(subscriptionId, Arg.Any()); - await organizationService.Received(0).ReplaceAndUpdateCacheAsync(Arg.Any()); + await replaceOrganizationAndUpdateCacheCommand.Received(0).RunAsync(Arg.Any()); await providerOrganizationRepository.Received(0).CreateAsync(Arg.Any()); await eventService.Received(0).LogProviderOrganizationEventAsync( Arg.Any(), Arg.Any()); diff --git a/bitwarden_license/test/Commercial.Core.Test/Providers/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/Providers/RemoveOrganizationFromProviderCommandTests.cs index 7bff0d4554db..bcd8ee5880c4 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Providers/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Providers/RemoveOrganizationFromProviderCommandTests.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; @@ -117,7 +118,7 @@ public async Task RemoveOrganizationFromProvider_OrganizationNotStripeEnabled_Ma await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); - await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(org => org.BillingEmail == "a@example.com")); + await sutProvider.GetDependency().Received(1).RunAsync(Arg.Is(org => org.BillingEmail == "a@example.com")); await sutProvider.GetDependency().Received(1) .DeleteAsync(providerOrganization); @@ -180,7 +181,7 @@ await stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubs await sutProvider.GetDependency().Received(1).RemovePaymentSource(organization); - await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(org => org.BillingEmail == "a@example.com")); + await sutProvider.GetDependency().Received(1).RunAsync(Arg.Is(org => org.BillingEmail == "a@example.com")); await sutProvider.GetDependency().Received(1) .DeleteAsync(providerOrganization); @@ -264,7 +265,7 @@ await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is().Received(1) .ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0); - await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(Arg.Is( + await sutProvider.GetDependency().Received(1).RunAsync(Arg.Is( org => org.BillingEmail == "a@example.com" && org.GatewaySubscriptionId == "subscription_id" && @@ -353,7 +354,7 @@ await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is().Received(1) .ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0); - await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(Arg.Is( + await sutProvider.GetDependency().Received(1).RunAsync(Arg.Is( org => org.BillingEmail == "a@example.com" && org.GatewaySubscriptionId == "subscription_id" && @@ -454,7 +455,7 @@ public async Task RemoveOrganizationFromProvider_DisabledOrganization_Consolidat await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); // Assert: Verify the disabled organization is now enabled - await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(Arg.Is( + await sutProvider.GetDependency().Received(1).RunAsync(Arg.Is( org => org.Enabled == true && // The previously disabled organization should now be enabled org.Status == OrganizationStatusType.Created && diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 99e0dc732754..01f3ca34669f 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -31,6 +31,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IStripeEventService _stripeEventService; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IOrganizationService _organizationService; + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand; private readonly IStripeAdapter _stripeAdapter; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; @@ -52,6 +53,7 @@ public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IStripeAdapter stripeAdapter, IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IUserService userService, @@ -72,6 +74,7 @@ public SubscriptionUpdatedHandler( _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; _organizationService = organizationService; + _replaceOrganizationAndUpdateCacheCommand = replaceOrganizationAndUpdateCacheCommand; _providerService = providerService; _stripeAdapter = stripeAdapter; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; @@ -497,7 +500,7 @@ private async Task HandleScheduleTriggeredFamiliesMigrationAsync( organization.UsersGetPremium = familiesPlan.UsersGetPremium; organization.Seats = familiesPlan.PasswordManager.BaseSeats; - await _organizationService.ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); _logger.LogInformation( "Updated organization ({OrganizationId}) to FamiliesAnnually plan after schedule transition", @@ -647,7 +650,7 @@ private async Task HandleScheduleTriggeredBusinessMigrationAsync( organization.Seats = (int)Math.Max(Math.Max(1L, billedSeatQuantity), (long)(organization.SmSeats ?? 0)); } - await _organizationService.ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); var sourceProvidedServiceAccounts = sourcePlan.SecretsManager?.BaseServiceAccount ?? 0; var targetProvidedServiceAccounts = targetPlan.SecretsManager?.BaseServiceAccount ?? 0; @@ -787,7 +790,7 @@ private async Task HandleScheduleTriggeredAnnualUpgradeOfferAsync( } organization.ChangePlan(targetPlan); - await _organizationService.ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); _logger.LogInformation( "Schedule-triggered annual upgrade applied for organization ({OrganizationId}): PlanType {SourcePlanType} -> {TargetPlanType}", diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs index 5cfd2191b302..483807922cae 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs @@ -4,13 +4,12 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; public class OrganizationUpdateCommand( - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IOrganizationRepository organizationRepository, IGlobalSettings globalSettings, IOrganizationBillingService organizationBillingService @@ -53,7 +52,7 @@ private async Task UpdateCloudAsync(Organization organization, Org organization.BackfillPublicPrivateKeys(request.Keys); - await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization, EventType.Organization_Updated); // Update billing information in Stripe if required await UpdateBillingAsync(organization, originalName, originalBillingEmail); @@ -69,7 +68,7 @@ private async Task UpdateCloudAsync(Organization organization, Org private async Task UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request) { organization.BackfillPublicPrivateKeys(request.Keys); - await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization, EventType.Organization_Updated); return organization; } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index ed94155f9e4d..3dddc4e6f62c 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -32,7 +32,6 @@ Task InviteUserAsync(Guid organizationId, Guid? invitingUserId Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); - Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd); void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index a79398a523dc..a9cbce06668f 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -143,7 +143,7 @@ public async Task AdjustStorageAsync(Guid organizationId, short storageA plan.PasswordManager.StripeStoragePlanId, plan.PasswordManager.BaseStorageGb, plan); - await ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); return secret; } @@ -211,7 +211,7 @@ private async Task UpdateAutoscalingAsync(Organization organization, int? maxAut organization.MaxAutoscaleSeats = maxAutoscaleSeats; - await ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } public async Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment) @@ -325,7 +325,7 @@ private async Task AdjustSeatsAsync(Organization organization, int seatA _logger.LogInformation("{Method}: Invoking _organizationRepository.ReplaceAsync with {Seats} seats for Organization ({OrganizationID})", nameof(AdjustSeatsAsync), organization.Seats, organization.Id); ; - await ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); if (organization.Seats.HasValue && organization.MaxAutoscaleSeats.HasValue && organization.Seats == organization.MaxAutoscaleSeats) @@ -357,7 +357,7 @@ public async Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expir { org.ExpirationDate = expirationDate; org.RevisionDate = DateTime.UtcNow; - await ReplaceAndUpdateCacheAsync(org); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(org); } } @@ -377,7 +377,7 @@ public async Task UpdateAsync(Organization organization, bool updateBilling = fa } } - await ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization, EventType.Organization_Updated); if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { @@ -1035,10 +1035,6 @@ await _eventService.LogOrganizationUserEventAsync(organizationUser, } } - - public Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) => - _replaceOrganizationAndUpdateCacheCommand.RunAsync(org, orgEvent); - private async Task GetOrgById(Guid id) { return await _organizationRepository.GetByIdAsync(id); diff --git a/src/Core/Billing/Organizations/Commands/AddSecretsManagerSubscriptionCommand.cs b/src/Core/Billing/Organizations/Commands/AddSecretsManagerSubscriptionCommand.cs index 8f0493eaf699..7294edb022e8 100644 --- a/src/Core/Billing/Organizations/Commands/AddSecretsManagerSubscriptionCommand.cs +++ b/src/Core/Billing/Organizations/Commands/AddSecretsManagerSubscriptionCommand.cs @@ -1,13 +1,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; -using Bit.Core.Services; using StaticStorePlan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Core.Billing.Organizations.Commands; @@ -18,7 +18,7 @@ public interface IAddSecretsManagerSubscriptionCommand } public class AddSecretsManagerSubscriptionCommand( - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IProviderRepository providerRepository, IPricingClient pricingClient, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand) @@ -56,7 +56,7 @@ public async Task RunAsync( organization.SmSeats = plan.SecretsManager.BaseSeats + additionalSmSeats; organization.SmServiceAccounts = plan.SecretsManager.BaseServiceAccount + additionalServiceAccounts; organization.UseSecretsManager = true; - await organizationService.ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); // TODO: call ReferenceEventService - see AC-1481 } diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs index eb024a6ba987..9e7b08e1acad 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses; @@ -9,7 +10,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -25,18 +25,18 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman { private readonly ILicensingService _licensingService; private readonly IGlobalSettings _globalSettings; - private readonly IOrganizationService _organizationService; + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand; private readonly Bitwarden.Server.Sdk.Features.IFeatureService _featureService; public UpdateOrganizationLicenseCommand( ILicensingService licensingService, IGlobalSettings globalSettings, - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, Bitwarden.Server.Sdk.Features.IFeatureService featureService) { _licensingService = licensingService; _globalSettings = globalSettings; - _organizationService = organizationService; + _replaceOrganizationAndUpdateCacheCommand = replaceOrganizationAndUpdateCacheCommand; _featureService = featureService; } @@ -136,6 +136,6 @@ private async Task UpdateOrganizationAsync(SelfHostedOrganizationDetails selfHos organization.UpdateFromLicense(license, _featureService); - await _organizationService.ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } } diff --git a/src/Core/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommand.cs b/src/Core/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommand.cs index c148676ebbd5..6a3c308fa639 100644 --- a/src/Core/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommand.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Models; @@ -9,7 +10,6 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; -using Bit.Core.Services; using Microsoft.Extensions.Logging; using OneOf.Types; using static Bit.Core.Billing.Constants.StripeConstants; @@ -44,7 +44,7 @@ Task> Run( public class UpgradeOrganizationPlanVNextCommand( ILogger logger, IOrganizationBillingService organizationBillingService, - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IPricingClient pricingClient, IPriceIncreaseScheduler priceIncreaseScheduler, IStripeAdapter stripeAdapter, @@ -191,6 +191,6 @@ private async Task UpdateOrganizationFeaturesAsync( organization.BackfillPublicPrivateKeys(keys); } - await organizationService.ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } } diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs index 8ed5b740fcdb..07dab8a5eef7 100644 --- a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; -using Bit.Core.Services; using Microsoft.Extensions.Logging; using OneOf.Types; using Stripe; @@ -23,7 +23,7 @@ Task> Run( public class RestartSubscriptionCommand( ILogger logger, IGetOrganizationRestartQuery getOrganizationRestartQuery, - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : BaseBillingCommand(logger), IRestartSubscriptionCommand { @@ -104,6 +104,6 @@ private async Task RestartOrganizationSubscriptionAsync( organization.ExpirationDate = subscription.GetCurrentPeriodEnd(); organization.RevisionDate = DateTime.UtcNow; - await organizationService.ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index d4bd586ea5b4..db155bb81ca0 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -1,8 +1,8 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; @@ -30,9 +30,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs private readonly ILogger _logger; private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IGlobalSettings _globalSettings; - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService; - private readonly IEventService _eventService; + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand; private readonly Bitwarden.Server.Sdk.Features.IFeatureService _featureService; private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand; private readonly IStripeAdapter _stripeAdapter; @@ -44,9 +42,7 @@ public UpdateSecretsManagerSubscriptionCommand( ILogger logger, IServiceAccountRepository serviceAccountRepository, IGlobalSettings globalSettings, - IOrganizationRepository organizationRepository, - IOrganizationAbilityCacheService organizationAbilityCacheService, - IEventService eventService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, Bitwarden.Server.Sdk.Features.IFeatureService featureService, IStripeAdapter stripeAdapter) @@ -57,9 +53,7 @@ public UpdateSecretsManagerSubscriptionCommand( _logger = logger; _serviceAccountRepository = serviceAccountRepository; _globalSettings = globalSettings; - _organizationRepository = organizationRepository; - _organizationAbilityCacheService = organizationAbilityCacheService; - _eventService = eventService; + _replaceOrganizationAndUpdateCacheCommand = replaceOrganizationAndUpdateCacheCommand; _updateOrganizationSubscriptionCommand = updateOrganizationSubscriptionCommand; _featureService = featureService; _stripeAdapter = stripeAdapter; @@ -156,7 +150,7 @@ await _paymentService.AdjustServiceAccountsAsync(update.Organization, update.Pla organization.MaxAutoscaleSmSeats = update.MaxAutoscaleSmSeats; organization.MaxAutoscaleSmServiceAccounts = update.MaxAutoscaleSmServiceAccounts; - await ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } private async Task SendSeatLimitEmailAsync(Organization organization) @@ -438,19 +432,6 @@ private void ValidateMaxAutoscaleSmServiceAccountUpdate(SecretsManagerSubscripti } } - // TODO: This is a temporary duplication of OrganizationService.ReplaceAndUpdateCache to avoid a circular dependency. - // TODO: This should no longer be necessary when user-related methods are extracted from OrganizationService: see PM-1880 - private async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) - { - await _organizationRepository.ReplaceAsync(org); - await _organizationAbilityCacheService.UpsertOrganizationAbilityAsync(org); - - if (orgEvent.HasValue) - { - await _eventService.LogOrganizationEventAsync(org, orgEvent.Value); - } - } - private async Task ValidateAutoScaleLimitsAsync(SecretsManagerSubscriptionUpdate update) { var (smSeatAutoScaleLimitReached, smServiceAccountsLimitReached) = await AreAutoscaleLimitsReachedAsync(update); diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 66bfc5cc1871..2f15cb64fb7a 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities; @@ -41,6 +42,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationService _organizationService; + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand; private readonly Bitwarden.Server.Sdk.Features.IFeatureService _featureService; private readonly IOrganizationBillingService _organizationBillingService; private readonly IPricingClient _pricingClient; @@ -59,6 +61,7 @@ public UpgradeOrganizationPlanCommand( IServiceAccountRepository serviceAccountRepository, IOrganizationRepository organizationRepository, IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, Bitwarden.Server.Sdk.Features.IFeatureService featureService, IOrganizationBillingService organizationBillingService, IPricingClient pricingClient, @@ -76,6 +79,7 @@ public UpgradeOrganizationPlanCommand( _serviceAccountRepository = serviceAccountRepository; _organizationRepository = organizationRepository; _organizationService = organizationService; + _replaceOrganizationAndUpdateCacheCommand = replaceOrganizationAndUpdateCacheCommand; _featureService = featureService; _organizationBillingService = organizationBillingService; _pricingClient = pricingClient; @@ -303,7 +307,7 @@ await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, upgrade.AdditionalServiceAccounts.GetValueOrDefault(); } - await _organizationService.ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); return new Tuple(success, paymentIntentClientSecret); } diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs index 15c12e44b7fc..e7c18d51aa2a 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs @@ -1,8 +1,8 @@ -using Bit.Core.Billing.Extensions; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bitwarden.Server.Sdk.Environment; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; @@ -25,7 +25,7 @@ internal interface IAddPrivilegedControlsToExistingSubscriptionCommand internal sealed class AddPrivilegedControlsToExistingSubscriptionCommand( IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IBitwardenEnvironment environment) : IAddPrivilegedControlsToExistingSubscriptionCommand { public async Task Run(OrganizationEntity organization, int seats, int? maxAutoscaleSeats) @@ -57,6 +57,6 @@ public async Task Run(OrganizationEntity organization, int seats, int? maxAutosc organization.PamSeats = seats; organization.MaxAutoscalePamSeats = maxAutoscaleSeats; organization.UsePam = true; - await organizationService.ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } } diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs index 8a3d564b5062..764f9e833165 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs @@ -1,8 +1,8 @@ -using Bit.Core.Billing.Extensions; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bitwarden.Server.Sdk.Environment; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; @@ -26,7 +26,7 @@ internal interface IChangePrivilegedControlsSeatsCommand internal sealed class ChangePrivilegedControlsSeatsCommand( IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IBitwardenEnvironment environment) : IChangePrivilegedControlsSeatsCommand { public async Task Run(OrganizationEntity organization, int seats, int? maxAutoscaleSeats) @@ -67,6 +67,6 @@ public async Task Run(OrganizationEntity organization, int seats, int? maxAutosc organization.PamSeats = seats; organization.MaxAutoscalePamSeats = maxAutoscaleSeats; - await organizationService.ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } } diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs index 40b43bc3f5fa..96aa29628ea1 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs @@ -1,8 +1,8 @@ -using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bitwarden.Server.Sdk.Environment; using Microsoft.Extensions.Logging; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; @@ -25,7 +25,7 @@ internal interface IRemovePrivilegedControlsCommand internal sealed class RemovePrivilegedControlsCommand( IPricingClient pricingClient, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IBitwardenEnvironment environment, ILogger logger) : IRemovePrivilegedControlsCommand { @@ -70,7 +70,7 @@ public async Task Run(OrganizationEntity organization) try { - await organizationService.ReplaceAndUpdateCacheAsync(organization); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } catch (Exception exception) { diff --git a/src/Libraries/Subscriptions.Organization/README.md b/src/Libraries/Subscriptions.Organization/README.md index ae983d255e29..34b11b84f538 100644 --- a/src/Libraries/Subscriptions.Organization/README.md +++ b/src/Libraries/Subscriptions.Organization/README.md @@ -336,8 +336,7 @@ This library depends on `Core` as a documented deviation from the rule restricti | `PrivilegedControlsSeatLimitReachedError`, `CannotAutoscalePrivilegedControlsSeatsOnSelfHostError`, `PrivilegedControlsSeatExpansionFailedError`, `PrivilegedControlsSeatReservationUnexpectedError` (`Bit.Core.Billing.Organizations`), `Error` (`Bit.Core.AdminConsole.Utilities.v2`) | The errors a reservation returns, which Admin Console member actions pass straight to the client | | `ICountNewPamSeatsRequiredQuery` (`Bit.Core.Billing.Organizations.Queries`) | Checking a self-hosted license has the Privileged Controls seats a reservation needs | | `IGlobalSettings` (`Bit.Core.Settings`), `CoreHelpers` (`Bit.Core.Utilities`) | Building the Owner email links and preventing auto-linking in email content | -| `IOrganizationService` (`Bit.Core.Services`) | Saving the organization's Privileged Controls seats and flags after the first purchase, a seat change, or removal | -| `IReplaceOrganizationAndUpdateCacheCommand` (`Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces`) | Saving the organization's Privileged Controls seat count after an autoscale or a revert | +| `IReplaceOrganizationAndUpdateCacheCommand` (`Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces`) | Saving the organization's Privileged Controls seats and flags after the first purchase, a seat change, an autoscale, a revert, or removal | | `IOrganizationUserRepository` (`Bit.Core.Repositories`) | Counting the Privileged Controls seats in use before a seat decrease and before an autoscale; finding the Owners to email when the autoscale limit is reached or a joining member needs a seat | | `IMailer` (`Bit.Core.Platform.Mail.Mailer`), `PrivilegedControlsSeatLimitReachedMail`, `PrivilegedControlsSeatNeededMail` (`Bit.Core.Billing.Models.Mail.Mailer`) | Sending the Owners the Privileged Controls autoscale limit reached email, and the email about a joining member who needs a seat | | `ISubscriptionDiscountService`, `DiscountTierType` (`Bit.Core.Billing.Services`, `Bit.Core.Billing.Enums`) | Eligibility-checking Families purchase coupons | diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index c3e12c0a036a..729934240da9 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -38,6 +38,7 @@ public class SubscriptionUpdatedHandlerTests private readonly IStripeEventService _stripeEventService; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IOrganizationService _organizationService; + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand; private readonly IStripeAdapter _stripeAdapter; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; @@ -61,6 +62,7 @@ public SubscriptionUpdatedHandlerTests() _stripeEventService = Substitute.For(); _stripeEventUtilityService = Substitute.For(); _organizationService = Substitute.For(); + _replaceOrganizationAndUpdateCacheCommand = Substitute.For(); _stripeAdapter = Substitute.For(); _organizationSponsorshipRenewCommand = Substitute.For(); _userService = Substitute.For(); @@ -83,6 +85,7 @@ public SubscriptionUpdatedHandlerTests() _stripeEventService, _stripeEventUtilityService, _organizationService, + _replaceOrganizationAndUpdateCacheCommand, _stripeAdapter, _organizationSponsorshipRenewCommand, _userService, @@ -1534,7 +1537,7 @@ public async Task HandleAsync_ScheduleTriggeredFamiliesMigration_UpdatesOrganiza Assert.Equal("Families", organization.Plan); Assert.True(organization.UsersGetPremium); Assert.Equal(6, organization.Seats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync( + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync( Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.FamiliesAnnually && @@ -1587,7 +1590,7 @@ public async Task HandleAsync_NoSchedule_DoesNotUpdateOrganization() await _sut.HandleAsync(parsedEvent); // Assert - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -1648,7 +1651,7 @@ public async Task HandleAsync_ScheduleTriggered_PreviousPriceNotOldFamilies_Does await _sut.HandleAsync(parsedEvent); // Assert - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -1704,7 +1707,7 @@ public async Task HandleAsync_ScheduleTriggered_CurrentPriceNotNewFamilies_DoesN await _sut.HandleAsync(parsedEvent); // Assert - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -1745,7 +1748,7 @@ public async Task HandleAsync_ScheduleTriggered_NoItemChanges_DoesNotUpdateOrgan await _sut.HandleAsync(parsedEvent); // Assert - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -1805,7 +1808,7 @@ public async Task HandleAsync_ScheduleTriggeredMigration_WhenOrganizationNotFoun await _sut.HandleAsync(parsedEvent); // Assert — guard exits early — logs warning, does not throw, does not update - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -1883,7 +1886,7 @@ public async Task HandleAsync_ScheduleTriggered_MultipleItems_MatchesFamiliesPri Assert.Equal(PlanType.FamiliesAnnually, organization.PlanType); Assert.True(organization.UsersGetPremium); Assert.Equal(6, organization.Seats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync( + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync( Arg.Is(o => o.Id == organizationId)); } @@ -2432,7 +2435,7 @@ public async Task HandleAsync_BusinessMigration_NoScheduleId_DoesNothing() await _sut.HandleAsync(parsedEvent); // Assert — handler bails on null ScheduleId; no org or assignment writes - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive() .ReplaceAsync(Arg.Any()); } @@ -2523,7 +2526,7 @@ public async Task HandleAsync_BusinessMigration_PreviousPriceNotIn2020Allowlist_ // Assert — handler resolved the path, then bailed at the source-price intersection. // No target-plan lookup, no org write, no assignment stamp. await _pricingClient.DidNotReceive().GetPlanOrThrow(PlanType.EnterpriseAnnually); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); } @@ -2594,7 +2597,7 @@ public async Task HandleAsync_BusinessMigration_FeatureFlagOff_DoesNothing() // Assert — dispatch gate (feature flag) is the outermost guard; no cohort lookups happen await _cohortRepository.DidNotReceive().GetByIdAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().GetByOrganizationIdAsync(Arg.Any()); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -2659,7 +2662,7 @@ public async Task HandleAsync_BusinessMigration_NoAssignment_SkipsSilently() // Assert — no cohort lookup, no DB writes, no warning (most orgs won't be in a cohort // so logging per-event would be noisy) await _cohortRepository.DidNotReceive().GetByIdAsync(Arg.Any()); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); _logger.DidNotReceive().Log( LogLevel.Warning, @@ -2737,7 +2740,7 @@ public async Task HandleAsync_BusinessMigration_AssignmentExistsCohortMissing_Lo await _sut.HandleAsync(parsedEvent); // Assert — cohort missing, no DB writes - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); _logger.Received(1).Log( LogLevel.Warning, @@ -2816,7 +2819,7 @@ public async Task HandleAsync_BusinessMigration_AssignmentAlreadyMigrated_LogsIn // Assert — idempotency: cohort lookup NEVER happens, no writes await _cohortRepository.DidNotReceive().GetByIdAsync(Arg.Any()); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); } @@ -2903,7 +2906,7 @@ public async Task HandleAsync_BusinessMigration_TargetPriceNotInCurrentItems_Log await _sut.HandleAsync(parsedEvent); // Assert — sanity check fired, no writes - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); _logger.Received(1).Log( LogLevel.Warning, @@ -3017,7 +3020,7 @@ public async Task HandleAsync_BusinessMigration_EnterpriseAnnual2020ToCurrent_Ap Assert.Equal((short)200, organization.Seats); Assert.Equal((short)50, organization.MaxStorageGb); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.EnterpriseAnnually)); Assert.NotNull(assignment.MigratedDate); @@ -3591,7 +3594,7 @@ public async Task HandleAsync_BusinessMigration_EnterpriseMonthly2020ToCurrent_A Assert.Equal((short)200, organization.Seats); Assert.Equal((short)50, organization.MaxStorageGb); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.EnterpriseMonthly)); Assert.NotNull(assignment.MigratedDate); Assert.NotEqual(default, assignment.RevisionDate); @@ -3700,7 +3703,7 @@ public async Task HandleAsync_BusinessMigration_TeamsAnnually2020ToCurrent_Appli Assert.Equal((short)50, organization.Seats); Assert.Equal((short)20, organization.MaxStorageGb); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsAnnually)); Assert.NotNull(assignment.MigratedDate); Assert.NotEqual(default, assignment.RevisionDate); @@ -3808,7 +3811,7 @@ public async Task HandleAsync_BusinessMigration_TeamsMonthly2020ToCurrent_Applie Assert.Equal((short)25, organization.Seats); Assert.Equal((short)10, organization.MaxStorageGb); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsMonthly)); Assert.NotNull(assignment.MigratedDate); Assert.NotEqual(default, assignment.RevisionDate); @@ -4354,7 +4357,7 @@ public async Task HandleAsync_BusinessMigration_TeamsStarterToCurrent_Reconciles // Assert — Seats reconciled to the billed seat line item quantity and persisted. Assert.Equal((int)billedSeats, organization.Seats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsMonthly && o.Seats == (int)billedSeats)); @@ -4475,7 +4478,7 @@ public async Task HandleAsync_BusinessMigration_PackagedSource_FloorsSeatsOnSmSe // Assert — Seats floored at SmSeats and persisted; SmSeats itself untouched (we raise PM, never lower SM). Assert.Equal(expectedSeats, organization.Seats); Assert.Equal(smSeats, organization.SmSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsMonthly && o.Seats == expectedSeats && @@ -4675,7 +4678,7 @@ public async Task HandleAsync_BusinessMigration_Teams2019MonthlyToCurrent_Reconc // Assert — handler did not bail (Gap A), plan-shape updated, and Seats reconciled to billed (Gap B). Assert.Equal((int)billedSeats, organization.Seats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsMonthly && o.Seats == (int)billedSeats)); @@ -4774,7 +4777,7 @@ public async Task HandleAsync_BusinessMigration_PerSeatSource_LeavesSeatsUnchang // Assert — per-seat source: Seats left untouched despite the differing line item quantity. Assert.Equal(25, organization.Seats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsMonthly && o.Seats == 25)); @@ -5061,7 +5064,7 @@ public async Task HandleAsync_BusinessMigration_TeamsMonthly_FeatureFlagOff_Does // no org write, no subscription metadata update. await _cohortRepository.DidNotReceive().GetByIdAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().GetByOrganizationIdAsync(Arg.Any()); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } @@ -5113,7 +5116,7 @@ public async Task HandleAsync_BusinessMigration_PreviousAttributesHasNoItemsData // Assert — business handler bailed: no cohort lookup, no migration writes, warning logged await _cohortAssignmentRepository.DidNotReceive().GetByOrganizationIdAsync(Arg.Any()); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); _logger.Received(1).Log( LogLevel.Warning, @@ -5199,7 +5202,7 @@ public async Task HandleAsync_BusinessMigration_CohortMigrationPathIdNull_LogsWa // Assert — null MigrationPathId is a skip; no target-plan lookup, no writes await _pricingClient.DidNotReceive().GetPlanOrThrow(PlanType.EnterpriseAnnually); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); _logger.Received(1).Log( LogLevel.Warning, @@ -5288,7 +5291,7 @@ public async Task HandleAsync_BusinessMigration_CohortReferencesUnregisteredMigr await _sut.HandleAsync(parsedEvent); // Assert — unregistered path is a safe skip; no NRE, no target lookup, no writes - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); _logger.Received(1).Log( LogLevel.Warning, @@ -5357,7 +5360,7 @@ public async Task HandleAsync_BusinessMigration_PricingServiceThrowsBillingExcep // Act + Assert — BillingException must propagate out of HandleAsync await Assert.ThrowsAsync(() => _sut.HandleAsync(parsedEvent)); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); } @@ -5448,7 +5451,7 @@ public async Task HandleAsync_BusinessMigration_OrganizationLookupReturnsNull_Lo await _sut.HandleAsync(parsedEvent); // Assert — handler reached the org lookup, saw null, and skipped without writing - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any()); } @@ -5534,7 +5537,7 @@ public async Task HandleAsync_BusinessMigration_OrganizationReplaceThrows_Swallo MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent, IsActive = true }); - _organizationService.ReplaceAndUpdateCacheAsync(Arg.Any()) + _replaceOrganizationAndUpdateCacheCommand.RunAsync(Arg.Any()) .Throws(new InvalidOperationException("simulated DB failure")); // Act — must NOT throw; generic exceptions are absorbed by the catch-all @@ -5633,7 +5636,7 @@ public async Task HandleAsync_BusinessMigration_AssignmentReplaceFailsAfterOrgWr await Assert.ThrowsAsync(() => _sut.HandleAsync(parsedEvent)); // Org was written before the failure - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.EnterpriseAnnually)); // Assignment MigratedDate was set in-memory but the write failed; retry will redo it. @@ -5875,7 +5878,7 @@ public async Task HandleAsync_BusinessMigration_EnterpriseAnnual2019ToCurrent_Ap Assert.Equal((short)200, organization.Seats); Assert.Equal((short)50, organization.MaxStorageGb); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.EnterpriseAnnually)); Assert.NotNull(assignment.MigratedDate); @@ -6192,7 +6195,7 @@ public async Task HandleAsync_BusinessMigration_EnterpriseAnnual2019NoSecretsMan // Assert — plan shape applied and assignment marked, despite no SM line items. Assert.Equal(PlanType.EnterpriseAnnually, organization.PlanType); Assert.False(organization.UseSecretsManager); // ChangePlan does not touch this column - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.EnterpriseAnnually)); Assert.NotNull(assignment.MigratedDate); await _cohortAssignmentRepository.Received(1).ReplaceAsync( @@ -6258,7 +6261,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_NoScheduleId_DoesNotUpdatePlanT await _sut.HandleAsync(parsedEvent); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -6312,7 +6315,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_PreviousPriceNotMonthly_DoesNot await _sut.HandleAsync(parsedEvent); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -6368,7 +6371,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_MonthlyToAnnualLatest_UpdatesPl await _sut.HandleAsync(parsedEvent); Assert.Equal(PlanType.TeamsAnnually, organization.PlanType); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsAnnually)); } @@ -6433,7 +6436,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_DuplicateDeliveryAfterFlip_Does await _sut.HandleAsync(parsedEvent); Assert.Equal(PlanType.TeamsAnnually, organization.PlanType); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsAnnually)); } @@ -6489,7 +6492,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_ReplaceAsyncThrows_RethrowsForS _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthly); _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(teamsAnnual); _pricingClient.ListPlans().Returns(MockPlans.Plans); - _organizationService.ReplaceAndUpdateCacheAsync(Arg.Any()) + _replaceOrganizationAndUpdateCacheCommand.RunAsync(Arg.Any()) .Returns(_ => throw new InvalidOperationException("transient DB failure")); await Assert.ThrowsAsync(() => _sut.HandleAsync(parsedEvent)); @@ -6554,7 +6557,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_PricingServiceThrowsOnTransitio await Assert.ThrowsAsync(() => _sut.HandleAsync(parsedEvent)); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -6612,7 +6615,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_PricingLookupThrowsOnUnrelatedI await Assert.ThrowsAsync(() => _sut.HandleAsync(parsedEvent)); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -6672,13 +6675,13 @@ public async Task HandleAsync_AnnualUpgradeOffer_SameEventReplayedAfterPricingFa .Returns(_ => throw new BillingException(message: "pricing service unavailable"), _ => teamsMonthly); await Assert.ThrowsAsync(() => _sut.HandleAsync(parsedEvent)); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); Assert.Equal(PlanType.TeamsMonthly, organization.PlanType); // Same event object, unchanged, as Stripe would redeliver it. await _sut.HandleAsync(parsedEvent); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync( + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync( Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsAnnually)); Assert.Equal(PlanType.TeamsAnnually, organization.PlanType); } @@ -6726,7 +6729,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_PreviousSubscriptionHasNoItems_ await _sut.HandleAsync(parsedEvent); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } [Fact] @@ -6788,7 +6791,7 @@ public async Task HandleAsync_AnnualUpgradeOffer_PreviousItemWithNullPrice_IsSki await _sut.HandleAsync(parsedEvent); Assert.Equal(PlanType.TeamsAnnually, organization.PlanType); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(o => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(o => o.Id == organizationId && o.PlanType == PlanType.TeamsAnnually)); } @@ -6851,6 +6854,6 @@ public async Task HandleAsync_AnnualUpgradeOffer_CurrentSubscriptionMissingTarge await _sut.HandleAsync(parsedEvent); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs index 997076e7ef33..b03e669ce5fb 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -27,7 +27,7 @@ public async Task UpdateAsync_WhenValidOrganization_UpdatesOrganization( { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var organizationBillingService = sutProvider.GetDependency(); organization.Id = organizationId; @@ -56,9 +56,9 @@ public async Task UpdateAsync_WhenValidOrganization_UpdatesOrganization( await organizationRepository .Received(1) .GetByIdAsync(Arg.Is(id => id == organizationId)); - await organizationService + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync( + .RunAsync( result, EventType.Organization_Updated); await organizationBillingService @@ -102,7 +102,7 @@ public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_CallsBillingUpd { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var organizationBillingService = sutProvider.GetDependency(); organization.Id = organizationId; @@ -128,9 +128,9 @@ public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_CallsBillingUpd Assert.Equal(organizationId, result.Id); Assert.Equal("New Name", result.Name); - await organizationService + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync( + .RunAsync( result, EventType.Organization_Updated); await organizationBillingService @@ -148,7 +148,7 @@ public async Task UpdateAsync_WhenKeysProvided_AndNotAlreadySet_SetsKeys( { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); organization.Id = organizationId; organization.PublicKey = null; @@ -177,9 +177,9 @@ public async Task UpdateAsync_WhenKeysProvided_AndNotAlreadySet_SetsKeys( Assert.Equal(publicKey, result.PublicKey); Assert.Equal(encryptedPrivateKey, result.PrivateKey); - await organizationService + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync( + .RunAsync( result, EventType.Organization_Updated); } @@ -194,7 +194,7 @@ public async Task UpdateAsync_WhenKeysProvided_AndAlreadySet_DoesNotOverwriteKey { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); organization.Id = organizationId; var existingPublicKey = organization.PublicKey; @@ -223,9 +223,9 @@ public async Task UpdateAsync_WhenKeysProvided_AndAlreadySet_DoesNotOverwriteKey Assert.Equal(existingPublicKey, result.PublicKey); Assert.Equal(existingPrivateKey, result.PrivateKey); - await organizationService + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync( + .RunAsync( result, EventType.Organization_Updated); } @@ -239,7 +239,7 @@ public async Task UpdateAsync_UpdatingNameOnly_UpdatesNameAndNotBillingEmail( { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var organizationBillingService = sutProvider.GetDependency(); organization.Id = organizationId; @@ -266,9 +266,9 @@ public async Task UpdateAsync_UpdatingNameOnly_UpdatesNameAndNotBillingEmail( Assert.Equal(newName, result.Name); Assert.Equal(originalBillingEmail, result.BillingEmail); - await organizationService + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync( + .RunAsync( result, EventType.Organization_Updated); await organizationBillingService @@ -285,7 +285,7 @@ public async Task UpdateAsync_UpdatingBillingEmailOnly_UpdatesBillingEmailAndNot { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var organizationBillingService = sutProvider.GetDependency(); organization.Id = organizationId; @@ -312,9 +312,9 @@ public async Task UpdateAsync_UpdatingBillingEmailOnly_UpdatesBillingEmailAndNot Assert.Equal(originalName, result.Name); Assert.Equal(newBillingEmail.ToLowerInvariant().Trim(), result.BillingEmail); - await organizationService + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync( + .RunAsync( result, EventType.Organization_Updated); await organizationBillingService @@ -330,7 +330,7 @@ public async Task UpdateAsync_WhenNoChanges_PreservesBothFields( { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = sutProvider.GetDependency(); var organizationBillingService = sutProvider.GetDependency(); organization.Id = organizationId; @@ -357,9 +357,9 @@ public async Task UpdateAsync_WhenNoChanges_PreservesBothFields( Assert.Equal(originalName, result.Name); Assert.Equal(originalBillingEmail, result.BillingEmail); - await organizationService + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .ReplaceAndUpdateCacheAsync( + .RunAsync( result, EventType.Organization_Updated); await organizationBillingService diff --git a/test/Core.Test/Billing/Organizations/Commands/AddSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/AddSecretsManagerSubscriptionCommandTests.cs index b723bda73123..31e1a4fbb495 100644 --- a/test/Core.Test/Billing/Organizations/Commands/AddSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/AddSecretsManagerSubscriptionCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Commands; @@ -9,7 +10,6 @@ using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using Bit.Core.Test.Billing.Mocks.Plans; using NSubstitute; @@ -22,7 +22,8 @@ namespace Bit.Core.Test.Billing.Organizations.Commands; public class AddSecretsManagerSubscriptionCommandTests { private readonly IPricingClient _pricingClient = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = + Substitute.For(); private readonly IProviderRepository _providerRepository = Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); @@ -31,7 +32,7 @@ public class AddSecretsManagerSubscriptionCommandTests public AddSecretsManagerSubscriptionCommandTests() { _command = new AddSecretsManagerSubscriptionCommand( - _organizationService, + _replaceOrganizationAndUpdateCacheCommand, _providerRepository, _pricingClient, _updateOrganizationSubscriptionCommand); @@ -161,7 +162,7 @@ public async Task RunAsync_UpdatesOrganization_WhenTeamsStarterPlan_AndSeatsWith Assert.Equal(plan.SecretsManager.BaseSeats + 5, organization.SmSeats); Assert.Equal(plan.SecretsManager.BaseServiceAccount + 5, organization.SmServiceAccounts); Assert.True(organization.UseSecretsManager); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] @@ -277,7 +278,7 @@ public async Task RunAsync_ThrowsBillingException_WhenUpdateSubscriptionCommandF await Assert.ThrowsAsync(() => _command.RunAsync(organization, additionalSmSeats: 5, additionalServiceAccounts: 10)); - await _organizationService.DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceiveWithAnyArgs().RunAsync(Arg.Any()); } [Fact] @@ -293,7 +294,7 @@ public async Task RunAsync_UpdatesOrganizationAndCache_OnSuccess() Assert.Equal(plan.SecretsManager.BaseSeats + 5, organization.SmSeats); Assert.Equal(plan.SecretsManager.BaseServiceAccount + 10, organization.SmServiceAccounts); Assert.True(organization.UseSecretsManager); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } private void SetupSubscriptionCommandSuccess() diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs index 0b7611c99d47..49c31ad969e6 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Organizations.Commands; @@ -10,7 +11,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -85,9 +85,9 @@ public async Task UpdateLicenseAsync_UpdatesLicenseFileAndOrganization( // Assertion: should have updated and saved the organization // Properties excluded from the comparison below are exceptions to the rule that the Organization mirrors // the OrganizationLicense - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .ReplaceAndUpdateCacheAsync(Arg.Is( + .RunAsync(Arg.Is( org => AssertPropertyEqual(license, org, "Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType", "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", @@ -205,9 +205,9 @@ public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFr AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes"); // Assertion: organization should be updated with ALL properties extracted from claims - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .ReplaceAndUpdateCacheAsync(Arg.Is(org => + .RunAsync(Arg.Is(org => org.Name == "Test Organization" && org.BillingEmail == "billing@test.com" && org.BusinessName == "Test Business" && @@ -300,9 +300,9 @@ public async Task UpdateLicenseAsync_WithoutClaimsPrincipal_IgnoresPamSeatsFromL var licenseFromFile = await JsonSerializer.DeserializeAsync(fs); Assert.Null(licenseFromFile.PamSeats); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .ReplaceAndUpdateCacheAsync(Arg.Is(org => org.PamSeats == null)); + .RunAsync(Arg.Is(org => org.PamSeats == null)); } finally { @@ -351,9 +351,9 @@ public async Task UpdateLicenseAsync_WithClaimsPrincipal_PamSeatsClaimAbsent_Set { await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .ReplaceAndUpdateCacheAsync(Arg.Is(org => org.UsePam && org.PamSeats == null)); + .RunAsync(Arg.Is(org => org.UsePam && org.PamSeats == null)); } finally { @@ -405,9 +405,9 @@ public async Task UpdateLicenseAsync_WrongInstallationIdInClaims_ThrowsBadReques Assert.Contains("The installation ID does not match the current installation.", exception.Message); // Verify organization was NOT saved - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any()); + .RunAsync(Arg.Any()); } [Theory, BitAutoData] @@ -453,9 +453,9 @@ public async Task UpdateLicenseAsync_ExpiredLicenseWithoutClaims_ThrowsBadReques Assert.Contains("The license has expired.", exception.Message); // Verify organization was NOT saved - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any()); + .RunAsync(Arg.Any()); } [Fact] diff --git a/test/Core.Test/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommandTests.cs index 750f57eca873..39fc548d1d0c 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; @@ -8,7 +9,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; -using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using Microsoft.Extensions.Logging; using NSubstitute; @@ -22,7 +22,7 @@ namespace Bit.Core.Test.Billing.Organizations.Commands; public class UpgradeOrganizationPlanVNextCommandTests { private readonly IOrganizationBillingService _organizationBillingService = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IPriceIncreaseScheduler _priceIncreaseScheduler = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); @@ -34,7 +34,7 @@ public UpgradeOrganizationPlanVNextCommandTests() _command = new UpgradeOrganizationPlanVNextCommand( Substitute.For>(), _organizationBillingService, - _organizationService, + _replaceOrganizationAndUpdateCacheCommand, _pricingClient, _priceIncreaseScheduler, _stripeAdapter, @@ -101,7 +101,7 @@ public async Task Run_UpgradeFromFree_FinalizesAndUpdatesOrganization() Assert.True(result.IsT0); await _organizationBillingService.Received(1).Finalize(Arg.Any()); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization, null); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization, null); Assert.Equal(targetPlan.Name, organization.Plan); Assert.Equal(targetPlan.Type, organization.PlanType); Assert.Equal(targetPlan.PasswordManager.BaseStorageGb, organization.MaxStorageGb); @@ -191,7 +191,7 @@ await _updateOrganizationSubscriptionCommand.Received(1).Run( Arg.Is(cs => cs.Changes.Count >= 1 && cs.ChargeImmediately)); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization, null); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization, null); Assert.Equal(targetPlan.Name, organization.Plan); Assert.Equal(targetPlan.Type, organization.PlanType); } @@ -420,7 +420,7 @@ public async Task Run_PaidUpgrade_CommandFailure_PropagatesResult() // Result is mapped through — BadRequest becomes T1 Assert.True(result.IsT1); - await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -565,8 +565,8 @@ public async Task Run_PaidUpgrade_ReleaseThrows_AbortsUpgrade() Assert.True(result.IsT3); await _updateOrganizationSubscriptionCommand.DidNotReceiveWithAnyArgs() .Run(Arg.Any(), Arg.Any()); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } private void SetupSubscriptionCommandSuccess() diff --git a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs index 90acb6bfcf2a..2522a27e3234 100644 --- a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; @@ -8,7 +9,6 @@ using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using NSubstitute; using Stripe; @@ -20,7 +20,7 @@ namespace Bit.Core.Test.Billing.Subscriptions; public class RestartSubscriptionCommandTests { - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly ISubscriberService _subscriberService = Substitute.For(); @@ -31,7 +31,7 @@ public RestartSubscriptionCommandTests() _command = new RestartSubscriptionCommand( Substitute.For>(), new GetOrganizationRestartQuery(_pricingClient), - _organizationService, + _replaceOrganizationAndUpdateCacheCommand, _stripeAdapter, _subscriberService); } @@ -276,7 +276,7 @@ await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.Id == organizationId && org.GatewaySubscriptionId == "sub_new" && org.Enabled == true && @@ -331,7 +331,7 @@ public async Task Run_Organization_IncompleteExpiredSubscription_Success() await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => options.Customer == "cus_123")); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.GatewaySubscriptionId == "sub_new" && org.Enabled == true)); } @@ -388,7 +388,7 @@ await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.Id == organizationId && org.GatewaySubscriptionId == "sub_new_2" && org.Enabled == true)); @@ -452,7 +452,7 @@ await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.Id == organizationId && org.GatewaySubscriptionId == "sub_new_3" && org.Enabled == true)); @@ -511,7 +511,7 @@ await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.Id == organizationId && org.GatewaySubscriptionId == "sub_upgraded" && org.Enabled == true && @@ -536,7 +536,7 @@ await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.UseRiskInsights)); } @@ -595,7 +595,7 @@ await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.Id == organizationId && org.GatewaySubscriptionId == "sub_complex" && org.Enabled == true)); @@ -653,7 +653,7 @@ await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.Id == organizationId && org.GatewaySubscriptionId == "sub_sm" && org.Enabled == true)); @@ -766,7 +766,7 @@ public async Task Run_Organization_WithDisabledPlan_UpgradesToNonRiskInsightsPla // Teams does not enable RiskInsights, so the mapped value is false. Assert.False(newPlan.HasRiskInsights); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(Arg.Is(org => + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(Arg.Is(org => org.Id == organizationId && org.PlanType == PlanType.TeamsAnnually && org.UseRiskInsights == newPlan.HasRiskInsights && diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 8724c52fb440..8f64dc3856e1 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.AbilitiesCache; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; @@ -1159,7 +1159,7 @@ public async Task UpdateSubscriptionAsync_WithFeatureFlag_SubscriptionDeletedInS await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .Run(default, default, default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RunAsync(default); } [Theory] @@ -1257,13 +1257,11 @@ await sutProvider.GetDependency().DidNotReceive() .SendOrganizationMaxSeatLimitReachedEmailAsync(Arg.Any(), Arg.Any(), Arg.Any>()); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpsertOrganizationAbilityAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RunAsync(default); } private void AssertUpdatedOrganization(Func organizationMatcher, SutProvider sutProvider) { - sutProvider.GetDependency().Received(1).ReplaceAsync(organizationMatcher()); - sutProvider.GetDependency().Received(1).UpsertOrganizationAbilityAsync(organizationMatcher()); + sutProvider.GetDependency().Received(1).RunAsync(organizationMatcher()); } } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 5ea3402824cc..4b30f60aaaa3 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Billing; using Bit.Core.Billing.Commands; @@ -18,7 +19,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.Billing.Mocks; @@ -104,7 +104,7 @@ public async Task UpgradePlan_MaxCollectionsExceedsNewPlan_ThrowsBadRequest( "Your organization currently has 6 collections. Your new plan allows for a maximum of (5) collections. Remove some collections.", exception.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RunAsync(default); } [Theory, FreeOrganizationUpgradeCustomize, BitAutoData] @@ -139,7 +139,7 @@ public async Task UpgradePlan_MaxCollectionsExceedsNewPlan_Vfo1FoundationEnabled "Your organization currently has 6 shared folders. Your new plan allows for a maximum of (5) shared folders. Remove some shared folders.", exception.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RunAsync(default); } [Theory, BitAutoData] @@ -213,7 +213,7 @@ public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgr Users = 1 }); await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); - await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(organization); + await sutProvider.GetDependency().Received(1).RunAsync(organization); } [Theory] @@ -261,7 +261,7 @@ await sutProvider.GetDependency().Received(1).AdjustSubsc organizationUpgrade.AdditionalSmSeats, 5, 3); - await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(organization); + await sutProvider.GetDependency().Received(1).RunAsync(organization); } [Theory, FreeOrganizationUpgradeCustomize] @@ -299,7 +299,7 @@ public async Task UpgradePlan_SM_Passes(PlanType planType, Organization organiza }); var result = await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); - await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + await sutProvider.GetDependency().Received(1).RunAsync( Arg.Is(o => o.Seats == plan.PasswordManager.BaseSeats + upgrade.AdditionalSeats && o.SmSeats == plan.SecretsManager.BaseSeats + upgrade.AdditionalSmSeats @@ -345,7 +345,7 @@ public async Task UpgradePlan_SM_NotEnoughSmSeats_Throws(PlanType planType, Orga var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); Assert.Contains("Your organization currently has 2 Secrets Manager seats filled. Your new plan only has", exception.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RunAsync(default); } [Theory, FreeOrganizationUpgradeCustomize] @@ -388,7 +388,7 @@ public async Task UpgradePlan_SM_NotEnoughServiceAccounts_Throws(PlanType planTy var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); Assert.Contains($"Your organization currently has {currentServiceAccounts} machine accounts. Your new plan only allows", exception.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RunAsync(default); } [Theory] @@ -434,9 +434,9 @@ public async Task UpgradePlan_WhenOrganizationIsMissingPublicAndPrivateKeys_Back // Assert Assert.Equal(newPublicKey, organization.PublicKey); Assert.Equal(newPrivateKey, organization.PrivateKey); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .ReplaceAndUpdateCacheAsync(organization); + .RunAsync(organization); } [Theory] @@ -482,9 +482,9 @@ public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_Doe // Assert Assert.Equal(existingPublicKey, organization.PublicKey); Assert.Equal(existingPrivateKey, organization.PrivateKey); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .ReplaceAndUpdateCacheAsync(organization); + .RunAsync(organization); } [Theory, BitAutoData] @@ -607,8 +607,8 @@ public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_Doe // Assert Assert.Equal(existingPublicKey, organization.PublicKey); Assert.Equal(existingPrivateKey, organization.PrivateKey); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .ReplaceAndUpdateCacheAsync(organization); + .RunAsync(organization); } } diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs index 58228d33d889..f4dcd00ae90f 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs @@ -1,10 +1,10 @@ -using Bit.Core.Billing.Commands; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; @@ -21,13 +21,14 @@ public class AddPrivilegedControlsToExistingSubscriptionCommandTests Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = + Substitute.For(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly AddPrivilegedControlsToExistingSubscriptionCommand _sut; public AddPrivilegedControlsToExistingSubscriptionCommandTests() => _sut = new AddPrivilegedControlsToExistingSubscriptionCommand( - _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, _organizationService, _environment); + _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, _replaceOrganizationAndUpdateCacheCommand, _environment); [Fact] public async Task Run_SelfHosted_ThrowsBadRequest() @@ -76,7 +77,7 @@ public async Task Run_FirstPurchase_SavesSeatsAutoscaleLimitAndTurnsOnPrivileged Assert.Equal(15, organization.PamSeats); Assert.Equal(20, organization.MaxAutoscalePamSeats); Assert.True(organization.UsePam); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] @@ -153,8 +154,8 @@ public async Task Run_SeatChangeStepRejectsTheRequest_ChangesAndSavesNothing() await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -183,8 +184,8 @@ public async Task Run_SubscriptionUpdateFails_LeavesTheOrganizationUntouchedAndU Assert.Null(organization.PamSeats); Assert.Null(organization.MaxAutoscalePamSeats); Assert.False(organization.UsePam); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs index 23630b126e76..03f78e69164b 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs @@ -1,10 +1,10 @@ -using Bit.Core.Billing.Commands; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; @@ -22,13 +22,14 @@ public class ChangePrivilegedControlsSeatsCommandTests Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = + Substitute.For(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly ChangePrivilegedControlsSeatsCommand _sut; public ChangePrivilegedControlsSeatsCommandTests() => _sut = new ChangePrivilegedControlsSeatsCommand( - _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, _organizationService, _environment); + _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, _replaceOrganizationAndUpdateCacheCommand, _environment); [Fact] public async Task Run_SelfHosted_ThrowsBadRequest() @@ -151,7 +152,7 @@ public async Task Run_SeatIncrease_SavesSeatsAndAutoscaleLimit() Assert.Equal(15, organization.PamSeats); Assert.Equal(20, organization.MaxAutoscalePamSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] @@ -164,7 +165,7 @@ public async Task Run_SeatReduction_SavesSeatsAndAutoscaleLimit() Assert.Equal(11, organization.PamSeats); Assert.Equal(20, organization.MaxAutoscalePamSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] @@ -188,7 +189,7 @@ public async Task Run_SeatsUnchanged_SavesTheNewAutoscaleLimit() Assert.Equal(_currentSeats, organization.PamSeats); Assert.Equal(20, organization.MaxAutoscalePamSeats); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] @@ -211,8 +212,8 @@ public async Task Run_SeatsUnchangedWithPamLimitAbovePmLimit_ThrowsBadRequest() "You cannot set a Privileged Controls autoscale limit higher than the Password Manager autoscale limit.", exception.Message); Assert.Null(organization.MaxAutoscalePamSeats); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -250,8 +251,8 @@ public async Task Run_SeatChangeStepRejectsTheRequest_ChangesAndSavesNothing() Assert.Equal(30, organization.MaxAutoscalePamSeats); await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -279,8 +280,8 @@ public async Task Run_SubscriptionUpdateFails_LeavesTheOrganizationUntouchedAndU Assert.Equal(_currentSeats, organization.PamSeats); Assert.Equal(30, organization.MaxAutoscalePamSeats); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs index 797a2aac8f1c..5d346085a631 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs @@ -1,10 +1,10 @@ -using Bit.Core.Billing.Commands; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; -using Bit.Core.Services; using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; @@ -23,7 +23,8 @@ public class RemovePrivilegedControlsCommandTests private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = + Substitute.For(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly RecordingLogger _logger = new(); private readonly RemovePrivilegedControlsCommand _sut; @@ -36,7 +37,7 @@ public RemovePrivilegedControlsCommandTests() .Run(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new Subscription()); _sut = new RemovePrivilegedControlsCommand( - _pricingClient, _updateOrganizationSubscriptionCommand, _organizationService, _environment, _logger); + _pricingClient, _updateOrganizationSubscriptionCommand, _replaceOrganizationAndUpdateCacheCommand, _environment, _logger); } [Fact] @@ -143,7 +144,7 @@ public async Task Run_SavesTheOrganization() await _sut.Run(organization); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [Fact] @@ -172,8 +173,8 @@ public async Task Run_SubscriptionUpdateFails_LeavesTheOrganizationUntouchedAndU Assert.True(organization.UsePam); Assert.Equal(12, organization.PamSeats); Assert.Equal(30, organization.MaxAutoscalePamSeats); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -181,8 +182,8 @@ public async Task Run_SaveFailsAfterStripeUpdate_LogsBothIdsAndRethrows() { var organization = CreateOrganization(); var thrown = new InvalidOperationException("database unavailable"); - _organizationService - .ReplaceAndUpdateCacheAsync(organization, Arg.Any()) + _replaceOrganizationAndUpdateCacheCommand + .RunAsync(organization, Arg.Any()) .ThrowsAsync(thrown); var exception = await Assert.ThrowsAsync(() => _sut.Run(organization)); @@ -197,8 +198,8 @@ private async Task AssertNothingChangedOrSaved() { await _updateOrganizationSubscriptionCommand.DidNotReceive() .Run(Arg.Any(), Arg.Any(), Arg.Any()); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } private static OrganizationEntity CreateOrganization( diff --git a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs index b77a90263b93..e4e2248a71b2 100644 --- a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs @@ -51,7 +51,7 @@ public void AddOrganizationSubscriptions_RegistersThePrivilegedControlsTrialQuer services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddLogging(); @@ -88,7 +88,7 @@ public void AddOrganizationSubscriptions_RegistersPurchasePrivilegedControlsHand services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddLogging(); @@ -106,7 +106,7 @@ public void AddOrganizationSubscriptions_RegistersRemovePrivilegedControlsHandle services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddLogging(); From 7162305950ace08414edeb14a51487f03d13543e Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:28:44 -0600 Subject: [PATCH 42/44] [PM-45107] feat: Gate Privileged Controls seat autoscaling on the billing support flag Gives PM39883_Pam_Billing_Support a kill switch over every member action that reserves Privileged Controls seats. With the flag off, cloud reservations succeed without buying seats, so members get access the way they did before seat autoscaling: no subscription change, no rollback, no seat-needed email, and no access removed at the seat limit. Self-hosted keeps rejecting a seat shortfall regardless of the flag, since self-hosted flags read off and that check predates autoscaling. --- .../IReservePrivilegedControlsSeatsCommand.cs | 4 +- .../ReservePrivilegedControlsSeatsCommand.cs | 11 +++++- .../Subscriptions.Organization/README.md | 2 +- .../OrganizationUserControllerPutTests.cs | 5 ++- ...ervePrivilegedControlsSeatsCommandTests.cs | 38 ++++++++++++++++++- ...izationServiceCollectionExtensionsTests.cs | 1 + 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs index 35ceee283368..bd3e34b7fc0c 100644 --- a/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs +++ b/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs @@ -19,7 +19,9 @@ public interface IReservePrivilegedControlsSeatsCommand /// /// /// Succeeds without checking seats when no seats are requested, the organization doesn't use Privileged - /// Controls, or it has no Privileged Controls seat count. Never throws: every failure is returned as an error. + /// Controls, or it has no Privileged Controls seat count. On cloud it also succeeds without checking seats when + /// is off. Never throws: every failure is returned as + /// an error. /// Task RunAsync(Organization organization, int seatsRequested); } diff --git a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs index 75245ae807db..7b3875187416 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs @@ -1,9 +1,11 @@ -using Bit.Core.Billing.Organizations; +using Bit.Core; +using Bit.Core.Billing.Organizations; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Exceptions; using Bitwarden.Server.Sdk.Environment; +using Bitwarden.Server.Sdk.Features; using Microsoft.Extensions.Logging; using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; @@ -13,6 +15,7 @@ internal sealed class ReservePrivilegedControlsSeatsCommand( ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, IAutoscalePrivilegedControlsSeatsCommand autoscalePrivilegedControlsSeatsCommand, IBitwardenEnvironment environment, + IFeatureService featureService, ILogger logger) : IReservePrivilegedControlsSeatsCommand { public async Task RunAsync(OrganizationEntity organization, int seatsRequested) @@ -55,6 +58,12 @@ private async Task ReserveAsync(Organiz : new PrivilegedControlsSeatReservationResult(); } + // With the flag off, cloud members get Privileged Controls access without any seat being reserved. + if (!featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support)) + { + return new PrivilegedControlsSeatReservationResult(); + } + // The autoscale command can't add seats to an organization that has none, so zero seats means none are free. if (organization.PamSeats < 1) { diff --git a/src/Libraries/Subscriptions.Organization/README.md b/src/Libraries/Subscriptions.Organization/README.md index 34b11b84f538..7dccfa614a87 100644 --- a/src/Libraries/Subscriptions.Organization/README.md +++ b/src/Libraries/Subscriptions.Organization/README.md @@ -315,7 +315,7 @@ This library depends on `Core` as a documented deviation from the rule restricti | From Core | Used for | | --- | --- | | `Policies.Application` (`Bit.Core.Auth.Identity`) | Requiring the standard user authorization policy on the group | -| `FeatureFlagKeys` (`Bit.Core`) | Gating the Privileged Controls group and the Privileged Controls purchase endpoint on `PM39883_Pam_Billing_Support` | +| `FeatureFlagKeys` (`Bit.Core`) | Gating the Privileged Controls group, the Privileged Controls purchase endpoint, and cloud Privileged Controls seat reservations on `PM39883_Pam_Billing_Support` | | `BillingExtensions.IsOnPrivilegedControlsSalesInitiatedTrial`, `PrivilegedControlsAutoscaleLimitFits`, `SubscriptionExtensions.HasPrivilegedControlsSeats` (`Bit.Core.Billing.Extensions`) | The trial definition shared with the Api subscription response, the autoscale-limit rule shared with `OrganizationService` and the Admin Portal, and detecting the Stripe Privileged Controls seat line | | `IOrganizationRepository` (`Bit.Core.Repositories`) | Resolving the organization the preview is for | | `Organization` (`Bit.Core.AdminConsole.Entities`) | The subscriber passed to the preview query; the entity the Privileged Controls command and factory read and update | diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs index 1db77989121e..0018b61a1681 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs @@ -4,6 +4,7 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; @@ -16,6 +17,7 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bitwarden.Server.Sdk.Features; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; @@ -33,7 +35,8 @@ public class OrganizationUserControllerPutTests : IClassFixture(_ => { }); + _factory.SubstituteService(featureService => + featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support).Returns(true)); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs index f3db2ea86b67..795e5ce48fa1 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs @@ -1,10 +1,12 @@ -using Bit.Core.Billing.Organizations; +using Bit.Core; +using Bit.Core.Billing.Organizations; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Exceptions; using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; +using Bitwarden.Server.Sdk.Features; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; @@ -19,15 +21,47 @@ public class ReservePrivilegedControlsSeatsCommandTests private readonly IAutoscalePrivilegedControlsSeatsCommand _autoscalePrivilegedControlsSeatsCommand = Substitute.For(); private readonly IBitwardenEnvironment _environment = Substitute.For(); + private readonly IFeatureService _featureService = Substitute.For(); private readonly RecordingLogger _logger = new(); private readonly ReservePrivilegedControlsSeatsCommand _sut; - public ReservePrivilegedControlsSeatsCommandTests() => + public ReservePrivilegedControlsSeatsCommandTests() + { + _featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support).Returns(true); _sut = new ReservePrivilegedControlsSeatsCommand( _countNewPamSeatsRequiredQuery, _autoscalePrivilegedControlsSeatsCommand, _environment, + _featureService, _logger); + } + + [Theory] + [InlineData(5)] + [InlineData(0)] + public async Task RunAsync_Cloud_FlagOff_SucceedsWithoutCallingAutoscale(int pamSeats) + { + var organization = CreateOrganization(pamSeats: pamSeats); + _featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support).Returns(false); + + var result = await _sut.RunAsync(organization, 2); + + Assert.True(result.Success); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); + } + + [Fact] + public async Task RunAsync_SelfHosted_FlagOff_Shortfall_ReturnsSelfHostedShortfall() + { + var organization = CreateOrganization(); + _environment.SelfHosted.Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support).Returns(false); + _countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, 2).Returns(1); + + var result = await _sut.RunAsync(organization, 2); + + Assert.IsType(result.Error); + } [Theory] [InlineData(0)] diff --git a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs index e4e2248a71b2..6df6bd3e81e3 100644 --- a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs @@ -129,6 +129,7 @@ public void AddPrivilegedControlsSeatAutoscaling_ResolvesReserveAutoscaleRevertA services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddLogging(); services.AddPrivilegedControlsSeatAutoscaling(); From b9d2de91727fe1796e8f783e4a2c831634a2c8be Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:38:43 -0600 Subject: [PATCH 43/44] Update bitwarden_license/src/Sso/Controllers/AccountController.cs Pattern match good Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> --- bitwarden_license/src/Sso/Controllers/AccountController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 704b54269e10..b3df2fd87fdf 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -708,7 +708,7 @@ SsoConfigurationData ssoConfigData { await EnsureSeatAvailableAsync(organization); - if (willPromoteStagedOrgUser && possibleOrgUser!.AccessPam) + if (possibleOrgUser is { Status: OrganizationUserStatusType.Staged, AccessPam: true }) { privilegedControlsSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); if (!privilegedControlsSeatReservation.Success) From a9bf862d56dea8d16822c91733767caaaf873b58 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:13:58 -0600 Subject: [PATCH 44/44] [PM-45107] test: Enable the billing support flag in Privileged Controls seat integration tests Seat reservations now skip seat checks on cloud when PM39883_Pam_Billing_Support is off, so the bulk enable and invite link integration tests that assert autoscaling and seat-limit behavior need the flag on to exercise it. --- .../OrganizationUsersControllerAcceptInviteLinkTests.cs | 5 +++++ .../OrganizationUsersControllerBulkEnablePamTests.cs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs index 3e874220dc26..048cb2e852af 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerAcceptInviteLinkTests.cs @@ -4,6 +4,7 @@ using Bit.Api.IntegrationTest.Auth.Helpers; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; @@ -12,6 +13,8 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bitwarden.Server.Sdk.Features; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; @@ -30,6 +33,8 @@ public class OrganizationUsersControllerAcceptInviteLinkTests : IClassFixture(featureService => + featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support).Returns(true)); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs index 054997c50975..e367b7a4199c 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs @@ -2,6 +2,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.Billing.Constants; @@ -14,6 +15,7 @@ using Bit.Core.Repositories; using Bit.Core.Settings; using Bitwarden.Server.Sdk.Environment; +using Bitwarden.Server.Sdk.Features; using NSubstitute; using Stripe; using Xunit; @@ -40,6 +42,8 @@ public OrganizationUsersControllerBulkEnablePamTests(ApiApplicationFactory apiFa { _factory = apiFactory; _factory.SubstituteService(_ => { }); + _factory.SubstituteService(featureService => + featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support).Returns(true)); // Billing reads self-hosted from the environment, which is fixed at startup, so it follows the settings flag // the tests flip instead. _factory.SubstituteService(environment =>