diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 0733e4d9fcf4..af552d617a11 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -358,9 +358,13 @@ public async Task Edit(Guid id) Trial = await GetTrialAsync(organization), }; - if (_featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support) && provider == null) + if (provider == null) { - model.PrivilegedControlsStripeSeats = await GetPrivilegedControlsStripeSeatsAsync(organization); + if (_featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support)) + { + model.PrivilegedControlsStripeSeats = await GetPrivilegedControlsStripeSeatsAsync(organization); + } + model.OccupiedPamSeatsCount = await _organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id); } @@ -452,7 +456,8 @@ public async Task Edit(Guid id, OrganizationEditModel model) PlanType = organization.PlanType, Seats = organization.Seats, UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation, - Enabled = organization.Enabled + Enabled = organization.Enabled, + UsePam = organization.UsePam }; if (model.PlanType.HasValue) @@ -480,7 +485,8 @@ public async Task Edit(Guid id, OrganizationEditModel model) } } - UpdateOrganization(organization, model); + var isProviderManaged = await _providerRepository.GetByOrganizationIdAsync(organization.Id) != null; + UpdateOrganization(organization, model, isProviderManaged); var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); if (organization.UseSecretsManager && !plan.SupportsSecretsManager) { @@ -488,9 +494,14 @@ public async Task Edit(Guid id, OrganizationEditModel model) return RedirectToAction("Edit", new { id }); } - if (_featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support) - && _accessControlService.UserHasPermission(Permission.Org_Plan_Edit) - && await ValidatePrivilegedControlsAsync(organization, plan) is { } privilegedControlsError) + if (_accessControlService.UserHasPermission(Permission.Org_Plan_Edit) + && !isProviderManaged + && ValidatePrivilegedControls( + organization, + plan, + organization.UsePam + ? await _organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + : 0) is { } privilegedControlsError) { TempData["Error"] = privilegedControlsError; return RedirectToAction("Edit", new { id }); @@ -514,6 +525,12 @@ await HandlePotentialProviderSeatScalingAsync( existingOrganizationData, model); + if (isProviderManaged && existingOrganizationData.UsePam) + { + TempData["Warning"] = + "Privileged Access Management was turned off because organizations managed by a provider can't use it."; + } + await _organizationRepository.ReplaceAsync(organization); if (cohortResolution.ShouldWrite) @@ -856,18 +873,16 @@ private async Task ResolveMigrationCohortAssign return new MigrationCohortAssignmentChange(assignmentToReplace, cohortToAssign, true, null); } - private async Task ValidatePrivilegedControlsAsync(Organization organization, Bit.Core.Models.StaticStore.Plan plan) + private static string ValidatePrivilegedControls( + Organization organization, + Bit.Core.Models.StaticStore.Plan plan, + int occupiedPamSeats) { if (!organization.UsePam) { return null; } - if (await _providerRepository.GetByOrganizationIdAsync(organization.Id) != null) - { - return "Organizations managed by a provider can't use Privileged Access Management"; - } - if (!plan.SupportsPrivilegedControls) { return "Plan does not support Privileged Access Management"; @@ -878,9 +893,9 @@ private async Task ValidatePrivilegedControlsAsync(Organization organiza return "Privileged Access Management seats must be at least 1"; } - if (organization.MaxAutoscalePamSeats < organization.PamSeats) + if (organization.PamSeats < occupiedPamSeats) { - return "Privileged Access Management max autoscale seats can't be lower than seats"; + return $"Privileged Access Management seats can't be lower than the {occupiedPamSeats} members who have access"; } if (organization.PamSeats > organization.Seats) @@ -889,16 +904,35 @@ private async Task ValidatePrivilegedControlsAsync(Organization organiza $"This organization has {organization.PamSeats} Privileged Controls seats and {organization.Seats} Password Manager seats."; } - if (!BillingExtensions.PrivilegedControlsAutoscaleLimitFits(organization.MaxAutoscalePamSeats, organization.MaxAutoscaleSeats)) + return null; + } + + private void UpdatePrivilegedControls(Organization organization, OrganizationEditModel model, bool isProviderManaged) + { + // Organizations managed by a provider can't use Privileged Access Management, so a save turns it off. + if (isProviderManaged) { - return "You cannot set a Privileged Controls autoscale limit higher than the Password Manager autoscale limit. " + - $"This organization has a Privileged Controls limit of {organization.MaxAutoscalePamSeats} and a Password Manager limit of {organization.MaxAutoscaleSeats}."; + organization.UsePam = false; + organization.PamSeats = null; + organization.MaxAutoscalePamSeats = null; + return; } - return null; + organization.UsePam = model.UsePam; + + // Autoscaling is self-serve, so the Admin Portal never stores a limit. + organization.MaxAutoscalePamSeats = null; + + // Turning it off keeps the seat count, so it's still there if it's turned back on. + if (!model.UsePam) + { + return; + } + + organization.PamSeats = model.PamSeats; } - private void UpdateOrganization(Organization organization, OrganizationEditModel model) + private void UpdateOrganization(Organization organization, OrganizationEditModel model, bool isProviderManaged) { if (_accessControlService.UserHasPermission(Permission.Org_Name_Edit)) { @@ -943,7 +977,6 @@ private void UpdateOrganization(Organization organization, OrganizationEditModel organization.UsePhishingBlocker = model.UsePhishingBlocker; organization.UseMyItems = model.UseMyItems; organization.UseInviteLinks = model.UseInviteLinks; - organization.UsePam = model.UsePam; //secrets organization.SmSeats = model.SmSeats; @@ -951,11 +984,7 @@ private void UpdateOrganization(Organization organization, OrganizationEditModel organization.SmServiceAccounts = model.SmServiceAccounts; organization.MaxAutoscaleSmServiceAccounts = model.MaxAutoscaleSmServiceAccounts; - if (_featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support)) - { - organization.PamSeats = model.UsePam ? model.PamSeats : null; - organization.MaxAutoscalePamSeats = model.UsePam ? model.MaxAutoscalePamSeats : null; - } + UpdatePrivilegedControls(organization, model, isProviderManaged); } if (_accessControlService.UserHasPermission(Permission.Org_Licensing_Edit)) diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index 92d25b4c152b..a80f86b58b69 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -23,9 +23,8 @@ var canManagePlanMigrationCohorts = AccessControlService.UserHasPermission(Permission.Tools_ManagePlanMigrationCohorts); var businessPlanPriceMigrationEnabled = FeatureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration); var canViewMigrationCohortAssignment = businessPlanPriceMigrationEnabled && canManagePlanMigrationCohorts; - var pamBillingSupportEnabled = FeatureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support); - var showPamCheckbox = !pamBillingSupportEnabled || Model.Provider == null; - var showPamConfiguration = pamBillingSupportEnabled && Model.Provider == null; + // Provider-managed organizations don't get the PAM section; the controller turns PAM off for them on save. + var showPam = Model.Provider == null; }
@@ -261,7 +260,7 @@ - @if (showPamCheckbox) + @if (showPam) {

Privileged Access Management

@@ -269,7 +268,7 @@
- @if (showPamConfiguration && !Model.UsePam && Model.PrivilegedControlsStripeSeats.HasValue) + @if (!Model.UsePam && Model.PrivilegedControlsStripeSeats.HasValue) {
Privileged Access Management doesn't match Stripe. Privileged Access Management is off, but the Stripe subscription has a Privileged Access Management line for @Model.PrivilegedControlsStripeSeats seats.
} @@ -348,7 +347,7 @@ } - @if (canViewPlan && showPamConfiguration) + @if (canViewPlan && showPam) { -
-
- - -
-
} diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml index 0e43ece910f5..b44ddc9fc904 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml @@ -59,12 +59,10 @@ pamConfiguration.hidden = !event.target.checked; + // Unticking keeps the values: the server leaves the seat count and limit alone while PAM is off. if (event.target.checked) { setInitialPamConfiguration(); - return; } - - clearPamConfiguration(); }); })(); @@ -158,13 +156,11 @@ } function setInitialPamConfiguration() { - document.getElementById('@(nameof(Model.PamSeats))').value = Math.max(@Model.OccupiedPamSeatsCount, 1); - document.getElementById('@(nameof(Model.MaxAutoscalePamSeats))').value = ''; - } - - function clearPamConfiguration() { - document.getElementById('@(nameof(Model.PamSeats))').value = ''; - document.getElementById('@(nameof(Model.MaxAutoscalePamSeats))').value = ''; + // Keep a seat count saved from when PAM was last on; otherwise default to the members who have access. + const pamSeats = document.getElementById('@(nameof(Model.PamSeats))'); + if (pamSeats.value === '') { + pamSeats.value = Math.max(@Model.OccupiedPamSeatsCount, 1); + } } function getPlan(planType) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs index 7fe5ae196169..402a895a2bf5 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommand.cs @@ -33,9 +33,11 @@ public async Task BulkEnablePamAsync(Organization organization, I var additionalPamSeatsRequired = await countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, organizationUsers.Count); - if (additionalPamSeatsRequired > 0 && globalSettings.SelfHosted) + if (additionalPamSeatsRequired > 0) { - return new CannotAutoscalePamSeatsOnSelfHost(); + return globalSettings.SelfHosted + ? new CannotAutoscalePamSeatsOnSelfHost() + : new NotEnoughPrivilegedControlsSeats(); } 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..e3724ef413a9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -3,13 +3,16 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; 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.Queries; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -17,6 +20,7 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; @@ -33,7 +37,9 @@ public class RestoreOrganizationUserCommand( ICollectionRepository collectionRepository, IAutomaticUserConfirmationPolicyEnforcementHandler automaticUserConfirmationPolicyEnforcementHandler, IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand, - TimeProvider timeProvider) : IRestoreOrganizationUserCommand + TimeProvider timeProvider, + ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery, + IGlobalSettings globalSettings) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName) { @@ -83,6 +89,13 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, } var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId); + + if (organizationUser.AccessPam && + await GetPrivilegedControlsSeatLimitErrorAsync(organization, 1) is { } privilegedControlsSeatLimitError) + { + throw new BadRequestException(privilegedControlsSeatLimitError.Message); + } + var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; @@ -130,6 +143,31 @@ await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.Organi } } + /// + /// The error to fail with when restoring members who have Privileged Controls + /// access would go over the organization's Privileged Controls seats, otherwise null. + /// + private async Task GetPrivilegedControlsSeatLimitErrorAsync( + Organization organization, + int membersWithAccess) + { + if (!organization.UsePam || membersWithAccess == 0) + { + return null; + } + + var additionalPamSeatsRequired = + await countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, membersWithAccess); + if (additionalPamSeatsRequired == 0) + { + return null; + } + + return globalSettings.SelfHosted + ? new CannotAutoscalePamSeatsOnSelfHost() + : new NotEnoughPrivilegedControlsSeats(); + } + private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser) { var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId!.Value); @@ -197,6 +235,12 @@ public async Task>> RestoreUsersAsync(Guid var newSeatsRequired = organizationUserIds.Count() - availableSeats; await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); + // Privileged Controls seats are a hard limit, so if the batch would go over them, none of its members with + // access are restored. + var privilegedControlsSeatLimitError = await GetPrivilegedControlsSeatLimitErrorAsync( + organization, + filteredUsers.Count(ou => ou.Status == OrganizationUserStatusType.Revoked && ou.AccessPam)); + var restoringUserIsOwner = false; var restoringUserIsAdminOrHigher = false; if (restoringUserId.HasValue) @@ -245,6 +289,11 @@ public async Task>> RestoreUsersAsync(Guid .FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value) .twoFactorIsEnabled; + if (organizationUser.AccessPam && privilegedControlsSeatLimitError is not null) + { + throw new BadRequestException(privilegedControlsSeatLimitError.Message); + } + await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled); if (organization.PlanType == PlanType.Free) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs index 150b0c8580cc..9c089ee03d0c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/Errors.cs @@ -12,6 +12,7 @@ public record CustomPermissionsNotEnabled() : BadRequestError("To enable custom 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 NotEnoughPrivilegedControlsSeats() : BadRequestError("Your organization has no Privileged Controls seats available. Contact sales for more seats."); 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..d0b63979b2bf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -206,10 +206,11 @@ await mailer.SendEmail(new MemberEmailChangedNotificationMail { 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 (additionalPamSeatsRequired > 0) { - return new CannotAutoscalePamSeatsOnSelfHost(); + return globalSettings.SelfHosted + ? new CannotAutoscalePamSeatsOnSelfHost() + : new NotEnoughPrivilegedControlsSeats(); } return null; diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 129b7e502cf3..838b10f2c110 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1625,18 +1625,18 @@ private static void StubPrivilegedControlsEdit( Organization organization, bool supportsPrivilegedControls = true, bool canEditPlan = true, - bool flagEnabled = true, - Provider provider = null) + Provider provider = null, + bool pamBillingSupport = false) { organization.PlanType = PlanType.EnterpriseAnnually; + sutProvider.GetDependency() + .IsEnabled(Bit.Core.FeatureFlagKeys.PM39883_Pam_Billing_Support) + .Returns(pamBillingSupport); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().GetPlanOrThrow(PlanType.EnterpriseAnnually) .Returns(new EnterprisePlanStub(supportsPrivilegedControls)); sutProvider.GetDependency().UserHasPermission(Permission.Org_Plan_Edit) .Returns(canEditPlan); - sutProvider.GetDependency() - .IsEnabled(Bit.Core.FeatureFlagKeys.PM39883_Pam_Billing_Support) - .Returns(flagEnabled); sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(provider); sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); } @@ -1652,14 +1652,16 @@ private static async Task AssertPrivilegedControlsRejectedAsync( await sutProvider.GetDependency().DidNotReceive().ReplaceAsync(Arg.Any()); } - [BitAutoData] + [BitAutoData(true)] + [BitAutoData(false)] [SutProviderCustomize] [Theory] - public async Task Edit_PrivilegedControls_TurnedOn_SavesSeatsAndLimit( + public async Task Edit_PrivilegedControls_TurnedOn_SavesSeatsWithoutLimit( + bool pamBillingSupport, Organization organization, SutProvider sutProvider) { - StubPrivilegedControlsEdit(sutProvider, organization); + StubPrivilegedControlsEdit(sutProvider, organization, pamBillingSupport: pamBillingSupport); var update = new OrganizationEditModel { PlanType = PlanType.EnterpriseAnnually, @@ -1671,32 +1673,37 @@ public async Task Edit_PrivilegedControls_TurnedOn_SavesSeatsAndLimit( await sutProvider.Sut.Edit(organization.Id, update); await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => o.UsePam && o.PamSeats == 25 && o.MaxAutoscalePamSeats == 40)); + Arg.Is(o => o.UsePam && o.PamSeats == 25 && o.MaxAutoscalePamSeats == null)); } [BitAutoData] [SutProviderCustomize] [Theory] - public async Task Edit_PrivilegedControls_TurnedOff_ClearsSeatsAndLimit( + public async Task Edit_PrivilegedControls_TurnedOff_KeepsSeatsAndClearsLimit( Organization organization, SutProvider sutProvider) { StubPrivilegedControlsEdit(sutProvider, organization); organization.UsePam = true; - organization.PamSeats = 25; - organization.MaxAutoscalePamSeats = 40; + organization.PamSeats = 5; + organization.MaxAutoscalePamSeats = 8; var update = new OrganizationEditModel { PlanType = PlanType.EnterpriseAnnually, UsePam = false, - PamSeats = 25, - MaxAutoscalePamSeats = 40 + PamSeats = null, + MaxAutoscalePamSeats = null }; await sutProvider.Sut.Edit(organization.Id, update); await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => !o.UsePam && o.PamSeats == null && o.MaxAutoscalePamSeats == null)); + Arg.Is(o => !o.UsePam && o.PamSeats == 5 && o.MaxAutoscalePamSeats == null)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetOccupiedPamSeatCountByOrganizationIdAsync(default); + // Members keep their access; it does nothing while the organization has it off. + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ReplaceManyAsync(default); } [BitAutoData] @@ -1727,6 +1734,8 @@ public async Task Edit_PrivilegedControls_SeatsMissing_Rejects( SutProvider sutProvider) { StubPrivilegedControlsEdit(sutProvider, organization); + organization.UsePam = false; + organization.PamSeats = null; var update = new OrganizationEditModel { PlanType = PlanType.EnterpriseAnnually, @@ -1737,6 +1746,7 @@ public async Task Edit_PrivilegedControls_SeatsMissing_Rejects( var result = await sutProvider.Sut.Edit(organization.Id, update); await AssertPrivilegedControlsRejectedAsync(result, organization, sutProvider); + Assert.Equal("Privileged Access Management seats must be at least 1", sutProvider.Sut.TempData["Error"]); } [BitAutoData] @@ -1762,44 +1772,54 @@ public async Task Edit_PrivilegedControls_SeatsBelowOne_Rejects( [BitAutoData] [SutProviderCustomize] [Theory] - public async Task Edit_PrivilegedControls_AutoscaleBelowSeats_Rejects( + public async Task Edit_PrivilegedControls_SeatsBelowOccupied_Rejects( Organization organization, SutProvider sutProvider) { StubPrivilegedControlsEdit(sutProvider, organization); + sutProvider.GetDependency() + .GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + .Returns(6); var update = new OrganizationEditModel { PlanType = PlanType.EnterpriseAnnually, + Seats = 10, UsePam = true, - PamSeats = 25, - MaxAutoscalePamSeats = 24 + PamSeats = 5 }; var result = await sutProvider.Sut.Edit(organization.Id, update); await AssertPrivilegedControlsRejectedAsync(result, organization, sutProvider); + Assert.Equal( + "Privileged Access Management seats can't be lower than the 6 members who have access", + sutProvider.Sut.TempData["Error"]); } [BitAutoData] [SutProviderCustomize] [Theory] - public async Task Edit_PrivilegedControls_AutoscaleEqualsSeats_Saves( + public async Task Edit_PrivilegedControls_SeatsEqualOccupied_Saves( Organization organization, SutProvider sutProvider) { StubPrivilegedControlsEdit(sutProvider, organization); + sutProvider.GetDependency() + .GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + .Returns(6); var update = new OrganizationEditModel { PlanType = PlanType.EnterpriseAnnually, + Seats = 10, UsePam = true, - PamSeats = 25, - MaxAutoscalePamSeats = 25 + PamSeats = 6 }; await sutProvider.Sut.Edit(organization.Id, update); + Assert.Null(sutProvider.Sut.TempData["Error"]); await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => o.MaxAutoscalePamSeats == 25)); + Arg.Is(o => o.UsePam && o.PamSeats == 6)); } [BitAutoData] @@ -1904,7 +1924,7 @@ await sutProvider.GetDependency().Received(1).ReplaceAs [BitAutoData] [SutProviderCustomize] [Theory] - public async Task Edit_PrivilegedControls_PamLimitAbovePmLimit_Rejects( + public async Task Edit_PrivilegedControls_NoPamLimitWithPmLimit_Saves( Organization organization, SutProvider sutProvider) { @@ -1916,75 +1936,56 @@ public async Task Edit_PrivilegedControls_PamLimitAbovePmLimit_Rejects( MaxAutoscaleSeats = 20, UsePam = true, PamSeats = 10, - MaxAutoscalePamSeats = 21 - }; - - var result = await sutProvider.Sut.Edit(organization.Id, update); - - await AssertPrivilegedControlsRejectedAsync(result, organization, sutProvider); - Assert.Equal( - "You cannot set a Privileged Controls autoscale limit higher than the Password Manager autoscale limit. " + - "This organization has a Privileged Controls limit of 21 and a Password Manager limit of 20.", - sutProvider.Sut.TempData["Error"]); - } - - [BitAutoData] - [SutProviderCustomize] - [Theory] - public async Task Edit_PrivilegedControls_PamLimitWithNoPmLimit_Saves( - Organization organization, - SutProvider sutProvider) - { - StubPrivilegedControlsEdit(sutProvider, organization); - var update = new OrganizationEditModel - { - PlanType = PlanType.EnterpriseAnnually, - Seats = 10, - MaxAutoscaleSeats = null, - UsePam = true, - PamSeats = 10, - MaxAutoscalePamSeats = 21 + MaxAutoscalePamSeats = null }; await sutProvider.Sut.Edit(organization.Id, update); await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => o.MaxAutoscalePamSeats == 21)); + Arg.Is(o => o.MaxAutoscaleSeats == 20 && o.MaxAutoscalePamSeats == null)); } [BitAutoData] [SutProviderCustomize] [Theory] - public async Task Edit_PrivilegedControls_NoPamLimitWithPmLimit_Saves( + public async Task Edit_PrivilegedControls_ProviderManaged_TurnsPamOffWithWarning( Organization organization, + Provider provider, SutProvider sutProvider) { - StubPrivilegedControlsEdit(sutProvider, organization); + StubPrivilegedControlsEdit(sutProvider, organization, provider: provider); + organization.UsePam = true; + organization.PamSeats = 5; + organization.MaxAutoscalePamSeats = 8; var update = new OrganizationEditModel { PlanType = PlanType.EnterpriseAnnually, Seats = 10, - MaxAutoscaleSeats = 20, - UsePam = true, - PamSeats = 10, - MaxAutoscalePamSeats = null + UsePam = false }; await sutProvider.Sut.Edit(organization.Id, update); + Assert.Null(sutProvider.Sut.TempData["Error"]); + Assert.Equal( + "Privileged Access Management was turned off because organizations managed by a provider can't use it.", + sutProvider.Sut.TempData["Warning"]); await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => o.MaxAutoscaleSeats == 20 && o.MaxAutoscalePamSeats == null)); + Arg.Is(o => !o.UsePam && o.PamSeats == null && o.MaxAutoscalePamSeats == null)); } [BitAutoData] [SutProviderCustomize] [Theory] - public async Task Edit_PrivilegedControls_ProviderManaged_Rejects( + public async Task Edit_PrivilegedControls_ProviderManaged_PostedPamIgnored( Organization organization, Provider provider, SutProvider sutProvider) { StubPrivilegedControlsEdit(sutProvider, organization, provider: provider); + organization.UsePam = false; + organization.PamSeats = null; + organization.MaxAutoscalePamSeats = null; var update = new OrganizationEditModel { PlanType = PlanType.EnterpriseAnnually, @@ -1992,33 +1993,11 @@ public async Task Edit_PrivilegedControls_ProviderManaged_Rejects( PamSeats = 25 }; - var result = await sutProvider.Sut.Edit(organization.Id, update); - - await AssertPrivilegedControlsRejectedAsync(result, organization, sutProvider); - } - - [BitAutoData] - [SutProviderCustomize] - [Theory] - public async Task Edit_PrivilegedControls_FlagOff_LeavesSeatsUnchanged( - Organization organization, - SutProvider sutProvider) - { - StubPrivilegedControlsEdit(sutProvider, organization, flagEnabled: false); - organization.PamSeats = 10; - organization.MaxAutoscalePamSeats = 20; - var update = new OrganizationEditModel - { - PlanType = PlanType.EnterpriseAnnually, - UsePam = true, - PamSeats = null, - MaxAutoscalePamSeats = null - }; - await sutProvider.Sut.Edit(organization.Id, update); + Assert.Null(sutProvider.Sut.TempData["Warning"]); await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => o.PamSeats == 10 && o.MaxAutoscalePamSeats == 20)); + Arg.Is(o => !o.UsePam && o.PamSeats == null && o.MaxAutoscalePamSeats == null)); } [BitAutoData] @@ -2818,6 +2797,7 @@ public async Task Edit_Get_PrivilegedControls_StripeFails_StillLoads( var model = await GetEditModelAsync(sutProvider, organization); + Assert.Equal(organization.Id, model.Organization.Id); Assert.Null(model.PrivilegedControlsStripeSeats); } @@ -2829,14 +2809,16 @@ public async Task Edit_Get_PrivilegedControls_FlagOff_DoesNotCallStripe( SutProvider sutProvider) { StubPrivilegedControlsGet(sutProvider, organization, flagEnabled: false); + sutProvider.GetDependency() + .GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) + .Returns(7); var model = await GetEditModelAsync(sutProvider, organization); Assert.Null(model.PrivilegedControlsStripeSeats); + Assert.Equal(7, model.OccupiedPamSeatsCount); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .GetSubscriptionAsync(default, default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .GetOccupiedPamSeatCountByOrganizationIdAsync(default); } [BitAutoData] diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs index 305c95b1929c..97f8223dc9a4 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerBulkEnablePamTests.cs @@ -194,7 +194,7 @@ public async Task BulkEnablePam_WhenSelfHostedAndSeatsInsufficient_DoesNotGrant( } [Fact] - public async Task BulkEnablePam_WhenCloudAndSeatsInsufficient_Grants() + public async Task BulkEnablePam_WhenCloudAndSeatsInsufficient_DoesNotGrant() { await SetPamSeatsAsync(1); await SetUsePamAsync(true); @@ -209,8 +209,10 @@ public async Task BulkEnablePam_WhenCloudAndSeatsInsufficient_Grants() var response = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/enable-pam", new OrganizationUserBulkRequestModel { Ids = [member.Id] }); - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - await AssertAccessPamAsync(member, true); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Your organization has no Privileged Controls seats available. Contact sales for more seats.", + await response.Content.ReadAsStringAsync()); + await AssertAccessPamAsync(member, false); } [Theory] diff --git a/test/Core.Test/AdminConsole/Models/Data/SelfHostedOrganizationDetailsTests.cs b/test/Core.Test/AdminConsole/Models/Data/SelfHostedOrganizationDetailsTests.cs index 0a2d79958903..bb65db76f3b5 100644 --- a/test/Core.Test/AdminConsole/Models/Data/SelfHostedOrganizationDetailsTests.cs +++ b/test/Core.Test/AdminConsole/Models/Data/SelfHostedOrganizationDetailsTests.cs @@ -327,6 +327,29 @@ public void ValidateForOrganization_DisabledResetPassword_NotAllowedByLicense_Su Assert.True(string.IsNullOrEmpty(exception)); } + [Theory] + [BitAutoData] + [OrganizationLicenseCustomize] + public void ValidateForOrganization_PamSeatsBelowMembersWithAccess_Success(List orgUsers, + List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) + { + // Existing grants stay when a license lowers Privileged Controls seats; new grants are blocked instead. + var (orgDetails, orgLicense) = GetOrganizationAndLicense(orgUsers, policies, ssoConfig, scimConnections, license); + orgUsers.ForEach(ou => + { + ou.Status = OrganizationUserStatusType.Confirmed; + ou.AccessPam = true; + }); + orgDetails.UsePam = true; + orgLicense.UsePam = true; + orgLicense.PamSeats = orgUsers.Count - 1; + + var result = orgDetails.CanUseLicense(license, false, out var exception); + + Assert.True(result); + Assert.True(string.IsNullOrEmpty(exception)); + } + private (SelfHostedOrganizationDetails organization, OrganizationLicense license) GetOrganizationAndLicense(List orgUsers, List policies, SsoConfig ssoConfig, List> scimConnections, OrganizationLicense license) { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs index eafede1a7f8e..f96786ffdc1a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/BulkEnablePam/BulkEnablePamCommandTests.cs @@ -147,7 +147,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task BulkEnablePamAsync_WhenCloudAndSeatsRequired_Grants(Organization organization, + public async Task BulkEnablePamAsync_WhenCloudAndSeatsRequired_ReturnsNotEnoughSeats(Organization organization, List organizationUserIds, List organizationUsers, SutProvider sutProvider) { @@ -167,10 +167,14 @@ public async Task BulkEnablePamAsync_WhenCloudAndSeatsRequired_Grants(Organizati var result = await sutProvider.Sut.BulkEnablePamAsync(organization, organizationUserIds); - Assert.True(result.IsSuccess); + Assert.True(result.IsError); + var error = Assert.IsType(result.AsError); + Assert.Equal( + "Your organization has no Privileged Controls seats available. Contact sales for more seats.", + error.Message); await sutProvider.GetDependency() - .Received(1) - .ReplaceManyAsync(Arg.Is>(users => users.All(u => u.AccessPam))); + .DidNotReceiveWithAnyArgs() + .ReplaceManyAsync(default!); } [Theory] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 2994b4fbf6c8..0f5474e8940e 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -19,6 +20,7 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Test.Common.AutoFixture; @@ -1232,6 +1234,157 @@ await organizationUserRepository .RestoreAsync(orgUser1.Id, Arg.Is(x => x != OrganizationUserStatusType.Revoked)); } + private const string _notEnoughPrivilegedControlsSeatsMessage = + "Your organization has no Privileged Controls seats available. Contact sales for more seats."; + private const string _cannotAutoscalePamSeatsOnSelfHostMessage = + "Cannot autoscale Privileged Controls seats on a self-hosted instance."; + + [Theory] + [BitAutoData(false, _notEnoughPrivilegedControlsSeatsMessage)] + [BitAutoData(true, _cannotAutoscalePamSeatsOnSelfHostMessage)] + public async Task RestoreUser_WithPamAccess_NoPamSeatsAvailable_Fails( + bool selfHosted, + string expectedMessage, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + organization.UsePam = true; + organizationUser.AccessPam = true; + sutProvider.GetDependency().SelfHosted = selfHosted; + sutProvider.GetDependency() + .CountNewPamSeatsRequiredAsync(organization, 1) + .Returns(1); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Equal(expectedMessage, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AutoAddSeatsAsync(default, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(default, default); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithPamAccess_PamSeatAvailable_Restores( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + organization.UsePam = true; + organizationUser.AccessPam = true; + sutProvider.GetDependency() + .CountNewPamSeatsRequiredAsync(organization, 1) + .Returns(0); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, Arg.Any()); + } + + [Theory] + [BitAutoData(true, false)] + [BitAutoData(false, true)] + public async Task RestoreUser_WithoutPamAccessOrPam_DoesNotCheckPamSeats( + bool usePam, + bool accessPam, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + organization.UsePam = usePam; + organizationUser.AccessPam = accessPam; + sutProvider.GetDependency() + .CountNewPamSeatsRequiredAsync(Arg.Any(), Arg.Any()) + .Returns(1); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, Arg.Any()); + } + + [Theory] + [BitAutoData(false, _notEnoughPrivilegedControlsSeatsMessage)] + [BitAutoData(true, _cannotAutoscalePamSeatsOnSelfHostMessage)] + public async Task RestoreUsers_WithPamAccess_NoPamSeatsAvailable_ReturnsErrorForMembersWithAccess( + bool selfHosted, + string expectedMessage, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser memberWithAccess, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser memberWithoutAccess, + SutProvider sutProvider) + { + RestoreUser_Setup(organization, owner, memberWithAccess, sutProvider); + memberWithoutAccess.OrganizationId = organization.Id; + organization.UsePam = true; + memberWithAccess.AccessPam = true; + memberWithoutAccess.AccessPam = false; + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns([memberWithAccess, memberWithoutAccess]); + sutProvider.GetDependency().SelfHosted = selfHosted; + sutProvider.GetDependency() + .CountNewPamSeatsRequiredAsync(organization, 1) + .Returns(1); + + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, + [memberWithAccess.Id, memberWithoutAccess.Id], owner.Id, Substitute.For(), null); + + Assert.Equal(expectedMessage, result.Single(r => r.Item1.Id == memberWithAccess.Id).Item2); + Assert.Empty(result.Single(r => r.Item1.Id == memberWithoutAccess.Id).Item2); + await sutProvider.GetDependency() + .DidNotReceive() + .RestoreAsync(memberWithAccess.Id, Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(memberWithoutAccess.Id, Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_WithPamAccess_PamSeatsAvailable_RestoresAll( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + SutProvider sutProvider) + { + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + orgUser2.OrganizationId = organization.Id; + organization.UsePam = true; + orgUser1.AccessPam = orgUser2.AccessPam = true; + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns([orgUser1, orgUser2]); + sutProvider.GetDependency() + .CountNewPamSeatsRequiredAsync(organization, 2) + .Returns(0); + + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, + [orgUser1.Id, orgUser2.Id], owner.Id, Substitute.For(), null); + + Assert.All(result, r => Assert.Empty(r.Item2)); + await sutProvider.GetDependency() + .Received(1) + .CountNewPamSeatsRequiredAsync(organization, 2); + await sutProvider.GetDependency() + .Received(2) + .RestoreAsync(Arg.Any(), Arg.Any()); + } + private static void RestoreUser_Setup( Organization organization, OrganizationUser? requestingOrganizationUser, 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..9648933090b7 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -130,7 +130,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task UpdateUserAsync_WhenGrantingPamOnCloudWithoutSeats_PersistsAccessPam( + public async Task UpdateUserAsync_WhenGrantingPamOnCloudWithoutSeats_ReturnsNotEnoughSeatsAndDoesNotPersist( SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) @@ -144,6 +144,32 @@ public async Task UpdateUserAsync_WhenGrantingPamOnCloudWithoutSeats_PersistsAcc var result = await sutProvider.Sut.UpdateUserAsync(request); + Assert.True(result.IsError); + var error = Assert.IsType(result.AsError); + Assert.Equal( + "Your organization has no Privileged Controls seats available. Contact sales for more seats.", + error.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(Arg.Any(), Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenGrantingPamOnCloudWithSeatsAvailable_PersistsAccessPam( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.AccessPam = false; + var request = Setup(sutProvider, organization, organizationUser, targetAccessPam: true); + sutProvider.GetDependency().SelfHosted = false; + sutProvider.GetDependency() + .CountNewPamSeatsRequiredAsync(organization, 1) + .Returns(0); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + Assert.True(result.IsSuccess); await sutProvider.GetDependency() .Received(1)