From d1bc4367e00fb16a9355810e319b3e5226c1b8b2 Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Thu, 5 Nov 2026 17:09:27 +0100 Subject: [PATCH 01/19] [PM-46145] feat: Provision Privileged Controls on sales-assisted sign-up without the flag --- .../CloudOrganizationSignUpCommand.cs | 18 ++++++-- .../Constants/TrialInitiationConstants.cs | 6 +++ .../Services/OrganizationBillingService.cs | 6 +-- .../CreateOrganizationSubscriptionCommand.cs | 6 +-- .../Subscriptions.Organization/README.md | 1 + .../CloudOrganizationSignUpCommandTests.cs | 46 ++++++++++++++++++- 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 9ca5b730c908..552d40a051a3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; @@ -72,7 +73,9 @@ public async Task SignUpOrganizationAsync(Organizati ValidateSecretsManagerPlan(plan, signup); } - if (signup.UsePrivilegedControls && featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support)) + // A request for Privileged Controls that isn't allowed is ignored, so the org is created without it. + var usePrivilegedControls = signup.UsePrivilegedControls && CanSignUpWithPrivilegedControls(signup); + if (usePrivilegedControls) { ValidatePrivilegedControlsSeats(plan, signup); } @@ -106,8 +109,6 @@ public async Task SignUpOrganizationAsync(Organizati // PAM purchase provision through the new subscription command (flagged); // every other paid signup stays on the legacy Finalize until that path is migrated. - var usePrivilegedControls = signup.UsePrivilegedControls && - featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support); if (usePrivilegedControls) { var purchase = BuildPrivilegedControlsPurchase(sale, signup); @@ -253,6 +254,17 @@ public void ValidateSecretsManagerPlan(Plan plan, OrganizationUpgrade upgrade) } } + /// + /// A sign-up from a sales invitation can always include Privileged Controls, because sales sells it at GA. + /// Any other sign-up is self-serve and needs on. + /// + /// + /// The initiation path is supplied by the client and is not verified against the sales invitation. + /// + private bool CanSignUpWithPrivilegedControls(OrganizationSignup signup) => + signup.InitiationPath == TrialInitiationConstants.SalesAssistedTrialInitiationPath || + featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support); + private OrganizationSubscriptionCreation BuildPrivilegedControlsPurchase( OrganizationSale sale, OrganizationSignup signup) { diff --git a/src/Core/Billing/Constants/TrialInitiationConstants.cs b/src/Core/Billing/Constants/TrialInitiationConstants.cs index fbe62e1f7034..45504b1aab72 100644 --- a/src/Core/Billing/Constants/TrialInitiationConstants.cs +++ b/src/Core/Billing/Constants/TrialInitiationConstants.cs @@ -3,4 +3,10 @@ public static class TrialInitiationConstants { public const int DefaultTrialLengthDays = 7; + + /// + /// The sign-up initiation path the client sends for a trial started from a sales invitation. + /// Must match InitiationPath.SalesAssistedTrialFromAdminPortal in the clients repo. + /// + public const string SalesAssistedTrialInitiationPath = "Sales assisted trial from admin portal"; } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index bece0aaacbd3..4f6cb355ef87 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -20,6 +20,7 @@ using Customer = Stripe.Customer; using StripeConstants = Bit.Core.Billing.Constants.StripeConstants; using Subscription = Stripe.Subscription; +using TrialInitiationConstants = Bit.Core.Billing.Constants.TrialInitiationConstants; namespace Bit.Core.Billing.Organizations.Services; @@ -37,9 +38,6 @@ public class OrganizationBillingService( ISubscriptionDiscountService subscriptionDiscountService, ITaxService taxService) : IOrganizationBillingService { - // Must match `InitiationPath.SalesAssistedTrialFromAdminPortal` in the clients repo. - private const string _salesAssistedTrialInitiationPath = "Sales assisted trial from admin portal"; - // Matched as a substring of the client's `InitiationPath` marketing-trial values. private const string _marketingTrialInitiationPathSegment = "trial from marketing website"; @@ -466,7 +464,7 @@ private async Task CreateSubscriptionAsync( { var path when !string.IsNullOrEmpty(path) && path.Contains(_marketingTrialInitiationPathSegment) => StripeConstants.TrialInitiationPaths.MarketingInitiated, - _salesAssistedTrialInitiationPath + TrialInitiationConstants.SalesAssistedTrialInitiationPath => StripeConstants.TrialInitiationPaths.SalesAssisted, _ => StripeConstants.TrialInitiationPaths.ProductInitiated } diff --git a/src/Libraries/Subscriptions.Organization/Commands/CreateOrganizationSubscriptionCommand.cs b/src/Libraries/Subscriptions.Organization/Commands/CreateOrganizationSubscriptionCommand.cs index 59d39c40dce8..72a2026ca9f7 100644 --- a/src/Libraries/Subscriptions.Organization/Commands/CreateOrganizationSubscriptionCommand.cs +++ b/src/Libraries/Subscriptions.Organization/Commands/CreateOrganizationSubscriptionCommand.cs @@ -18,6 +18,7 @@ using Plan = Bit.Core.Models.StaticStore.Plan; using StripeConstants = Bit.Core.Billing.Constants.StripeConstants; using Subscription = Stripe.Subscription; +using TrialInitiationConstants = Bit.Core.Billing.Constants.TrialInitiationConstants; namespace Bit.Subscriptions.Organization.Commands; @@ -30,9 +31,6 @@ internal sealed class CreateOrganizationSubscriptionCommand( ISubscriptionDiscountService subscriptionDiscountService, ITaxService taxService) : ICreateOrganizationSubscriptionCommand { - // Must match `InitiationPath.SalesAssistedTrialFromAdminPortal` in the clients repo. - private const string _salesAssistedTrialInitiationPath = "Sales assisted trial from admin portal"; - // Matched as a substring of the client's `InitiationPath` marketing-trial values. private const string _marketingTrialInitiationPathSegment = "trial from marketing website"; @@ -388,7 +386,7 @@ private static SubscriptionCreateOptions BuildSubscriptionCreateOptions( { var path when !string.IsNullOrEmpty(path) && path.Contains(_marketingTrialInitiationPathSegment) => StripeConstants.TrialInitiationPaths.MarketingInitiated, - _salesAssistedTrialInitiationPath + TrialInitiationConstants.SalesAssistedTrialInitiationPath => StripeConstants.TrialInitiationPaths.SalesAssisted, _ => StripeConstants.TrialInitiationPaths.ProductInitiated } diff --git a/src/Libraries/Subscriptions.Organization/README.md b/src/Libraries/Subscriptions.Organization/README.md index 866c7f8a062f..af7f1201a8ab 100644 --- a/src/Libraries/Subscriptions.Organization/README.md +++ b/src/Libraries/Subscriptions.Organization/README.md @@ -332,6 +332,7 @@ This library depends on `Core` as a documented deviation from the rule restricti | `TaxID` (`Bit.Core.Billing.Payment.Models`) | The organization's on-file tax id and the mapped request tax id | | `PlanCadenceType`, `PlanType`, `ProductTierType` (`Bit.Core.Billing.Enums`), `StripeConstants.SubscriptionStatus` | Resolving the target plan, gating the previewable subscription statuses, and the restart preview's `canceled` check | | `StripeConstants` (`Bit.Core.Billing.Constants`) | `classic` billing mode, the `sm-standalone` coupon, `customer_tax_location_invalid`, `tax_id_invalid`, the `es_cif` and `eu_vat` tax ID types, the `card_error` error type | +| `TrialInitiationConstants` (`Bit.Core.Billing.Constants`) | The sales-assisted sign-up initiation path, shared with the cloud sign-up command and `OrganizationBillingService` | | `BadRequestException`, `ConflictException` (`Bit.Core.Exceptions`) | Rejected-request responses from the previews and the Privileged Controls purchase, via the group's exception handling | | `ICreateOrganizationSubscriptionCommand` (`Bit.Core.Billing.Organizations.Commands`), `OrganizationSubscriptionCreation` (`Bit.Core.Billing.Organizations.Models`) | The net-new-subscription purchase contract Core owns so the cloud sign-up command can inject it; this library implements it | | `CustomerSetup`, `SubscriptionSetup` (`Bit.Core.Billing.Models.Sales`), `TokenizedPaymentSource` (`Bit.Core.Billing.Models`), `TaxInformation` (`Bit.Core.Billing.Tax.Models`) | The customer payment/tax/coupon inputs for creating the subscription | diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs index dd302b5de41d..aaac27a42386 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; @@ -769,10 +770,51 @@ await sutProvider.GetDependency().Received(1) .CancelAndRecoverChargesAsync(Arg.Any()); } + [Theory] + [BitAutoData(false, TrialInitiationConstants.SalesAssistedTrialInitiationPath)] + [BitAutoData(true, TrialInitiationConstants.SalesAssistedTrialInitiationPath)] + [BitAutoData(true, "Password Manager trial from marketing website")] + [BitAutoData(true, null)] + public async Task SignUpAsync_PrivilegedControls_SalesAssistedOrFlagOn_Provisions( + bool pamBillingSupport, string initiationPath, + OrganizationSignup signup, SutProvider sutProvider) + { + ArrangePrivilegedControlsSignup(signup, sutProvider, pamSeats: 6, pamBillingSupport: pamBillingSupport); + signup.InitiationPath = initiationPath; + + await sutProvider.Sut.SignUpOrganizationAsync(signup); + + await sutProvider.GetDependency().Received(1).Run( + Arg.Any(), + Arg.Is(c => c.PrivilegedControls != null && c.PrivilegedControls.Seats == 6)); + await sutProvider.GetDependency().DidNotReceive().Finalize(Arg.Any()); + } + + [Theory] + [BitAutoData("Password Manager trial from marketing website")] + [BitAutoData((string?)null)] + public async Task SignUpAsync_PrivilegedControls_SelfServeWithFlagOff_IgnoresPrivilegedControls( + string initiationPath, + OrganizationSignup signup, SutProvider sutProvider) + { + // Seats above the Password Manager seats would fail validation if the request were honored. + ArrangePrivilegedControlsSignup(signup, sutProvider, pamSeats: 11, pamBillingSupport: false); + signup.InitiationPath = initiationPath; + + await sutProvider.Sut.SignUpOrganizationAsync(signup); + + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => o.PamSeats == null && !o.UsePam)); + await sutProvider.GetDependency().Received(1).Finalize(Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .Run(default!, default!); + } + private static void ArrangePrivilegedControlsSignup( OrganizationSignup signup, SutProvider sutProvider, - int? pamSeats) + int? pamSeats, + bool pamBillingSupport = true) { signup.Plan = PlanType.EnterpriseAnnually; signup.AdditionalSeats = 10; @@ -785,7 +827,7 @@ private static void ArrangePrivilegedControlsSignup( signup.PamSeats = pamSeats; sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support).Returns(true); + .IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support).Returns(pamBillingSupport); sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(MockPlans.Get(signup.Plan)); sutProvider.GetDependency() .GetAsync(signup.Owner.Id) From 3e8906d2f9622942803cff47fe6790c7d9e35cf6 Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Thu, 5 Nov 2026 17:09:28 +0100 Subject: [PATCH 02/19] [PM-46145] feat: Make Admin Portal Privileged Controls available without the flag --- .../Controllers/OrganizationsController.cs | 12 ++-- .../Views/Shared/_OrganizationForm.cshtml | 11 ++-- .../Views/SalesAssistedTrial/Index.cshtml | 4 +- .../OrganizationsControllerTests.cs | 66 ++++++++++++------- 4 files changed, 54 insertions(+), 39 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 0733e4d9fcf4..f626a9b5a58b 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -358,7 +358,7 @@ 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); model.OccupiedPamSeatsCount = @@ -488,8 +488,7 @@ 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) + if (_accessControlService.UserHasPermission(Permission.Org_Plan_Edit) && await ValidatePrivilegedControlsAsync(organization, plan) is { } privilegedControlsError) { TempData["Error"] = privilegedControlsError; @@ -951,11 +950,8 @@ 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; - } + organization.PamSeats = model.UsePam ? model.PamSeats : null; + organization.MaxAutoscalePamSeats = model.UsePam ? model.MaxAutoscalePamSeats : null; } 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..d57aa6c6c707 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; + // Organizations managed by a provider can't use Privileged Access Management. + 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/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 1b79ec825abd..1ca1bbf6ba26 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1625,9 +1625,13 @@ private static void StubPrivilegedControlsEdit( Organization organization, bool supportsPrivilegedControls = true, bool canEditPlan = 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)); @@ -1648,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, @@ -1667,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_KeepsSeatsAndLimit( 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 == 8)); + 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] @@ -1738,84 +1749,6 @@ public async Task Edit_PrivilegedControls_SeatsMissing_Rejects( Assert.Equal("Privileged Access Management seats must be at least 1", sutProvider.Sut.TempData["Error"]); } - [BitAutoData] - [SutProviderCustomize] - [Theory] - public async Task Edit_PrivilegedControls_LegacyWithoutSeats_UnrelatedChange_SavesWithoutSeats( - Organization organization, - SutProvider sutProvider) - { - StubPrivilegedControlsEdit(sutProvider, organization); - organization.UsePam = true; - organization.PamSeats = null; - organization.MaxAutoscalePamSeats = null; - var update = new OrganizationEditModel - { - Name = "Renamed organization", - PlanType = PlanType.EnterpriseAnnually, - Seats = 10, - UsePam = true, - PamSeats = null - }; - - var result = await sutProvider.Sut.Edit(organization.Id, update); - - Assert.Null(sutProvider.Sut.TempData["Error"]); - Assert.IsType(result); - await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => o.UsePam && o.PamSeats == null)); - } - - [BitAutoData] - [SutProviderCustomize] - [Theory] - public async Task Edit_PrivilegedControls_LegacyWithoutSeats_SeatsPosted_Validates( - Organization organization, - SutProvider sutProvider) - { - StubPrivilegedControlsEdit(sutProvider, organization); - organization.UsePam = true; - organization.PamSeats = null; - var update = new OrganizationEditModel - { - PlanType = PlanType.EnterpriseAnnually, - Seats = 10, - UsePam = true, - PamSeats = 11 - }; - - var result = await sutProvider.Sut.Edit(organization.Id, update); - - await AssertPrivilegedControlsRejectedAsync(result, organization, sutProvider); - Assert.Equal( - "You cannot have more Privileged Controls seats than Password Manager seats. " + - "This organization has 11 Privileged Controls seats and 10 Password Manager seats.", - sutProvider.Sut.TempData["Error"]); - } - - [BitAutoData] - [SutProviderCustomize] - [Theory] - public async Task Edit_PrivilegedControls_LegacyWithoutSeats_SeatsBelowOnePosted_Rejects( - Organization organization, - SutProvider sutProvider) - { - StubPrivilegedControlsEdit(sutProvider, organization); - organization.UsePam = true; - organization.PamSeats = null; - var update = new OrganizationEditModel - { - PlanType = PlanType.EnterpriseAnnually, - UsePam = true, - PamSeats = 0 - }; - - 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] [SutProviderCustomize] [Theory] @@ -1839,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] @@ -1978,57 +1921,6 @@ await sutProvider.GetDependency().Received(1).ReplaceAs Arg.Is(o => o.Seats == 30 && o.PamSeats == 25)); } - [BitAutoData] - [SutProviderCustomize] - [Theory] - public async Task Edit_PrivilegedControls_PamLimitAbovePmLimit_Rejects( - Organization organization, - SutProvider sutProvider) - { - StubPrivilegedControlsEdit(sutProvider, organization); - var update = new OrganizationEditModel - { - PlanType = PlanType.EnterpriseAnnually, - Seats = 10, - 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 - }; - - await sutProvider.Sut.Edit(organization.Id, update); - - await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => o.MaxAutoscalePamSeats == 21)); - } - [BitAutoData] [SutProviderCustomize] [Theory] @@ -2108,31 +2000,6 @@ await sutProvider.GetDependency().Received(1).ReplaceAs Arg.Is(o => !o.UsePam && o.PamSeats == null && o.MaxAutoscalePamSeats == null)); } - [BitAutoData] - [SutProviderCustomize] - [Theory] - public async Task Edit_PrivilegedControls_PamBillingSupportFlagOff_StillSavesSeatsAndLimit( - Organization organization, - SutProvider sutProvider) - { - StubPrivilegedControlsEdit(sutProvider, organization); - sutProvider.GetDependency() - .IsEnabled(Bit.Core.FeatureFlagKeys.PM39883_Pam_Billing_Support) - .Returns(false); - var update = new OrganizationEditModel - { - PlanType = PlanType.EnterpriseAnnually, - UsePam = true, - PamSeats = 25, - MaxAutoscalePamSeats = 40 - }; - - await sutProvider.Sut.Edit(organization.Id, update); - - await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => o.UsePam && o.PamSeats == 25 && o.MaxAutoscalePamSeats == 40)); - } - [BitAutoData] [SutProviderCustomize] [Theory] @@ -2835,9 +2702,13 @@ private static Subscription PrivilegedControlsSubscription(bool includePrivilege private static void StubPrivilegedControlsGet( SutProvider sutProvider, Organization organization, + bool flagEnabled = true, Provider provider = null) { sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency() + .IsEnabled(Bit.Core.FeatureFlagKeys.PM39883_Pam_Billing_Support) + .Returns(flagEnabled); sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(provider); } @@ -2920,7 +2791,6 @@ public async Task Edit_Get_PrivilegedControls_StripeFails_StillLoads( SutProvider sutProvider) { StubPrivilegedControlsGet(sutProvider, organization); - sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); sutProvider.GetDependency() .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) .ThrowsAsync(new StripeException()); @@ -2929,57 +2799,26 @@ public async Task Edit_Get_PrivilegedControls_StripeFails_StillLoads( Assert.Equal(organization.Id, model.Organization.Id); Assert.Null(model.PrivilegedControlsStripeSeats); - Assert.Equal( - "The Privileged Access Management Stripe check could not be loaded, so mismatch warnings are not shown. " + - "Reload the page or check Stripe directly.", - sutProvider.Sut.TempData["Warning"]); } [BitAutoData] [SutProviderCustomize] [Theory] - public async Task Edit_Get_PrivilegedControls_StripeFails_BillingErrorAlreadyShown_AddsNoSecondToast( + public async Task Edit_Get_PrivilegedControls_FlagOff_DoesNotCallStripe( Organization organization, SutProvider sutProvider) { - StubPrivilegedControlsGet(sutProvider, organization); - sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); - // The billing load in the same action already reported the outage. - sutProvider.GetDependency().GetBillingAsync(organization).ThrowsAsync(new StripeException()); - sutProvider.GetDependency() - .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) - .ThrowsAsync(new StripeException()); - - var model = await GetEditModelAsync(sutProvider, organization); - - Assert.Equal(organization.Id, model.Organization.Id); - Assert.Null(model.PrivilegedControlsStripeSeats); - Assert.True(sutProvider.Sut.TempData.ContainsKey("Error")); - Assert.False(sutProvider.Sut.TempData.ContainsKey("Warning")); - } - - [BitAutoData] - [SutProviderCustomize] - [Theory] - public async Task Edit_Get_PrivilegedControls_PamBillingSupportFlagOff_StillLoadsSeats( - Organization organization, - SutProvider sutProvider) - { - StubPrivilegedControlsGet(sutProvider, organization); - sutProvider.GetDependency() - .IsEnabled(Bit.Core.FeatureFlagKeys.PM39883_Pam_Billing_Support) - .Returns(false); - sutProvider.GetDependency() - .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) - .Returns(PrivilegedControlsSubscription()); + StubPrivilegedControlsGet(sutProvider, organization, flagEnabled: false); sutProvider.GetDependency() .GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id) .Returns(7); var model = await GetEditModelAsync(sutProvider, organization); - Assert.Equal(20, model.PrivilegedControlsStripeSeats); + Assert.Null(model.PrivilegedControlsStripeSeats); Assert.Equal(7, model.OccupiedPamSeatsCount); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetSubscriptionAsync(default, default); } [BitAutoData] From 0e49e67a372eabe7e332140f6d859fb5ce242ae5 Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Fri, 6 Nov 2026 16:32:31 +0100 Subject: [PATCH 17/19] [PM-46145] feat: Make Privileged Controls seats a hard limit when granting or restoring access Finance bills Privileged Controls outside the platform, so cloud no longer lets a grant go over the organization's seats. Granting access to one member or in bulk now fails with a 400 asking the organization to contact sales, matching how self-hosted already refused. Restoring a revoked member who has access is limited the same way in cloud and self-hosted; a bulk restore reports the error against each member with access and restores the rest. A self-hosted license with fewer seats than members who already have access is still accepted, so existing grants stay and only new ones are blocked. --- .../BulkEnablePam/BulkEnablePamCommand.cs | 6 +- .../v1/RestoreOrganizationUserCommand.cs | 51 +++++- .../OrganizationUsers/UpdateUser/v2/Errors.cs | 1 + .../v2/UpdateOrganizationUserCommand.cs | 7 +- .../SelfHostedOrganizationDetailsTests.cs | 23 +++ .../BulkEnablePamCommandTests.cs | 12 +- .../RestoreOrganizationUserCommandTests.cs | 153 ++++++++++++++++++ .../v2/UpdateOrganizationUserCommandTests.cs | 28 +++- 8 files changed, 270 insertions(+), 11 deletions(-) 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/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) From 53c95372b59137a9576d145fb9e5781428945c9a Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Fri, 6 Nov 2026 16:37:22 +0100 Subject: [PATCH 18/19] [PM-46145] fix: Always store the Privileged Access Management autoscale limit as empty --- .../Controllers/OrganizationsController.cs | 8 +++---- .../OrganizationsControllerTests.cs | 4 ++-- ...ateOrganizationSubscriptionCommandTests.cs | 21 ------------------- 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 7344d5228ba0..af552d617a11 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -920,16 +920,16 @@ private void UpdatePrivilegedControls(Organization organization, OrganizationEdi organization.UsePam = model.UsePam; - // Turning it off keeps the seat count and limit, so they're still there if it's turned back on. + // 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; - - // Autoscaling is self-serve, so the Admin Portal doesn't set a limit. - organization.MaxAutoscalePamSeats = null; } private void UpdateOrganization(Organization organization, OrganizationEditModel model, bool isProviderManaged) diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 1ca1bbf6ba26..838b10f2c110 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1679,7 +1679,7 @@ await sutProvider.GetDependency().Received(1).ReplaceAs [BitAutoData] [SutProviderCustomize] [Theory] - public async Task Edit_PrivilegedControls_TurnedOff_KeepsSeatsAndLimit( + public async Task Edit_PrivilegedControls_TurnedOff_KeepsSeatsAndClearsLimit( Organization organization, SutProvider sutProvider) { @@ -1698,7 +1698,7 @@ public async Task Edit_PrivilegedControls_TurnedOff_KeepsSeatsAndLimit( await sutProvider.Sut.Edit(organization.Id, update); await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(o => !o.UsePam && o.PamSeats == 5 && o.MaxAutoscalePamSeats == 8)); + 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. diff --git a/test/Libraries/Subscriptions.Organization.Test/Commands/CreateOrganizationSubscriptionCommandTests.cs b/test/Libraries/Subscriptions.Organization.Test/Commands/CreateOrganizationSubscriptionCommandTests.cs index 8b7be58e2c27..02442277f655 100644 --- a/test/Libraries/Subscriptions.Organization.Test/Commands/CreateOrganizationSubscriptionCommandTests.cs +++ b/test/Libraries/Subscriptions.Organization.Test/Commands/CreateOrganizationSubscriptionCommandTests.cs @@ -312,27 +312,6 @@ await _command.Run(organization, Assert.Null(_capturedSubscriptionOptions!.TrialSettings); } - // Literal strings on purpose: the initiation path must match the clients-repo InitiationPath enum, and the - // metadata value is persisted in Stripe, so changing either should fail this test. - [Fact] - public async Task Run_SalesAssistedInitiationPath_SetsSalesAssistedTrialMetadata() - { - StubSuccessfulPurchase(); - - var creation = Creation(trialDays: 7) with - { - Trial = new OrganizationSubscriptionCreation.TrialSetup - { - Length = 7, - InitiationPath = "Sales assisted trial from admin portal" - } - }; - - await _command.Run(Organization(), creation); - - Assert.Equal("sales-assisted", _capturedSubscriptionOptions!.Metadata["trialInitiationPath"]); - } - [Fact] public async Task Run_NoPaymentMethodAndTrial_SetsMissingPaymentMethodCancel() { From 48d3700c3bcc2b4f4874a04dae22a9dbfab11758 Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Fri, 6 Nov 2026 17:35:01 +0100 Subject: [PATCH 19/19] [PM-46145] test: Expect a 400 when cloud Privileged Controls seats are insufficient --- .../OrganizationUsersControllerBulkEnablePamTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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]