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/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 defa0b4e53f3..e6cda884deb9 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/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 3672bf92ccd2..b3df2fd87fdf 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -12,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; @@ -59,6 +62,8 @@ public class AccountController : Controller private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IRegisterUserCommand _registerUserCommand; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly IReservePrivilegedControlsSeatsCommand _reservePrivilegedControlsSeatsCommand; + private readonly ISendPrivilegedControlsSeatNeededEmailCommand _sendPrivilegedControlsSeatNeededEmailCommand; public AccountController( IAuthenticationSchemeProvider schemeProvider, @@ -80,7 +85,9 @@ public AccountController( IDataProtectorTokenFactory dataProtector, IOrganizationDomainRepository organizationDomainRepository, IRegisterUserCommand registerUserCommand, - ISendOrganizationInvitesCommand sendOrganizationInvitesCommand) + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + ISendPrivilegedControlsSeatNeededEmailCommand sendPrivilegedControlsSeatNeededEmailCommand) { _schemeProvider = schemeProvider; _clientStore = clientStore; @@ -102,6 +109,8 @@ public AccountController( _organizationDomainRepository = organizationDomainRepository; _registerUserCommand = registerUserCommand; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _reservePrivilegedControlsSeatsCommand = reservePrivilegedControlsSeatsCommand; + _sendPrivilegedControlsSeatNeededEmailCommand = sendPrivilegedControlsSeatNeededEmailCommand; } [HttpGet] @@ -693,10 +702,20 @@ 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? privilegedControlsSeatReservation = null; if (possibleOrgUser == null || willPromoteStagedOrgUser) { await EnsureSeatAvailableAsync(organization); + + if (possibleOrgUser is { Status: OrganizationUserStatusType.Staged, AccessPam: true }) + { + privilegedControlsSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!privilegedControlsSeatReservation.Success) + { + possibleOrgUser.AccessPam = false; + } + } } // If the email domain is verified, we can mark the email as verified @@ -772,13 +791,21 @@ SsoConfigurationData ssoConfigData if (possibleOrgUser.Status == OrganizationUserStatusType.Staged) { - // Seat availability was verified up-front before user creation so safe to consume this seat. + // The member seat and the Privileged Controls seat were both reserved up-front before user + // 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; } possibleOrgUser.UserId = newUser.Id; possibleOrgUser.RevisionDate = DateTime.UtcNow; await _organizationUserRepository.ReplaceAsync(possibleOrgUser); + + if (privilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + { + await _sendPrivilegedControlsSeatNeededEmailCommand.RunAsync( + organization, possibleOrgUser.Email ?? newUser.Email); + } } // Create the SsoUser record to link the user to the SSO provider. @@ -851,6 +878,17 @@ private async Task PromoteStagedOrgUserAndSendInviteAsync( var previousStatus = orgUser.Status; var previousRevisionDate = orgUser.RevisionDate; + var previousAccessPam = orgUser.AccessPam; + + PrivilegedControlsSeatReservationResult? privilegedControlsSeatReservation = null; + if (orgUser.AccessPam) + { + privilegedControlsSeatReservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!privilegedControlsSeatReservation.Success) + { + orgUser.AccessPam = false; + } + } orgUser.Status = OrganizationUserStatusType.Invited; orgUser.RevisionDate = DateTime.UtcNow; @@ -873,10 +911,16 @@ await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( orgUser.Status = previousStatus; orgUser.RevisionDate = previousRevisionDate; + orgUser.AccessPam = previousAccessPam; await _organizationUserRepository.ReplaceAsync(orgUser); throw; } + + if (privilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + { + await _sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, orgUser.Email!); + } } /// 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 077d1238acc8..b09a7760bfc7 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/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/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 4b8885790a1c..d43870aa872f 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -1,11 +1,20 @@ 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.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.IdentityServer; 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.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; @@ -26,8 +35,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +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; @@ -1055,6 +1066,212 @@ public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingUserButNoOr Assert.Equal(email, ex.UserEmail); } + [Theory, BitAutoData] + 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 = BuildStagedOrgUserWithPrivilegedControls(orgId, email); + ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatLimitReachedError()); + var organizationUserRepository = sutProvider.GetDependency(); + + await Assert.ThrowsAsync( + () => InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email)); + + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => + ou.Id == orgUser.Id && + ou.Status == OrganizationUserStatusType.Invited && + !ou.AccessPam)); + sendSeatNeededEmailCommand.RunAsync(organization, email); + }); + } + + [Theory, BitAutoData] + 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 = BuildStagedOrgUserWithPrivilegedControls(orgId, email); + ArrangeStagedMember(sutProvider, organization, null, orgUser, email); + sutProvider.GetDependency() + .RunAsync(orgId, PolicyType.TwoFactorAuthentication) + .Returns(new PolicyStatus(orgId, PolicyType.TwoFactorAuthentication)); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatLimitReachedError()); + var organizationUserRepository = sutProvider.GetDependency(); + + await InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email); + + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => + ou.Id == orgUser.Id && + ou.Status == OrganizationUserStatusType.Invited && + !ou.AccessPam)); + sendSeatNeededEmailCommand.RunAsync(organization, email); + }); + } + + [Theory, BitAutoData] + 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 = BuildStagedOrgUserWithPrivilegedControls(orgId, email); + ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatReservationUnexpectedError()); + + 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)); + await sendSeatNeededEmailCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default!); + } + + [Theory, BitAutoData] + 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 = BuildStagedOrgUserWithPrivilegedControls(orgId, email); + ArrangeStagedMember(sutProvider, organization, null, orgUser, email); + sutProvider.GetDependency() + .RunAsync(orgId, PolicyType.TwoFactorAuthentication) + .Returns(new PolicyStatus(orgId, PolicyType.TwoFactorAuthentication)); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatReservationUnexpectedError()); + + await InvokeCreateUserAndOrgUserConditionallyAsync(sutProvider, orgId, email); + + await sutProvider.GetDependency() + .Received(1) + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), organization); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(ou => ou.Id == orgUser.Id && !ou.AccessPam)); + await sendSeatNeededEmailCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default!); + } + + [Theory, BitAutoData] + 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 = BuildStagedOrgUserWithPrivilegedControls(orgId, email); + ArrangeStagedMember(sutProvider, organization, existingUser, orgUser, email); + var sendSeatNeededEmailCommand = ArrangePrivilegedControlsSeatUnavailable( + sutProvider, organization, new PrivilegedControlsSeatLimitReachedError()); + 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)); + await sendSeatNeededEmailCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default!); + } + + private static ISendPrivilegedControlsSeatNeededEmailCommand ArrangePrivilegedControlsSeatUnavailable( + SutProvider sutProvider, + Organization organization, + CommandError error) + { + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(error)); + return sutProvider.GetDependency(); + } + + private static OrganizationUser BuildStagedOrgUserWithPrivilegedControls(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); + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(new PrivilegedControlsSeatReservationResult()); + } + + 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, diff --git a/bitwarden_license/test/SSO.Test/packages.lock.json b/bitwarden_license/test/SSO.Test/packages.lock.json index 8e8c776843d3..6242b36784a6 100644 --- a/bitwarden_license/test/SSO.Test/packages.lock.json +++ b/bitwarden_license/test/SSO.Test/packages.lock.json @@ -1784,6 +1784,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1819,6 +1822,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": { @@ -1827,6 +1839,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": { @@ -1869,11 +1888,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 d97781979fb0..e4302943cf2b 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 7e02652dcfef..abfa375bfe55 100644 --- a/bitwarden_license/test/Scim.Test/packages.lock.json +++ b/bitwarden_license/test/Scim.Test/packages.lock.json @@ -1744,6 +1744,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1779,6 +1782,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": { @@ -1787,6 +1799,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": { @@ -1810,7 +1829,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": { @@ -1833,6 +1853,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 3c1385bbf88b..27eafa2f6934 100644 --- a/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json @@ -2058,7 +2058,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 df7bae11cfd9..c0ee450cec02 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 e851a756043d..bb22321eaea3 100644 --- a/perf/MicroBenchmarks/packages.lock.json +++ b/perf/MicroBenchmarks/packages.lock.json @@ -1682,6 +1682,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1709,7 +1712,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": { @@ -1735,6 +1739,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": { @@ -1743,6 +1756,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": { @@ -1772,6 +1792,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 7d56145f28a3..e3bf1b7fcaed 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/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/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 2d4d175fd57f..bf0348e355f0 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/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs index 37bc06d0511b..ccc5e112069d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommand.cs @@ -6,6 +6,9 @@ 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; @@ -26,6 +29,8 @@ public class AcceptOrganizationInviteLinkCommand( IMailService mailService, IPushAutoConfirmNotificationCommand pushAutoConfirmNotificationCommand, IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + ISendPrivilegedControlsSeatNeededEmailCommand sendPrivilegedControlsSeatNeededEmailCommand, ILogger logger, IEventService eventService) : IAcceptOrganizationInviteLinkCommand @@ -70,6 +75,7 @@ private async Task> AcceptExistingOrgUserAsync( { var organization = validationResult.Organization; + PrivilegedControlsSeatReservationResult? privilegedControlsSeatReservation = null; if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -77,8 +83,18 @@ private async Task> AcceptExistingOrgUserAsync( { return seatReservationError; } + + if (existingOrganizationUser.AccessPam) + { + privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!privilegedControlsSeatReservation.Success) + { + existingOrganizationUser.AccessPam = false; + } + } } + var memberEmail = existingOrganizationUser.Email ?? user.Email; existingOrganizationUser.Status = OrganizationUserStatusType.Accepted; existingOrganizationUser.UserId = user.Id; existingOrganizationUser.Email = null; @@ -91,6 +107,11 @@ private async Task> AcceptExistingOrgUserAsync( await organizationUserRepository.ReplaceAsync(existingOrganizationUser); + if (privilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + { + 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 8ad827ff857f..047e455577a4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommand.cs @@ -6,6 +6,9 @@ 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; @@ -37,6 +40,8 @@ public class ConfirmOrganizationInviteLinkCommand( IStripePaymentService stripePaymentService, IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, IPushNotificationService pushNotificationService, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + ISendPrivilegedControlsSeatNeededEmailCommand sendPrivilegedControlsSeatNeededEmailCommand, IEventService eventService, ILogger logger) : IConfirmOrganizationInviteLinkCommand @@ -115,6 +120,7 @@ private async Task> AddUserToOrganizationAsync(C private async Task> ConfirmExistingMembershipAsync( Organization organization, OrganizationUser existingOrganizationUser, User user, string orgUserKey) { + PrivilegedControlsSeatReservationResult? privilegedControlsSeatReservation = null; if (existingOrganizationUser.Status == OrganizationUserStatusType.Staged) { var seatReservationError = await ReserveSeatAsync(organization); @@ -122,8 +128,18 @@ private async Task> ConfirmExistingMembershipAsy { return seatReservationError; } + + if (existingOrganizationUser.AccessPam) + { + privilegedControlsSeatReservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, 1); + if (!privilegedControlsSeatReservation.Success) + { + existingOrganizationUser.AccessPam = false; + } + } } + var memberEmail = existingOrganizationUser.Email ?? user.Email; existingOrganizationUser.Status = OrganizationUserStatusType.Confirmed; existingOrganizationUser.UserId = user.Id; existingOrganizationUser.Email = null; @@ -132,6 +148,11 @@ private async Task> ConfirmExistingMembershipAsy await organizationUserRepository.ReplaceAsync(existingOrganizationUser); + if (privilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + { + 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 7fe5ae196169..d05dadc6752e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs @@ -2,17 +2,16 @@ 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.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Helpers; 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 +30,12 @@ public async Task BulkEnablePamAsync(Organization organization, I return new PamNotEnabled(); } - var additionalPamSeatsRequired = - await countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, organizationUsers.Count); - if (additionalPamSeatsRequired > 0 && globalSettings.SelfHosted) + var privilegedControlsSeatsRequested = + organizationUsers.Count(u => PrivilegedControlsSeatOccupancy.OccupiesSeat(u.Status)); + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(organization, privilegedControlsSeatsRequested); + if (!reservation.Success) { - return new CannotAutoscalePamSeatsOnSelfHost(); + return reservation.Error!; } foreach (var organizationUser in organizationUsers) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 12bebf3d023e..600cdc7ef3ae 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -7,9 +7,14 @@ 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; @@ -17,6 +22,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; @@ -33,7 +39,10 @@ public class RestoreOrganizationUserCommand( ICollectionRepository collectionRepository, IAutomaticUserConfirmationPolicyEnforcementHandler automaticUserConfirmationPolicyEnforcementHandler, IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, - TimeProvider timeProvider) : IRestoreOrganizationUserCommand + TimeProvider timeProvider, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + ISendPrivilegedControlsSeatNeededEmailCommand sendPrivilegedControlsSeatNeededEmailCommand, + ILogger logger) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName) { @@ -54,7 +63,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 +74,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 +84,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) { @@ -85,6 +95,7 @@ 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 initialPasswordManagerSeats = organization.Seats; if (availableSeats < 1) { @@ -109,6 +120,28 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled); var status = organizationUser.GetPriorActiveOrganizationUserStatusType(); + PrivilegedControlsSeatReservationResult scimPrivilegedControlsSeatReservation = null; + + 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 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) + { + 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. + await organizationUserRepository.ReplaceAsync(organizationUser); + } + } + } await organizationUserRepository.RestoreAsync(organizationUser.Id, status); @@ -118,6 +151,13 @@ await organizationUserRepository.StartInviteReminderSequencesAsync( [organizationUser.Id], timeProvider.GetUtcNow().UtcDateTime); } + if (scimPrivilegedControlsSeatReservation?.Error is PrivilegedControlsSeatLimitReachedError) + { + var memberEmail = organizationUser.Email + ?? (await userRepository.GetByIdAsync(organizationUser.UserId!.Value)).Email; + await sendPrivilegedControlsSeatNeededEmailCommand.RunAsync(organization, memberEmail); + } + if (organizationUser.UserId.HasValue && organization.UseMyItems && (await policyRequirementQuery.GetAsync(organizationUser.UserId.Value)).State == OrganizationDataOwnershipState.Enabled @@ -130,6 +170,51 @@ await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.Organi } } + /// + /// 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. + /// + /// 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, seatsRequested); + if (reservation.Success) + { + return; + } + + await ReleasePasswordManagerSeatsAsync(organization, initialPasswordManagerSeats); + throw reservation.Error is InternalError + ? new InvalidOperationException(reservation.Error.Message, reservation.Exception) + : new BadRequestException(reservation.Error!.Message); + } + + /// + /// Restores the Password Manager seat count captured before the restore bought seats. + /// + private async Task ReleasePasswordManagerSeatsAsync(Organization organization, int? initialPasswordManagerSeats) + { + if (initialPasswordManagerSeats is null || initialPasswordManagerSeats == organization.Seats) + { + return; + } + + var seatsToRelease = organization.Seats.GetValueOrDefault() - initialPasswordManagerSeats.Value; + + 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); + } + } + private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser) { var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId!.Value); @@ -195,8 +280,22 @@ 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); + if (organization.UsePam) + { + // 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 ReservePrivilegedControlsSeatsOrThrowAsync(organization, privilegedControlsSeatsRequested, + initialPasswordManagerSeats); + } + var restoringUserIsOwner = false; var restoringUserIsAdminOrHigher = false; if (restoringUserId.HasValue) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs index 5928752cdae0..e53501bb246e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; 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 +27,8 @@ public class InviteStagedOrganizationUsersCommand( IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IStripePaymentService stripePaymentService, IPricingClient pricingClient, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + IRevertPrivilegedControlsSeatsCommand revertPrivilegedControlsSeatsCommand, ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, IEventService eventService, TimeProvider timeProvider, @@ -71,6 +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 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); @@ -80,12 +84,24 @@ public async Task>> RunAsync(Invite return secretsManagerSeatReservationError; } + var membersWithPrivilegedControls = eligible.Count(organizationUser => organizationUser.AccessPam); + 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, initialPrivilegedControlsSeats); + await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); + await ReleaseSeatsAsync(organization, seatsToAdd); + return privilegedControlsSeatReservation.Error!; + } + try { await InviteAsync(eligible, organization, request.PerformedBy); } catch { + await ReleasePrivilegedControlsSeatsAsync(organization, initialPrivilegedControlsSeats); await ReleaseSecretsManagerSeatsAsync(organization, secretsManagerSeatsToAdd, initialSecretsManagerSeats); await ReleaseSeatsAsync(organization, seatsToAdd); throw; @@ -391,4 +407,27 @@ private async Task ReleaseSeatsAsync(Organization organization, int seatsToRelea seatsToRelease, organization.Id); } } + + /// + /// 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? initialPrivilegedControlsSeats) + { + if (initialPrivilegedControlsSeats is null || initialPrivilegedControlsSeats == organization.PamSeats) + { + return; + } + + try + { + await revertPrivilegedControlsSeatsCommand.RunAsync(organization, initialPrivilegedControlsSeats.Value); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to release Privileged Controls seat(s) for organization {OrganizationId}", + organization.Id); + } + } } 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/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs index 1a381161b6d1..566a74200658 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -5,7 +5,8 @@ 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.Organizations.Commands; +using Bit.Core.Billing.Organizations.Helpers; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; using Bit.Core.Enums; @@ -27,7 +28,7 @@ public class UpdateOrganizationUserCommand( IOrganizationUserRepository organizationUserRepository, IUpdateOrganizationUserValidator validator, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, - ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IPricingClient pricingClient, IEventService eventService, @@ -204,15 +205,13 @@ 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) + if (!PrivilegedControlsSeatOccupancy.OccupiesSeat(request.OrganizationUserToUpdate.Status)) { - return new CannotAutoscalePamSeatsOnSelfHost(); + return null; } - return null; + var reservation = await reservePrivilegedControlsSeatsCommand.RunAsync(request.Organization, 1); + return reservation.Error; } private async Task LoadUserToUpdateAsync(UpdateOrganizationUserRequest request) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IReplaceOrganizationAndUpdateCacheCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IReplaceOrganizationAndUpdateCacheCommand.cs new file mode 100644 index 000000000000..5ea641f6298d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IReplaceOrganizationAndUpdateCacheCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +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 RunAsync(Organization org, EventType? orgEvent = null); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommand.cs new file mode 100644 index 000000000000..7b47343eb3a9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommand.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 ReplaceOrganizationAndUpdateCacheCommand( + IOrganizationRepository organizationRepository, + IOrganizationAbilityCacheService organizationAbilityCacheService, + IEventService eventService, + ILogger logger) : IReplaceOrganizationAndUpdateCacheCommand +{ + public async Task RunAsync(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 replacing Organization ({OrganizationID}) and updating its cache", org.Id); + throw; + } + } +} 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 ccfc0f37326d..a9cbce06668f 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -4,12 +4,14 @@ 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.Repositories; +using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Constants; @@ -59,6 +61,9 @@ public class OrganizationService : IOrganizationService private readonly IStripeAdapter _stripeAdapter; private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand; private readonly TimeProvider _timeProvider; + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand; + 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, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, + IReservePrivilegedControlsSeatsCommand reservePrivilegedControlsSeatsCommand, + IRevertPrivilegedControlsSeatsCommand revertPrivilegedControlsSeatsCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -106,6 +114,9 @@ public OrganizationService( _stripeAdapter = stripeAdapter; _updateOrganizationSubscriptionCommand = updateOrganizationSubscriptionCommand; _timeProvider = timeProvider; + _replaceOrganizationAndUpdateCacheCommand = replaceOrganizationAndUpdateCacheCommand; + _reservePrivilegedControlsSeatsCommand = reservePrivilegedControlsSeatsCommand; + _revertPrivilegedControlsSeatsCommand = revertPrivilegedControlsSeatsCommand; } public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) @@ -132,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; } @@ -200,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) @@ -314,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) @@ -346,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); } } @@ -366,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)) { @@ -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 initialPrivilegedControlsSeats = 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, initialPrivilegedControlsSeats); + var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id); // Revert autoscaling @@ -828,6 +852,41 @@ private async Task RestorePromotedStagedUsersAsync( await _organizationUserRepository.ReplaceManyAsync(promotedStagedUsers); } + private async Task ReservePrivilegedControlsSeatsAsync(Organization organization, int seatsRequested) + { + var reservation = await _reservePrivilegedControlsSeatsCommand.RunAsync(organization, seatsRequested); + if (reservation.Success) + { + return; + } + + throw reservation.Error is InternalError + ? new InvalidOperationException(reservation.Error.Message, reservation.Exception) + : new BadRequestException(reservation.Error.Message); + } + + /// + /// Restores the Privileged Controls seat count captured before the reservation when the invite fails. + /// + private async Task ReleasePrivilegedControlsSeatsAsync(Organization organization, int? initialPrivilegedControlsSeats) + { + if (initialPrivilegedControlsSeats is null || initialPrivilegedControlsSeats == organization.PamSeats) + { + return; + } + + try + { + await _revertPrivilegedControlsSeatsCommand.RunAsync(organization, initialPrivilegedControlsSeats.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)); @@ -976,26 +1035,6 @@ 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; - } - } - private async Task GetOrgById(Guid id) { return await _organizationRepository.GetByIdAsync(id); 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/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..069c07bc46e7 --- /dev/null +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.html.hbs @@ -0,0 +1,529 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
A member needs a Privileged Controls seat
+ +
+ +
{{ MemberEmail }} has been given access to {{ OrganizationName }}, + but not to Privileged Controls, because your organization has + reached its Privileged Controls seat limit.
+ +
+ +
You can add more seats by going to the Subscription page and + increasing 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..dec23c32a50f --- /dev/null +++ b/src/Core/Billing/Models/Mail/Mailer/PrivilegedControlsSeatNeededView.text.hbs @@ -0,0 +1,7 @@ +A member needs a Privileged Controls seat + +{{{MemberEmail}}} has been given access to {{{OrganizationName}}}, but not to Privileged Controls, because your organization has reached its Privileged Controls seat limit. + +You can add more seats by going to the Subscription page and increasing the limit. + +Manage subscription: {{{VaultSubscriptionUrl}}} 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/IAutoscalePrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs index c020936aea12..842714f3283a 100644 --- a/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Core/Billing/Organizations/Commands/IAutoscalePrivilegedControlsSeatsCommand.cs @@ -12,22 +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. + /// 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 or 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 new file mode 100644 index 000000000000..bd3e34b7fc0c --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/IReservePrivilegedControlsSeatsCommand.cs @@ -0,0 +1,27 @@ +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 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 seats are requested, the organization doesn't use Privileged + /// 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/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs b/src/Core/Billing/Organizations/Commands/IRevertPrivilegedControlsSeatsCommand.cs new file mode 100644 index 000000000000..aac620f68489 --- /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 RunAsync(OrganizationEntity organization, int seats); +} 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/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/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/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/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. + + + + + + + + + + + + + + + A member needs a Privileged Controls seat + + + {{ MemberEmail }} has been given access to {{ OrganizationName }}, + but not to Privileged Controls, because your organization has + reached its Privileged Controls seat limit. + + + You can add more seats by going to the Subscription page and + increasing the limit. + + Manage subscription + + + + + + + + + + diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index bbcacd84c265..4c1bf32a501f 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -112,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) => 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/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 defa0b4e53f3..e6cda884deb9 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/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 c5105b1c77b8..4ad04d565eb7 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 88% rename from src/Libraries/Subscriptions.Organization/Commands/AddPrivilegedControlsToExistingSubscriptionCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs index c0d6e93a12ae..e7c18d51aa2a 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/AddPrivilegedControlsToExistingSubscriptionCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AddPrivilegedControlsToExistingSubscriptionCommand.cs @@ -1,12 +1,12 @@ -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; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal interface IAddPrivilegedControlsToExistingSubscriptionCommand { @@ -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/AutoscalePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs similarity index 75% rename from src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/AutoscalePrivilegedControlsSeatsCommand.cs index d0db41adb68f..7c5415d1ebbd 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/AutoscalePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/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,33 +7,34 @@ 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; using Bitwarden.Server.Sdk.Environment; 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, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, - IOrganizationService organizationService, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, IOrganizationUserRepository organizationUserRepository, + IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, IMailer mailer, IGlobalSettings globalSettings, 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."); } @@ -42,13 +44,8 @@ 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; + await organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + seatsRequested; if (seatsNeeded > organization.PamSeats) { @@ -57,11 +54,14 @@ 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); + await replaceOrganizationAndUpdateCacheCommand.RunAsync(organization); } if (organization.MaxAutoscalePamSeats == seatsNeeded) @@ -84,7 +84,7 @@ private async Task NotifyOwnersOfSeatLimitAsync(OrganizationEntity organization, await mailer.SendEmail(new PrivilegedControlsSeatLimitReachedMail { ToEmails = ownerEmails, - Subject = $"{organization.DisplayName()} Privileged Controls seat limit reached", + Subject = $"{organization.DisplayName()} has reached its Privileged Controls seat limit", View = new PrivilegedControlsSeatLimitReachedView(globalSettings) { OrganizationId = organization.Id, diff --git a/src/Libraries/Subscriptions.Organization/Commands/ChangePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs similarity index 90% rename from src/Libraries/Subscriptions.Organization/Commands/ChangePrivilegedControlsSeatsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs index febbf40c9475..764f9e833165 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/ChangePrivilegedControlsSeatsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ChangePrivilegedControlsSeatsCommand.cs @@ -1,12 +1,12 @@ -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; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal interface IChangePrivilegedControlsSeatsCommand { @@ -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/RemovePrivilegedControlsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs similarity index 90% rename from src/Libraries/Subscriptions.Organization/Commands/RemovePrivilegedControlsCommand.cs rename to src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs index 91a84931cd82..96aa29628ea1 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/RemovePrivilegedControlsCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RemovePrivilegedControlsCommand.cs @@ -1,13 +1,13 @@ -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; -namespace Bit.Subscriptions.Organization.Commands; +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; internal interface IRemovePrivilegedControlsCommand { @@ -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/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs new file mode 100644 index 000000000000..7b3875187416 --- /dev/null +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/ReservePrivilegedControlsSeatsCommand.cs @@ -0,0 +1,79 @@ +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; + +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; + +internal sealed class ReservePrivilegedControlsSeatsCommand( + ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, + IAutoscalePrivilegedControlsSeatsCommand autoscalePrivilegedControlsSeatsCommand, + IBitwardenEnvironment environment, + IFeatureService featureService, + ILogger logger) : IReservePrivilegedControlsSeatsCommand +{ + public async Task RunAsync(OrganizationEntity organization, int seatsRequested) + { + // A null seat count means no seat count applies to the organization yet. + if (seatsRequested < 1 || !organization.UsePam || organization.PamSeats is null) + { + return new PrivilegedControlsSeatReservationResult(); + } + + try + { + return await ReserveAsync(organization, seatsRequested); + } + catch (BadRequestException e) + { + logger.LogWarning(e, + "Could not reserve {SeatsToReserve} Privileged Controls seat(s) for organization {OrganizationId}", + 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}", + seatsRequested, organization.Id); + return new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatReservationUnexpectedError(), e); + } + } + + private async Task ReserveAsync(OrganizationEntity organization, int seatsRequested) + { + if (environment.SelfHosted) + { + var seatsRequired = await countNewPamSeatsRequiredQuery + .CountNewPamSeatsRequiredAsync(organization, seatsRequested); + + return seatsRequired > 0 + ? new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError()) + : 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) + { + return new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError()); + } + + var result = await autoscalePrivilegedControlsSeatsCommand.RunAsync(organization, seatsRequested); + + return result.Success + ? 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 new file mode 100644 index 000000000000..ef8dfca36281 --- /dev/null +++ b/src/Libraries/Subscriptions.Organization/Commands/PrivilegedControls/RevertPrivilegedControlsSeatsCommand.cs @@ -0,0 +1,39 @@ +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.Subscriptions.Organization.Queries; +using Bitwarden.Server.Sdk.Environment; +using OrganizationEntity = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Subscriptions.Organization.Commands.PrivilegedControls; + +internal sealed class RevertPrivilegedControlsSeatsCommand( + IPrivilegedControlsSeatChangeSetFactory seatChangeSetFactory, + IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, + IReplaceOrganizationAndUpdateCacheCommand replaceOrganizationAndUpdateCacheCommand, + IIsOnPrivilegedControlsSalesInitiatedTrialQuery isOnTrialQuery, + IBitwardenEnvironment environment) : IRevertPrivilegedControlsSeatsCommand +{ + public async Task RunAsync(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 changeSet = await seatChangeSetFactory.CreateAsync(organization, seats); + (await updateOrganizationSubscriptionCommand.Run(organization, changeSet)).GetValueOrThrowHttpException(); + } + + organization.PamSeats = seats; + 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..8ea5b97999b8 --- /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 = $"A member at {organizationName} needs a Privileged Controls seat", + 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/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/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/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/src/Libraries/Subscriptions.Organization/README.md b/src/Libraries/Subscriptions.Organization/README.md index c8c4dcfaea9f..7dccfa614a87 100644 --- a/src/Libraries/Subscriptions.Organization/README.md +++ b/src/Libraries/Subscriptions.Organization/README.md @@ -25,8 +25,13 @@ 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`, `IReservePrivilegedControlsSeatsCommand`, and +`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, 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 @@ -310,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 | @@ -326,9 +331,14 @@ 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`, `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 | +| `IGlobalSettings` (`Bit.Core.Settings`), `CoreHelpers` (`Bit.Core.Utilities`) | Building the Owner email links and preventing auto-linking in email content | +| `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 | | `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 ea3c175afbd3..07328eb71453 100644 --- a/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs +++ b/src/Libraries/Subscriptions.Organization/SubscriptionsOrganizationServiceCollectionExtensions.cs @@ -2,7 +2,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; @@ -21,6 +23,7 @@ public static IServiceCollection AddOrganizationSubscriptions(this IServiceColle { services.AddInvoicing(); services.AddOrganizationAuthorization(); + services.AddPrivilegedControlsSeatAutoscaling(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -29,17 +32,31 @@ 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 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) + { + services.TryAddScoped(); + services.TryAddScoped(); + 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 51896c79fb6e..56981505cbf0 100644 --- a/test/Admin.Test/packages.lock.json +++ b/test/Admin.Test/packages.lock.json @@ -1705,7 +1705,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": { @@ -1798,6 +1799,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -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": { @@ -1856,6 +1869,13 @@ "Infrastructure.EntityFramework": "[2026.10.1, )" } }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.10.1, )", + "CurrentUser": "[0.0.1, )" + } + }, "pam.domain": { "type": "Project", "dependencies": { @@ -1899,6 +1919,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/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs index 7d7072f8a7e6..0018b61a1681 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerPutTests.cs @@ -4,17 +4,20 @@ 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; 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; using Bit.Core.Repositories; using Bitwarden.Server.Sdk.Features; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; @@ -32,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); } @@ -417,6 +421,32 @@ public async Task Put_AdminGrantingPamToSelf_Succeeds() await AssertAccessPamAsync(admin, true); } + [Fact] + public async Task Put_GrantingPrivilegedControlsAtSeatLimit_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 PrivilegedControlsSeatLimitReachedError().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..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); } @@ -438,6 +443,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_WithStagedMembershipWithPrivilegedControls_AtPrivilegedControlsSeatLimit_JoinsWithoutPrivilegedControls() + { + await FillPrivilegedControlsSeatsAtLimitAsync(); + 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 +648,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 +658,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 FillPrivilegedControlsSeatsAtLimitAsync() + { + _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(); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs index 305c95b1929c..e367b7a4199c 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs @@ -2,14 +2,22 @@ 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; 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 Bitwarden.Server.Sdk.Environment; +using Bitwarden.Server.Sdk.Features; +using NSubstitute; +using Stripe; using Xunit; using V1_RestoreUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; @@ -22,13 +30,24 @@ 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(_ => { }); + _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 => + environment.SelfHosted.Returns(_ => _factory.GetService().SelfHosted)); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } @@ -42,6 +61,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 +239,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 +266,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/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 } + } + } + ] + } + }; } diff --git a/test/Api.IntegrationTest/packages.lock.json b/test/Api.IntegrationTest/packages.lock.json index 7b841092c42c..d80ce6c4b588 100644 --- a/test/Api.IntegrationTest/packages.lock.json +++ b/test/Api.IntegrationTest/packages.lock.json @@ -1559,7 +1559,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 4cc893a3dba3..cc225e30c3ea 100644 --- a/test/Billing.IntegrationTest/packages.lock.json +++ b/test/Billing.IntegrationTest/packages.lock.json @@ -1922,7 +1922,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": { @@ -1983,6 +1984,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]" } }, @@ -2106,7 +2108,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/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/Billing.Test/packages.lock.json b/test/Billing.Test/packages.lock.json index 201cdbb34e55..8c83b3cd53f4 100644 --- a/test/Billing.Test/packages.lock.json +++ b/test/Billing.Test/packages.lock.json @@ -1759,6 +1759,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]" } }, @@ -1858,6 +1859,9 @@ "xunit": "[2.6.6, )" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1893,6 +1897,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": { @@ -1901,6 +1914,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": { @@ -1930,6 +1950,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/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs index e9c6f0b21093..b42cfff6a6a8 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/AcceptOrganizationInviteLinkCommandTests.cs @@ -4,6 +4,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; 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; @@ -246,6 +249,152 @@ await sutProvider.GetDependency() .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any()); } + [Theory, BitAutoData] + public async Task AcceptAsync_StagedMemberWithPrivilegedControls_AtLimit_JoinsWithoutPrivilegedControls( + 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, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + var organizationUserRepository = sutProvider.GetDependency(); + var sendSeatNeededEmailCommand = sutProvider.GetDependency(); + + var result = await sutProvider.Sut.AcceptAsync(BuildRequest(organization, inviteLink, user)); + + Assert.True(result.IsSuccess); + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => + ou.Id == stagedOrganizationUser.Id && + ou.Status == OrganizationUserStatusType.Accepted && + !ou.AccessPam)); + sendSeatNeededEmailCommand.RunAsync(organization, memberEmail); + }); + } + + [Theory, BitAutoData] + public async Task AcceptAsync_StagedMemberWithPrivilegedControls_ReservationFailed_JoinsWithoutPrivilegedControlsOrEmail( + 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, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), new TimeoutException("Stripe is unreachable."))); + + 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 sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); + } + + [Theory, BitAutoData] + public async Task AcceptAsync_StagedMemberWithPrivilegedControls_SeatAvailable_JoinsWithPrivilegedControls( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, sutProvider); + SetupStagedMembership(organization, user, stagedOrganizationUser, sutProvider); + stagedOrganizationUser.AccessPam = true; + + 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 sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); + } + + [Theory, BitAutoData] + public async Task AcceptAsync_StagedMemberWithoutPrivilegedControls_ReservesNoPrivilegedControlsSeat( + 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() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task AcceptAsync_NotStaged_DoesNotReservePrivilegedControlsSeat( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser invitedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, sutProvider); + invitedOrganizationUser.OrganizationId = organization.Id; + invitedOrganizationUser.Status = OrganizationUserStatusType.Invited; + invitedOrganizationUser.Email = user.Email; + invitedOrganizationUser.UserId = null; + 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); + } + + 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, @@ -606,6 +755,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 c68e45958f72..d5d52f70bbbd 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ConfirmOrganizationInviteLinkCommandTests.cs @@ -6,6 +6,9 @@ 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; @@ -318,6 +321,109 @@ await sutProvider.GetDependency() ou.ExternalId == stagedOrganizationUser.ExternalId)); } + [Theory, BitAutoData] + public async Task ConfirmAsync_StagedMemberWithPrivilegedControls_AtLimit_JoinsWithoutPrivilegedControls( + 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, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + var organizationUserRepository = sutProvider.GetDependency(); + var sendSeatNeededEmailCommand = sutProvider.GetDependency(); + + var result = await sutProvider.Sut.ConfirmAsync(BuildRequest(inviteLink, user)); + + Assert.True(result.IsSuccess); + Received.InOrder(() => + { + organizationUserRepository.ReplaceAsync(Arg.Is(ou => + ou.Id == stagedOrganizationUser.Id && + ou.Status == OrganizationUserStatusType.Confirmed && + !ou.AccessPam)); + sendSeatNeededEmailCommand.RunAsync(organization, memberEmail); + }); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_StagedMemberWithPrivilegedControls_ReservationFailed_JoinsWithoutPrivilegedControlsOrEmail( + 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, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), new TimeoutException("Stripe is unreachable."))); + + 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.AccessPam)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_StagedMemberWithPrivilegedControls_SeatAvailable_JoinsWithPrivilegedControls( + Organization organization, + OrganizationInviteLink inviteLink, + User user, + OrganizationUser stagedOrganizationUser, + SutProvider sutProvider) + { + SetupHappyPath(organization, inviteLink, user, stagedOrganizationUser, sutProvider); + SetupStagedMembership(user, stagedOrganizationUser); + stagedOrganizationUser.AccessPam = true; + + 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 sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); + } + + [Theory, BitAutoData] + public async Task ConfirmAsync_NotStaged_DoesNotReservePrivilegedControlsSeat( + 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(default!, default); + } + [Theory, BitAutoData] public async Task ConfirmAsync_WhenAutoEnrollEnabled_EnrollsUser( Organization organization, @@ -487,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 eafede1a7f8e..17f188bb799d 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs @@ -2,10 +2,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.BulkEnablePam; 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.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; -using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -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); @@ -131,15 +139,14 @@ 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(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!); @@ -160,10 +167,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(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -188,10 +194,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(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); @@ -200,4 +205,131 @@ 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(new PrivilegedControlsSeatReservationResult()); + + 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(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); + 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(new PrivilegedControlsSeatReservationResult()); + + 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))); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 2994b4fbf6c8..0a503350bddc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -10,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; @@ -25,6 +28,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; @@ -1232,6 +1236,539 @@ await organizationUserRepository .RestoreAsync(orgUser1.Id, Arg.Is(x => x != OrganizationUserStatusType.Revoked)); } + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_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_MemberWithPrivilegedControls_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(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Equal(new PrivilegedControlsSeatLimitReachedError().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_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(new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError())); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Equal(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_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); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + 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_MemberWithPrivilegedControls_ReservationFails_GivesBackThePasswordManagerSeatAndThrowsBadRequest( + 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); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatExpansionFailedError("Stripe rejected the update."), + new BadRequestException("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_MemberWithPrivilegedControls_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); + SetupAutoAddSeatsRaisesSeatCount(organization, 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_MemberWithPrivilegedControls_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); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + 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 PrivilegedControlsSeatLimitReachedError().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Admin_MemberWithPrivilegedControls_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(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + 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, + [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_RestoresWithoutPrivilegedControlsAndSaves( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + 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, 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); + sendSeatNeededEmailCommand.RunAsync(organization, "member@example.com"); + }); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Scim_SelfHostedShortfall_RestoresWithoutPrivilegedControlsOrEmail( + 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, + [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); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, EventSystemUser.SCIM); + + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, user.Email); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Scim_ReservationFailed_RestoresWithoutPrivilegedControlsOrEmail( + 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 PrivilegedControlsSeatReservationUnexpectedError(), new TimeoutException("Stripe is unreachable."))); + + 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 sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); + } + + [Theory, BitAutoData] + public async Task RestoreUser_Scim_SeatAvailable_RestoresWithPrivilegedControls( + 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.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 sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default!); + Assert.True(organizationUser.AccessPam); + } + + [Theory, BitAutoData] + public async Task RestoreUser_PublicApi_MemberWithPrivilegedControls_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); + Assert.True(organizationUser.AccessPam); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_CountsOnlyRevokedMembersWithPrivilegedControlsReturningToSeatUsingStatus( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [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, 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; + } + confirmedWithoutPrivilegedControls.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(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null)); + + Assert.Equal(new PrivilegedControlsSeatLimitReachedError().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .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); + SetupAutoAddSeatsRaisesSeatCount(organization, 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(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + 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_ReservationFailsUnexpectedly_GivesBackThePasswordManagerSeatsAndThrows( + 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); + SetupAutoAddSeatsRaisesSeatCount(organization, sutProvider); + orgUser1.AccessPam = true; + orgUser1.StatusNew = OrganizationUserStatusTypeNew.Confirmed; + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns([orgUser1]); + sutProvider.GetDependency() + .RunAsync(organization, 1) + .Returns(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), new TimeoutException("Stripe is unreachable."))); + + 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(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .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, @@ -1251,6 +1788,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 58bb2aadc9fa..f6af8bc7726f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommandTests.cs @@ -6,6 +6,9 @@ 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; @@ -74,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 { @@ -713,6 +719,334 @@ 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 AutoscalePrivilegedControlsSeatsTo( + SutProvider sutProvider, Organization organization, int newPrivilegedControlsSeats) => + sutProvider.GetDependency() + .RunAsync(organization, Arg.Any()) + .Returns(callInfo => + { + organization.PamSeats = newPrivilegedControlsSeats; + return new PrivilegedControlsSeatReservationResult(); + }); + + [Theory, BitAutoData] + public async Task RunAsync_StagedMembersWithPrivilegedControls_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_ReservesPasswordManagerSeatsBeforePrivilegedControlsSeats( + 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_PrivilegedControlsAtLimit_ReturnsSeatLimitReachedAndReleasesPasswordManagerSeats( + 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(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + 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_PrivilegedControlsAtLimit_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(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + 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_PrivilegedControlsReservationFails_ReturnsSeatExpansionFailedAndReleasesPasswordManagerSeats( + 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(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.Equal("Stripe rejected the subscription change.", result.AsError.Message); + await sutProvider.GetDependency() + .Received(1) + .AdjustSeatsAsync(organization.Id, -(organizationUsers.Count - 1)); + } + + [Theory, BitAutoData] + public async Task RunAsync_PrivilegedControlsReservationFailsUnexpectedly_ReturnsInternalErrorAndReleasesPasswordManagerSeats( + 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(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), + new ConflictException("The subscription is unpaid."))); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + 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_PrivilegedControlsReservationFailsUnexpectedly_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(new PrivilegedControlsSeatReservationResult( + new PrivilegedControlsSeatReservationUnexpectedError(), + new ConflictException("The subscription is unpaid."))); + + 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_PrivilegedControlsReservationFailsAfterSeatsChanged_RevertsPrivilegedControlsSeats( + 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) + .RunAsync(organization, 5); + } + + [Theory, BitAutoData] + public async Task RunAsync_PrivilegedControlsSelfHostedShortfall_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(new PrivilegedControlsSeatReservationResult(new CannotAutoscalePrivilegedControlsSeatsOnSelfHostError())); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + await AssertNothingHappenedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task RunAsync_PasswordManagerReservationFails_DoesNotReservePrivilegedControlsSeats( + 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); + sutProvider.GetDependency() + .AutoAddSeatsAsync(organization, Arg.Any()) + .ThrowsAsync(new BadRequestException("Seat limit has been reached.")); + + var result = await sutProvider.Sut.RunAsync(request); + + Assert.IsType(result.AsError); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task RunAsync_SecretsManagerAdjustFails_DoesNotReservePrivilegedControlsSeats( + 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); + 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() + .DidNotReceiveWithAnyArgs() + .RunAsync(default!, default); + } + + [Theory, BitAutoData] + 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); + AutoscalePrivilegedControlsSeatsTo(sutProvider, organization, 7); + FailInviteSend(sutProvider); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organization, 5); + } + + [Theory, BitAutoData] + 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); + AutoscalePrivilegedControlsSeatsTo(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_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); + AutoscalePrivilegedControlsSeatsTo(sutProvider, organization, 7); + FailInviteSend(sutProvider); + sutProvider.GetDependency() + .RunAsync(organization, 5) + .ThrowsAsync(new GatewayException("Stripe is unreachable.")); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(request)); + } + + [Theory, BitAutoData] + public async Task RunAsync_NoPrivilegedControlsSeatsAdded_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() + .RunAsync(default!, default); + } + /// Asserts the request succeeded overall but reported for one member. private static void AssertSkipped( CommandResult> result, Guid skippedId) where TError : Error 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..b608fcd81a9a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -5,7 +5,9 @@ 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.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; @@ -15,7 +17,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,15 +115,14 @@ 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(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,10 +137,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(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.UpdateUserAsync(request); @@ -160,10 +159,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(new PrivilegedControlsSeatReservationResult()); var result = await sutProvider.Sut.UpdateUserAsync(request); @@ -174,6 +172,125 @@ await sutProvider.GetDependency() Arg.Any>()); } + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_EnablingPrivilegedControls_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_EnablingPrivilegedControls_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(new PrivilegedControlsSeatReservationResult(new PrivilegedControlsSeatLimitReachedError())); + + 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_EnablingPrivilegedControls_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>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_EnablingPrivilegedControls_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_EnablingPrivilegedControls_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_AlreadyHasPrivilegedControls_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( @@ -551,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/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/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommandTests.cs new file mode 100644 index 000000000000..59698ca09a1c --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/ReplaceOrganizationAndUpdateCacheCommandTests.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 ReplaceOrganizationAndUpdateCacheCommandTests +{ + [Theory, BitAutoData] + public async Task RunAsync_SavesThenRefreshesTheCache( + Organization organization, + SutProvider sutProvider) + { + await sutProvider.Sut.RunAsync(organization); + + var organizationRepository = sutProvider.GetDependency(); + var organizationAbilityCacheService = sutProvider.GetDependency(); + Received.InOrder(() => + { + organizationRepository.ReplaceAsync(organization); + organizationAbilityCacheService.UpsertOrganizationAbilityAsync(organization); + }); + } + + [Theory, BitAutoData] + public async Task RunAsync_NoEvent_LogsNothing( + Organization organization, + SutProvider sutProvider) + { + await sutProvider.Sut.RunAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationEventAsync(default(Organization)!, default); + } + + [Theory, BitAutoData] + public async Task RunAsync_WithEvent_LogsTheEvent( + Organization organization, + SutProvider sutProvider) + { + await sutProvider.Sut.RunAsync(organization, EventType.Organization_Updated); + + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationEventAsync(organization, EventType.Organization_Updated); + } + + [Theory, BitAutoData] + public async Task RunAsync_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.RunAsync(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..2c72c247c099 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -1,14 +1,15 @@ 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.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; @@ -1263,9 +1264,8 @@ public async Task UpdateAsync_WhenValidOrganization_AndUpdateBillingIsTrue_Updat { // Arrange var organizationRepository = sutProvider.GetDependency(); - var organizationAbilityCacheService = sutProvider.GetDependency(); + var replaceOrganizationAndUpdateCacheCommand = 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 - .Received(1) - .UpsertOrganizationAbilityAsync(Arg.Is(org => org == organization)); - await eventService + await replaceOrganizationAndUpdateCacheCommand .Received(1) - .LogOrganizationEventAsync(Arg.Is(org => org == organization), - Arg.Is(e => e == EventType.Organization_Updated)); + .RunAsync(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 replaceOrganizationAndUpdateCacheCommand = 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 - .Received(1) - .ReplaceAsync(Arg.Is(org => org == organization)); - await organizationAbilityCacheService + await replaceOrganizationAndUpdateCacheCommand .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)); + .RunAsync(organization, EventType.Organization_Updated); } [Theory, BitAutoData] @@ -2051,4 +2036,238 @@ 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(new PrivilegedControlsSeatReservationResult()); + + 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(new PrivilegedControlsSeatReservationResult()); + + 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(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 PrivilegedControlsSeatLimitReachedError().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(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 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] + [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(new PrivilegedControlsSeatReservationResult()) + .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) + .RunAsync(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(new PrivilegedControlsSeatReservationResult()) + .AndDoes(_ => organization.PamSeats = 6); + sutProvider.GetDependency() + .SendInvitesAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("SMTP is down.")); + sutProvider.GetDependency() + .RunAsync(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/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); +} 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/Organizations/Helpers/PrivilegedControlsSeatOccupancyTests.cs b/test/Core.Test/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancyTests.cs new file mode 100644 index 000000000000..b97a446c90a0 --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Helpers/PrivilegedControlsSeatOccupancyTests.cs @@ -0,0 +1,19 @@ +using Bit.Core.Billing.Organizations.Helpers; +using Bit.Core.Enums; +using Xunit; + +namespace Bit.Core.Test.Billing.Organizations.Helpers; + +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/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/Events.IntegrationTest/packages.lock.json b/test/Events.IntegrationTest/packages.lock.json index d9f8f494c362..5fcf82828465 100644 --- a/test/Events.IntegrationTest/packages.lock.json +++ b/test/Events.IntegrationTest/packages.lock.json @@ -1864,6 +1864,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1908,7 +1911,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": { @@ -1945,6 +1949,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": { @@ -1961,6 +1974,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": { @@ -2003,6 +2023,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 59301d12ede1..04476fae352b 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 a79473172d91..7d5663a5ecff 100644 --- a/test/Identity.Test/packages.lock.json +++ b/test/Identity.Test/packages.lock.json @@ -1781,6 +1781,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1808,7 +1811,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": { @@ -1834,6 +1838,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": { @@ -1842,6 +1855,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": { @@ -1871,6 +1891,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 9783e236d6f8..104fb19149a4 100644 --- a/test/IntegrationTestCommon/packages.lock.json +++ b/test/IntegrationTestCommon/packages.lock.json @@ -1852,6 +1852,9 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -1879,7 +1882,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": { @@ -1905,6 +1909,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": { @@ -1921,6 +1934,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": { @@ -1963,6 +1983,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/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs index 99202e4e6ed6..f4dcd00ae90f 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/AddPrivilegedControlsToExistingSubscriptionCommandTests.cs @@ -1,11 +1,11 @@ -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; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -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/AutoscalePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/AutoscalePrivilegedControlsSeatsCommandTests.cs index 231a848269a9..a5db41f29983 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,10 +10,9 @@ 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; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -32,11 +32,13 @@ public class AutoscalePrivilegedControlsSeatsCommandTests Substitute.For(); private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); - private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = + Substitute.For(); private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); private readonly IMailer _mailer = Substitute.For(); private readonly GlobalSettings _globalSettings = new(); + private readonly FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = new(); private readonly IBitwardenEnvironment _environment = Substitute.For(); private readonly AutoscalePrivilegedControlsSeatsCommand _sut; @@ -44,20 +46,21 @@ public AutoscalePrivilegedControlsSeatsCommandTests() => _sut = new AutoscalePrivilegedControlsSeatsCommand( _seatChangeSetFactory, _updateOrganizationSubscriptionCommand, - _organizationService, + _replaceOrganizationAndUpdateCacheCommand, _organizationUserRepository, + _isOnTrialQuery, _mailer, _globalSettings, _environment, 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()); @@ -66,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() @@ -79,174 +82,263 @@ 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("")] + public async Task RunAsync_NoGatewaySubscriptionOnTrialAndSeatsShort_SavesSeatsWithoutTouchingStripe( + string? gatewaySubscriptionId) + { + var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); + SetSeatsInUse(organization, _seatsInUse); + _isOnTrialQuery.Result = true; + + var result = await _sut.RunAsync(organization, 3); + + Assert.True(result.Success); + Assert.Equal(13, organization.PamSeats); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); + await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), 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_NoGatewaySubscription_ThrowsBadRequestWithoutReadingSeatsInUse(string? gatewaySubscriptionId) + public async Task RunAsync_NoGatewaySubscriptionAndSeatsAlreadyCovered_SucceedsWithoutChangingAnything( + string? gatewaySubscriptionId) { var organization = CreateOrganization(gatewaySubscriptionId: gatewaySubscriptionId); + SetSeatsInUse(organization, _seatsInUse); - var exception = await Assert.ThrowsAsync(() => _sut.Run(organization, 1)); + var result = await _sut.RunAsync(organization, 2); - Assert.Equal("No subscription found.", exception.Message); - await _organizationUserRepository.DidNotReceive() - .GetOccupiedPamSeatCountByOrganizationIdAsync(Arg.Any()); + Assert.True(result.Success); + Assert.Equal(_boughtSeats, organization.PamSeats); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + 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.RunAsync(organization, 2); + + Assert.True(result.Success); + Assert.Equal(7, organization.PamSeats); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RunAsync_OnTrial_PastLimit_ReturnsLimitWithoutSaving() + { + var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 6); + SetSeatsInUse(organization, 5); + _isOnTrialQuery.Result = true; + + var result = await _sut.RunAsync(organization, 2); + + Assert.False(result.Success); + Assert.Equal(6, result.MaxAutoscaleSeats); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RunAsync_OnTrial_ReachesLimitExactly_EmailsOwners() + { + var organization = CreateOrganization(pamSeats: 5, maxAutoscalePamSeats: 7); + SetSeatsInUse(organization, 5); + _isOnTrialQuery.Result = true; + + await _sut.RunAsync(organization, 2); + + await _mailer.Received(1).SendEmail(Arg.Any()); + } + + [Fact] + public async Task RunAsync_SeatsAlreadyCovered_DoesNotQueryTrial() + { + var organization = CreateOrganization(pamSeats: 5); + SetSeatsInUse(organization, 3); + + 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); await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); 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] - 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); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [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()); 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()); await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [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 => 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)); } [Fact] - public async Task Run_TotalEqualsTheLimitAndNoSeatsAreBought_StillEmailsOwners() + public async Task RunAsync_TotalEqualsTheLimitAndNoSeatsAreBought_StillEmailsOwners() { var organization = CreateOrganization(pamSeats: 12, maxAutoscalePamSeats: 12); SetSeatsInUse(organization, 11); - 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()); @@ -255,19 +347,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); @@ -275,31 +367,31 @@ 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); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); } [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); - await _organizationService.DidNotReceive() - .ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive() + .RunAsync(Arg.Any(), Arg.Any()); } [Fact] - public async Task Run_SubscriptionUpdateRejected_ThrowsBadRequestAndLeavesTheOrganizationUnsaved() + public async Task RunAsync_SubscriptionUpdateRejected_ThrowsBadRequestAndLeavesTheOrganizationUnsaved() { var organization = CreateOrganization(maxAutoscalePamSeats: 15); SetSeatsInUse(organization, _seatsInUse); @@ -307,12 +399,12 @@ 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); - await _organizationService.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/ChangePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs index 91049aac8833..03f78e69164b 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ChangePrivilegedControlsSeatsCommandTests.cs @@ -1,11 +1,11 @@ -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; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -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/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 5163790938fb..26fcf1ab4fa6 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..5d346085a631 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RemovePrivilegedControlsCommandTests.cs @@ -1,11 +1,11 @@ -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; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bitwarden.Server.Sdk.Environment; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -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/Commands/ReservePrivilegedControlsSeatsCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs new file mode 100644 index 000000000000..795e5ce48fa1 --- /dev/null +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/ReservePrivilegedControlsSeatsCommandTests.cs @@ -0,0 +1,215 @@ +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; +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 IFeatureService _featureService = Substitute.For(); + private readonly RecordingLogger _logger = new(); + private readonly ReservePrivilegedControlsSeatsCommand _sut; + + 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)] + [InlineData(-1)] + public async Task RunAsync_NoSeatsRequested_SucceedsWithoutCallingAutoscale(int seatsRequested) + { + var organization = CreateOrganization(); + + var result = await _sut.RunAsync(organization, seatsRequested); + + Assert.True(result.Success); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); + } + + [Fact] + public async Task RunAsync_PrivilegedControlsOff_SucceedsWithoutCallingAutoscale() + { + var organization = CreateOrganization(usePam: false); + + var result = await _sut.RunAsync(organization, 2); + + Assert.True(result.Success); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAsync_NullPrivilegedControlsSeats_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().RunAsync(default!, default); + await _countNewPamSeatsRequiredQuery.DidNotReceiveWithAnyArgs().CountNewPamSeatsRequiredAsync(default!, default); + } + + [Fact] + public async Task RunAsync_Cloud_ZeroPrivilegedControlsSeats_ReturnsLimitReachedWithoutCallingAutoscale() + { + var organization = CreateOrganization(pamSeats: 0); + + var result = await _sut.RunAsync(organization, 2); + + Assert.IsType(result.Error); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); + } + + [Fact] + public async Task RunAsync_SelfHosted_ZeroPrivilegedControlsSeats_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() + { + var organization = CreateOrganization(); + _environment.SelfHosted.Returns(true); + _countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, 2).Returns(1); + + var result = await _sut.RunAsync(organization, 2); + + Assert.IsType(result.Error); + await _autoscalePrivilegedControlsSeatsCommand.DidNotReceiveWithAnyArgs().RunAsync(default!, default); + } + + [Fact] + public async Task RunAsync_SelfHosted_NoShortfall_Succeeds() + { + var organization = CreateOrganization(); + _environment.SelfHosted.Returns(true); + _countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, 2).Returns(0); + + var result = await _sut.RunAsync(organization, 2); + + Assert.True(result.Success); + } + + [Fact] + public async Task RunAsync_Cloud_AutoscaleSucceeds_Succeeds() + { + var organization = CreateOrganization(); + _autoscalePrivilegedControlsSeatsCommand.RunAsync(organization, 3) + .Returns(new AutoscalePrivilegedControlsSeatsResult(true, null)); + + var result = await _sut.RunAsync(organization, 3); + + Assert.True(result.Success); + await _autoscalePrivilegedControlsSeatsCommand.Received(1).RunAsync(organization, 3); + } + + [Fact] + public async Task RunAsync_Cloud_AutoscaleAtLimit_ReturnsLimitReached() + { + var organization = CreateOrganization(); + _autoscalePrivilegedControlsSeatsCommand.RunAsync(organization, 3) + .Returns(new AutoscalePrivilegedControlsSeatsResult(false, 6)); + + var result = await _sut.RunAsync(organization, 3); + + Assert.IsType(result.Error); + } + + [Fact] + public async Task RunAsync_AutoscaleThrowsBadRequest_ReturnsExpansionFailedWithTheMessageAndLogsAWarning() + { + var organization = CreateOrganization(); + var exception = new BadRequestException("No subscription found."); + _autoscalePrivilegedControlsSeatsCommand.RunAsync(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.RunAsync(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() + { + 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 new file mode 100644 index 000000000000..7662270600b8 --- /dev/null +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/RevertPrivilegedControlsSeatsCommandTests.cs @@ -0,0 +1,106 @@ +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.Subscriptions.Organization.Commands.PrivilegedControls; +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 IReplaceOrganizationAndUpdateCacheCommand _replaceOrganizationAndUpdateCacheCommand = + Substitute.For(); + private readonly FakeIsOnPrivilegedControlsSalesInitiatedTrialQuery _isOnTrialQuery = new(); + private readonly IBitwardenEnvironment _environment = Substitute.For(); + private readonly RevertPrivilegedControlsSeatsCommand _sut; + + public RevertPrivilegedControlsSeatsCommandTests() => + _sut = new RevertPrivilegedControlsSeatsCommand( + _seatChangeSetFactory, + _updateOrganizationSubscriptionCommand, + _replaceOrganizationAndUpdateCacheCommand, + _isOnTrialQuery, + _environment); + + [Fact] + public async Task RunAsync_SeatsUnchanged_DoesNothing() + { + var organization = CreateOrganization(pamSeats: 5); + + await _sut.RunAsync(organization, 5); + + await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), Arg.Any(), Arg.Any()); + await _replaceOrganizationAndUpdateCacheCommand.DidNotReceive().RunAsync(Arg.Any()); + } + + [Fact] + public async Task RunAsync_OnTrial_ResetsSeatsOnOrganizationOnly() + { + var organization = CreateOrganization(pamSeats: 7); + _isOnTrialQuery.Result = true; + + await _sut.RunAsync(organization, 5); + + Assert.Equal(5, organization.PamSeats); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); + await _seatChangeSetFactory.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + await _updateOrganizationSubscriptionCommand.DidNotReceive() + .Run(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RunAsync_Paid_UpdatesSubscriptionThenSaves() + { + var organization = CreateOrganization(pamSeats: 7); + _isOnTrialQuery.Result = false; + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("privileged-controls-enterprise-seat-annually", 5)] + }; + _seatChangeSetFactory.CreateAsync(organization, 5).Returns(changeSet); + _updateOrganizationSubscriptionCommand + .Run(organization, changeSet, Arg.Any()) + .Returns(new Subscription()); + + await _sut.RunAsync(organization, 5); + + await _seatChangeSetFactory.Received(1).CreateAsync(organization, 5); + await _updateOrganizationSubscriptionCommand.Received(1).Run(organization, changeSet); + Assert.Equal(5, organization.PamSeats); + await _replaceOrganizationAndUpdateCacheCommand.Received(1).RunAsync(organization); + } + + [Fact] + public async Task RunAsync_SelfHosted_ThrowsBadRequest() + { + var organization = CreateOrganization(pamSeats: 7); + _environment.SelfHosted.Returns(true); + + await Assert.ThrowsAsync(() => _sut.RunAsync(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" + }; +} 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/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); } 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/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/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; 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..c13ffbe3f7f9 100644 --- a/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/OrganizationSubscriptionEndpointsRequestBindingTests.cs @@ -8,7 +8,8 @@ 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.Commands.PrivilegedControls; 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 3824de27d60a..6df6bd3e81e3 100644 --- a/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/SubscriptionsOrganizationServiceCollectionExtensionsTests.cs @@ -1,10 +1,14 @@ -using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +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; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Subscriptions.Organization.Commands; +using Bit.Core.Settings; +using Bit.Subscriptions.Organization.Commands.PrivilegedControls; using Bit.Subscriptions.Organization.Handlers; using Bit.Subscriptions.Organization.Queries; using Bitwarden.Server.Sdk.Environment; @@ -47,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(); @@ -84,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(); @@ -102,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(); @@ -111,4 +115,33 @@ public void AddOrganizationSubscriptions_RegistersRemovePrivilegedControlsHandle Assert.NotNull(scope.ServiceProvider.GetService()); } + + [Fact] + 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()); + 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()); + 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 23d97bc1aec3..44488dbba164 100644 --- a/test/Notifications.Test/packages.lock.json +++ b/test/Notifications.Test/packages.lock.json @@ -1989,6 +1989,9 @@ "xunit": "[2.6.6, )" } }, + "currentuser": { + "type": "Project" + }, "data": { "type": "Project" }, @@ -2022,7 +2025,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": { @@ -2059,6 +2063,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": { @@ -2102,6 +2115,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": { @@ -2144,6 +2164,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 befd7a5c0856..d5ce386c1503 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, )" + } } } }