From de519cbc7b38b9da5d1827a48b086eaec5b5e758 Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Tue, 30 Jun 2026 12:10:10 +0100 Subject: [PATCH 1/2] [PM-39210] fix: Block converting a Secrets-Manager-enabled org to a Business Unit Portal BusinessUnitConverter gated org-to-Business-Unit conversion only on Enterprise tier and never checked UseSecretsManager, so a Secrets-Manager-enabled org could be converted. The Password Manager line was then swapped to the Business Unit price and the org's GatewaySubscriptionId nulled, orphaning the Secrets Manager subscription line. Add a UseSecretsManager guard to both validators, mirroring ProviderService, placed before the Stripe GetSubscription call so a disqualified org short-circuits. ValidateInitiationAsync appends a user-facing problem; ValidateFinalizationAsync fails via the existing log-and-throw convention. --- .../Services/BusinessUnitConverter.cs | 10 +++ .../Services/BusinessUnitConverterTests.cs | 63 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 5fa2c9405c45..8a2dd2a4891a 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -332,6 +332,11 @@ private async Task SendInviteAsync( Fail("Organization must be on an enterprise plan."); } + if (organization.UseSecretsManager) + { + Fail("Organization is subscribed to Secrets Manager."); + } + var subscription = await subscriberService.GetSubscription(organization); if (subscription is not @@ -418,6 +423,11 @@ void Fail(string scopedError) problems.Add("Organization must be on an enterprise plan."); } + if (organization.UseSecretsManager) + { + problems.Add("Organization is subscribed to Secrets Manager. Please contact Customer Support to convert this organization to a business unit."); + } + var subscription = await subscriberService.GetSubscription(organization); if (subscription is not diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index 48b971a032a8..d00b67051e2a 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -72,6 +72,7 @@ public async Task FinalizeConversion_Succeeds_ReturnsProviderId( string organizationKey) { organization.PlanType = PlanType.EnterpriseAnnually2020; + organization.UseSecretsManager = false; var enterpriseAnnually2020 = MockPlans.Get(PlanType.EnterpriseAnnually2020); @@ -206,6 +207,25 @@ await _organizationUserRepository.DidNotReceiveWithAnyArgs() .GetByOrganizationAsync(Arg.Any(), Arg.Any()); } + [Theory, BitAutoData] + public async Task FinalizeConversion_OrganizationUsesSecretsManager_ThrowsBillingException( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + organization.UseSecretsManager = true; + + var businessUnitConverter = BuildConverter(); + + await Assert.ThrowsAsync(() => + businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey)); + + await _subscriberService.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any()); + } + #endregion #region InitiateConversion @@ -216,6 +236,7 @@ public async Task InitiateConversion_Succeeds_ReturnsProviderId( string providerAdminEmail) { organization.PlanType = PlanType.EnterpriseAnnually; + organization.UseSecretsManager = false; _subscriberService.GetSubscription(organization).Returns(new Subscription { @@ -292,6 +313,7 @@ public async Task InitiateConversion_ValidationFails_ReturnsErrors( string providerAdminEmail) { organization.PlanType = PlanType.TeamsMonthly; + organization.UseSecretsManager = true; _subscriberService.GetSubscription(organization).Returns(new Subscription { @@ -324,6 +346,8 @@ public async Task InitiateConversion_ValidationFails_ReturnsErrors( Assert.Contains("Organization must be on an enterprise plan.", problems); + Assert.Contains("Organization is subscribed to Secrets Manager. Please contact Customer Support to convert this organization to a business unit.", problems); + Assert.Contains("Organization must have a valid subscription.", problems); Assert.Contains("Organization is already linked to a provider.", problems); @@ -331,6 +355,45 @@ public async Task InitiateConversion_ValidationFails_ReturnsErrors( Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems); } + [Theory, BitAutoData] + public async Task InitiateConversion_OrganizationUsesSecretsManager_ReturnsError( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.EnterpriseAnnually; + organization.UseSecretsManager = true; + + _subscriberService.GetSubscription(organization).Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Active + }); + + var user = new User + { + Id = Guid.NewGuid(), + Email = providerAdminEmail + }; + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var businessUnitConverter = BuildConverter(); + + var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail); + + Assert.True(result.IsT1); + + var problems = result.AsT1; + + Assert.Contains("Organization is subscribed to Secrets Manager. Please contact Customer Support to convert this organization to a business unit.", problems); + + await _providerRepository.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + } + #endregion #region ResendConversionInvite From 300d6b74afd844f3f6c4fdc630c9ee6f60fc4740 Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Tue, 30 Jun 2026 15:55:31 +0100 Subject: [PATCH 2/2] Hide Convert to Business Unit option in Admin when org uses Secrets Manager --- src/Admin/AdminConsole/Views/Organizations/Edit.cshtml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 690ee3d778c1..a736de797657 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -18,7 +18,8 @@ var canConvertToBusinessUnit = AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && - Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; + Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending } && + !Model.Organization.UseSecretsManager; } @section Scripts {