diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 69b7e67ec1..36a5f2c0a9 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -48,7 +48,7 @@ public class CreateProviderCommand : ICreateProviderCommand await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created); } - public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats) + public async Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats) { var providerId = await CreateProviderAsync(provider, ownerEmail); diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 799b57dc5a..fff6b5271d 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -692,10 +692,10 @@ public class ProviderService : IProviderService throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); } break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually)) { - throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); + throw new BadRequestException($"Business Unit Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); } break; case ProviderType.Reseller: diff --git a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs new file mode 100644 index 0000000000..97d9377cd6 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs @@ -0,0 +1,462 @@ +#nullable enable +using System.Diagnostics.CodeAnalysis; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Commercial.Core.Billing; + +[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] +public class BusinessUnitConverter( + IDataProtectionProvider dataProtectionProvider, + GlobalSettings globalSettings, + ILogger logger, + IMailService mailService, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPricingClient pricingClient, + IProviderOrganizationRepository providerOrganizationRepository, + IProviderPlanRepository providerPlanRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserRepository userRepository) : IBusinessUnitConverter +{ + private readonly IDataProtector _dataProtector = + dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector"); + + public async Task FinalizeConversion( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey) + { + var user = await userRepository.GetByIdAsync(userId); + + var (subscription, provider, providerOrganization, providerUser) = await ValidateFinalizationAsync(organization, user, token); + + var existingPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + var updatedPlan = await pricingClient.GetPlanOrThrow(existingPlan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly); + + // Bring organization under management. + organization.Plan = updatedPlan.Name; + organization.PlanType = updatedPlan.Type; + organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections; + organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; + organization.UsePolicies = updatedPlan.HasPolicies; + organization.UseSso = updatedPlan.HasSso; + organization.UseGroups = updatedPlan.HasGroups; + organization.UseEvents = updatedPlan.HasEvents; + organization.UseDirectory = updatedPlan.HasDirectory; + organization.UseTotp = updatedPlan.HasTotp; + organization.Use2fa = updatedPlan.Has2fa; + organization.UseApi = updatedPlan.HasApi; + organization.UseResetPassword = updatedPlan.HasResetPassword; + organization.SelfHost = updatedPlan.HasSelfHost; + organization.UsersGetPremium = updatedPlan.UsersGetPremium; + organization.UseCustomPermissions = updatedPlan.HasCustomPermissions; + organization.UseScim = updatedPlan.HasScim; + organization.UseKeyConnector = updatedPlan.HasKeyConnector; + organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; + organization.BillingEmail = provider.BillingEmail!; + organization.GatewayCustomerId = null; + organization.GatewaySubscriptionId = null; + organization.ExpirationDate = null; + organization.MaxAutoscaleSeats = null; + organization.Status = OrganizationStatusType.Managed; + + // Enable organization access via key exchange. + providerOrganization.Key = organizationKey; + + // Complete provider setup. + provider.Gateway = GatewayType.Stripe; + provider.GatewayCustomerId = subscription.CustomerId; + provider.GatewaySubscriptionId = subscription.Id; + provider.Status = ProviderStatusType.Billable; + + // Enable provider access via key exchange. + providerUser.Key = providerKey; + providerUser.Status = ProviderUserStatusType.Confirmed; + + // Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them. + await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [] + } + }); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.OrganizationId] = string.Empty, + [StripeConstants.MetadataKeys.ProviderId] = provider.Id.ToString(), + ["convertedFrom"] = organization.Id.ToString() + }; + + var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = provider.SubscriberType(), + Value = provider.DisplayName()?.Length <= 30 + ? provider.DisplayName() + : provider.DisplayName()?[..30] + } + ] + }, + Metadata = metadata + }); + + // Find the existing password manager price on the subscription. + var passwordManagerItem = subscription.Items.First(item => + { + var priceId = existingPlan.HasNonSeatBasedPasswordManagerPlan() + ? existingPlan.PasswordManager.StripePlanId + : existingPlan.PasswordManager.StripeSeatPlanId; + + return item.Price.Id == priceId; + }); + + // Get the new business unit price. + var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, updatedPlan.Type); + + // Replace the existing password manager price with the new business unit price. + var updateSubscription = + stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + new SubscriptionUpdateOptions + { + Items = [ + new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, + Deleted = true + }, + new SubscriptionItemOptions + { + Price = updatedPriceId, + Quantity = organization.Seats + } + ], + Metadata = metadata + }); + + await Task.WhenAll(updateCustomer, updateSubscription); + + // Complete database updates for provider setup. + await Task.WhenAll( + organizationRepository.ReplaceAsync(organization), + providerOrganizationRepository.ReplaceAsync(providerOrganization), + providerRepository.ReplaceAsync(provider), + providerUserRepository.ReplaceAsync(providerUser)); + + return provider.Id; + } + + public async Task>> InitiateConversion( + Organization organization, + string providerAdminEmail) + { + var user = await userRepository.GetByEmailAsync(providerAdminEmail); + + var problems = await ValidateInitiationAsync(organization, user); + + if (problems is { Count: > 0 }) + { + return problems; + } + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = organization.Name, + BillingEmail = organization.BillingEmail, + Status = ProviderStatusType.Pending, + UseEvents = true, + Type = ProviderType.BusinessUnit + }); + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var managedPlanType = plan.IsAnnual + ? PlanType.EnterpriseAnnually + : PlanType.EnterpriseMonthly; + + var createProviderOrganization = providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organization.Id + }); + + var createProviderPlan = providerPlanRepository.CreateAsync(new ProviderPlan + { + ProviderId = provider.Id, + PlanType = managedPlanType, + SeatMinimum = 0, + PurchasedSeats = organization.Seats, + AllocatedSeats = organization.Seats + }); + + var createProviderUser = providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user!.Id, + Email = user.Email, + Status = ProviderUserStatusType.Invited, + Type = ProviderUserType.ProviderAdmin + }); + + await Task.WhenAll(createProviderOrganization, createProviderPlan, createProviderUser); + + await SendInviteAsync(organization, user.Email); + + return provider.Id; + } + + public Task ResendConversionInvite( + Organization organization, + string providerAdminEmail) => + IfConversionInProgressAsync(organization, providerAdminEmail, + async (_, _, providerUser) => + { + if (!string.IsNullOrEmpty(providerUser.Email)) + { + await SendInviteAsync(organization, providerUser.Email); + } + }); + + public Task ResetConversion( + Organization organization, + string providerAdminEmail) => + IfConversionInProgressAsync(organization, providerAdminEmail, + async (provider, providerOrganization, providerUser) => + { + var tasks = new List + { + providerOrganizationRepository.DeleteAsync(providerOrganization), + providerUserRepository.DeleteAsync(providerUser) + }; + + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + + if (providerPlans is { Count: > 0 }) + { + tasks.AddRange(providerPlans.Select(providerPlanRepository.DeleteAsync)); + } + + await Task.WhenAll(tasks); + + await providerRepository.DeleteAsync(provider); + }); + + #region Utilities + + private async Task IfConversionInProgressAsync( + Organization organization, + string providerAdminEmail, + Func callback) + { + var user = await userRepository.GetByEmailAsync(providerAdminEmail); + + if (user == null) + { + return; + } + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + if (provider is not + { + Type: ProviderType.BusinessUnit, + Status: ProviderStatusType.Pending + }) + { + return; + } + + var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id); + + if (providerUser is + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Invited + }) + { + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + await callback(provider, providerOrganization!, providerUser); + } + } + + private async Task SendInviteAsync( + Organization organization, + string providerAdminEmail) + { + var token = _dataProtector.Protect( + $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail); + } + + private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync( + Organization organization, + User? user, + string token) + { + if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise) + { + Fail("Organization must be on an enterprise plan."); + } + + var subscription = await subscriberService.GetSubscription(organization); + + if (subscription is not + { + Status: + StripeConstants.SubscriptionStatus.Active or + StripeConstants.SubscriptionStatus.Trialing or + StripeConstants.SubscriptionStatus.PastDue + }) + { + Fail("Organization must have a valid subscription."); + } + + if (user == null) + { + Fail("Provider admin must be a Bitwarden user."); + } + + if (!CoreHelpers.TokenIsValid( + "BusinessUnitConversionInvite", + _dataProtector, + token, + user.Email, + organization.Id, + globalSettings.OrganizationInviteExpirationHours)) + { + Fail("Email token is invalid."); + } + + var organizationUser = + await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id); + + if (organizationUser is not + { + Status: OrganizationUserStatusType.Confirmed + }) + { + Fail("Provider admin must be a confirmed member of the organization being converted."); + } + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + if (provider is not + { + Type: ProviderType.BusinessUnit, + Status: ProviderStatusType.Pending + }) + { + Fail("Linked provider is not a pending business unit."); + } + + var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id); + + if (providerUser is not + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Invited + }) + { + Fail("Provider admin has not been invited."); + } + + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + + return (subscription, provider, providerOrganization!, providerUser); + + [DoesNotReturn] + void Fail(string scopedError) + { + logger.LogError("Could not finalize business unit conversion for organization ({OrganizationID}): {Error}", + organization.Id, scopedError); + throw new BillingException(); + } + } + + private async Task?> ValidateInitiationAsync( + Organization organization, + User? user) + { + var problems = new List(); + + if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise) + { + problems.Add("Organization must be on an enterprise plan."); + } + + var subscription = await subscriberService.GetSubscription(organization); + + if (subscription is not + { + Status: + StripeConstants.SubscriptionStatus.Active or + StripeConstants.SubscriptionStatus.Trialing or + StripeConstants.SubscriptionStatus.PastDue + }) + { + problems.Add("Organization must have a valid subscription."); + } + + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + + if (providerOrganization != null) + { + problems.Add("Organization is already linked to a provider."); + } + + if (user == null) + { + problems.Add("Provider admin must be a Bitwarden user."); + } + else + { + var organizationUser = + await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id); + + if (organizationUser is not + { + Status: OrganizationUserStatusType.Confirmed + }) + { + problems.Add("Provider admin must be a confirmed member of the organization being converted."); + } + } + + return problems.Count == 0 ? null : problems; + } + + #endregion +} diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 757d6510f1..98ebefd4f1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -791,7 +791,7 @@ public class ProviderBillingService( Provider provider, Organization organization) { - if (provider.Type == ProviderType.MultiOrganizationEnterprise) + if (provider.Type == ProviderType.BusinessUnit) { return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType; } diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs index 4cc0711ec9..a9dbb6febf 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs @@ -51,7 +51,7 @@ public static class ProviderPriceAdapter /// The provider's subscription. /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. - /// Thrown when the provider's type is not or . + /// Thrown when the provider's type is not or . /// Thrown when the provided does not relate to a Stripe price ID. public static string GetPriceId( Provider provider, @@ -78,7 +78,7 @@ public static class ProviderPriceAdapter PlanType.EnterpriseMonthly => MSP.Active.Enterprise, _ => throw invalidPlanType }, - ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any() + ProviderType.BusinessUnit => BusinessUnit.Legacy.List.Intersect(priceIds).Any() ? planType switch { PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually, @@ -103,7 +103,7 @@ public static class ProviderPriceAdapter /// The provider to get the Stripe price ID for. /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. - /// Thrown when the provider's type is not or . + /// Thrown when the provider's type is not or . /// Thrown when the provided does not relate to a Stripe price ID. public static string GetActivePriceId( Provider provider, @@ -120,7 +120,7 @@ public static class ProviderPriceAdapter PlanType.EnterpriseMonthly => MSP.Active.Enterprise, _ => throw invalidPlanType }, - ProviderType.MultiOrganizationEnterprise => planType switch + ProviderType.BusinessUnit => planType switch { PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually, PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly, diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 5ae5be8847..7f8c82e2c9 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddTransient(); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs index e354e44173..82fcb016b3 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs @@ -63,7 +63,7 @@ public class CreateProviderCommandTests } [Theory, BitAutoData] - public async Task CreateMultiOrganizationEnterpriseAsync_Success( + public async Task CreateBusinessUnitAsync_Success( Provider provider, User user, PlanType plan, @@ -71,13 +71,13 @@ public class CreateProviderCommandTests SutProvider sutProvider) { // Arrange - provider.Type = ProviderType.MultiOrganizationEnterprise; + provider.Type = ProviderType.BusinessUnit; var userRepository = sutProvider.GetDependency(); userRepository.GetByEmailAsync(user.Email).Returns(user); // Act - await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats); + await sutProvider.Sut.CreateBusinessUnitAsync(provider, user.Email, plan, minimumSeats); // Assert await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(provider); @@ -85,7 +85,7 @@ public class CreateProviderCommandTests } [Theory, BitAutoData] - public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws( + public async Task CreateBusinessUnitAsync_UserIdIsInvalid_Throws( Provider provider, SutProvider sutProvider) { @@ -94,7 +94,7 @@ public class CreateProviderCommandTests // Act var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default)); + () => sutProvider.Sut.CreateBusinessUnitAsync(provider, default, default, default)); // Assert Assert.Contains("Invalid owner.", exception.Message); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs new file mode 100644 index 0000000000..5d2d0a2c7c --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs @@ -0,0 +1,501 @@ +#nullable enable +using System.Text; +using Bit.Commercial.Core.Billing; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Commercial.Core.Test.Billing; + +public class BusinessUnitConverterTests +{ + private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For(); + private readonly GlobalSettings _globalSettings = new(); + private readonly ILogger _logger = Substitute.For>(); + private readonly IMailService _mailService = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For(); + private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IProviderUserRepository _providerUserRepository = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + + private BusinessUnitConverter BuildConverter() => new( + _dataProtectionProvider, + _globalSettings, + _logger, + _mailService, + _organizationRepository, + _organizationUserRepository, + _pricingClient, + _providerOrganizationRepository, + _providerPlanRepository, + _providerRepository, + _providerUserRepository, + _stripeAdapter, + _subscriberService, + _userRepository); + + #region FinalizeConversion + + [Theory, BitAutoData] + public async Task FinalizeConversion_Succeeds_ReturnsProviderId( + Organization organization, + Guid userId, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + + var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020); + + var subscription = new Subscription + { + Id = "subscription_id", + CustomerId = "customer_id", + Status = StripeConstants.SubscriptionStatus.Active, + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + Id = "subscription_item_id", + Price = new Price + { + Id = enterpriseAnnually2020.PasswordManager.StripeSeatPlanId + } + } + ] + } + }; + + _subscriberService.GetSubscription(organization).Returns(subscription); + + var user = new User + { + Id = Guid.NewGuid(), + Email = "provider-admin@example.com" + }; + + _userRepository.GetByIdAsync(userId).Returns(user); + + var token = SetupDataProtection(organization, user.Email); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var provider = new Provider + { + Type = ProviderType.BusinessUnit, + Status = ProviderStatusType.Pending + }; + + _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var providerUser = new ProviderUser + { + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Invited + }; + + _providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var providerOrganization = new ProviderOrganization(); + + _providerOrganizationRepository.GetByOrganizationId(organization.Id).Returns(providerOrganization); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020) + .Returns(enterpriseAnnually2020); + + var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually) + .Returns(enterpriseAnnually); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey); + + await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any()); + + var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type); + + await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is( + arguments => + arguments.Items.Count == 2 && + arguments.Items[0].Id == "subscription_item_id" && + arguments.Items[0].Deleted == true && + arguments.Items[1].Price == updatedPriceId && + arguments.Items[1].Quantity == organization.Seats)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.PlanType == PlanType.EnterpriseAnnually && + arguments.Status == OrganizationStatusType.Managed && + arguments.GatewayCustomerId == null && + arguments.GatewaySubscriptionId == null)); + + await _providerOrganizationRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Key == organizationKey)); + + await _providerRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Gateway == GatewayType.Stripe && + arguments.GatewayCustomerId == subscription.CustomerId && + arguments.GatewaySubscriptionId == subscription.Id && + arguments.Status == ProviderStatusType.Billable)); + + await _providerUserRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Key == providerKey && + arguments.Status == ProviderUserStatusType.Confirmed)); + } + + /* + * Because the validation for finalization is not an applicative like initialization is, + * I'm just testing one specific failure here. I don't see much value in testing every single opportunity for failure. + */ + [Theory, BitAutoData] + public async Task FinalizeConversion_ValidationFails_ThrowsBillingException( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + + var subscription = new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }; + + _subscriberService.GetSubscription(organization).Returns(subscription); + + var businessUnitConverter = BuildConverter(); + + await Assert.ThrowsAsync(() => + businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey)); + + await _organizationUserRepository.DidNotReceiveWithAnyArgs() + .GetByOrganizationAsync(Arg.Any(), Arg.Any()); + } + + #endregion + + #region InitiateConversion + + [Theory, BitAutoData] + public async Task InitiateConversion_Succeeds_ReturnsProviderId( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.EnterpriseAnnually; + + _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 provider = new Provider { Id = Guid.NewGuid() }; + + _providerRepository.CreateAsync(Arg.Is(argument => + argument.Name == organization.Name && + argument.BillingEmail == organization.BillingEmail && + argument.Status == ProviderStatusType.Pending && + argument.Type == ProviderType.BusinessUnit)).Returns(provider); + + var plan = StaticStore.GetPlan(organization.PlanType); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var token = SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail); + + Assert.True(result.IsT0); + + var providerId = result.AsT0; + + Assert.Equal(provider.Id, providerId); + + await _providerOrganizationRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.OrganizationId == organization.Id)); + + await _providerPlanRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.PlanType == PlanType.EnterpriseAnnually && + argument.SeatMinimum == 0 && + argument.PurchasedSeats == organization.Seats && + argument.AllocatedSeats == organization.Seats)); + + await _providerUserRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.UserId == user.Id && + argument.Email == user.Email && + argument.Status == ProviderUserStatusType.Invited && + argument.Type == ProviderUserType.ProviderAdmin)); + + await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( + organization, + token, + user.Email); + } + + [Theory, BitAutoData] + public async Task InitiateConversion_ValidationFails_ReturnsErrors( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.TeamsMonthly; + + _subscriberService.GetSubscription(organization).Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }); + + var user = new User + { + Id = Guid.NewGuid(), + Email = providerAdminEmail + }; + + _providerOrganizationRepository.GetByOrganizationId(organization.Id) + .Returns(new ProviderOrganization()); + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Invited }; + + _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 must be on an enterprise plan.", problems); + + Assert.Contains("Organization must have a valid subscription.", problems); + + Assert.Contains("Organization is already linked to a provider.", problems); + + Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems); + } + + #endregion + + #region ResendConversionInvite + + [Theory, BitAutoData] + public async Task ResendConversionInvite_ConversionInProgress_Succeeds( + Organization organization, + string providerAdminEmail) + { + SetupConversionInProgress(organization, providerAdminEmail); + + var token = SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); + + await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( + organization, + token, + providerAdminEmail); + } + + [Theory, BitAutoData] + public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing( + Organization organization, + string providerAdminEmail) + { + SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); + + await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + #endregion + + #region ResetConversion + + [Theory, BitAutoData] + public async Task ResetConversion_ConversionInProgress_Succeeds( + Organization organization, + string providerAdminEmail) + { + var (provider, providerOrganization, providerUser, providerPlan) = SetupConversionInProgress(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResetConversion(organization, providerAdminEmail); + + await _providerOrganizationRepository.Received(1) + .DeleteAsync(providerOrganization); + + await _providerUserRepository.Received(1) + .DeleteAsync(providerUser); + + await _providerPlanRepository.Received(1) + .DeleteAsync(providerPlan); + + await _providerRepository.Received(1) + .DeleteAsync(provider); + } + + [Theory, BitAutoData] + public async Task ResetConversion_NoConversionInProgress_DoesNothing( + Organization organization, + string providerAdminEmail) + { + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResetConversion(organization, providerAdminEmail); + + await _providerOrganizationRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerUserRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerPlanRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + } + + #endregion + + #region Utilities + + private string SetupDataProtection( + Organization organization, + string providerAdminEmail) + { + var dataProtector = new MockDataProtector(organization, providerAdminEmail); + _dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector").Returns(dataProtector); + return dataProtector.Protect(dataProtector.Token); + } + + private (Provider, ProviderOrganization, ProviderUser, ProviderPlan) SetupConversionInProgress( + Organization organization, + string providerAdminEmail) + { + var user = new User { Id = Guid.NewGuid() }; + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.BusinessUnit, + Status = ProviderStatusType.Pending + }; + + _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var providerUser = new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + UserId = user.Id, + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Invited, + Email = providerAdminEmail + }; + + _providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id) + .Returns(providerUser); + + var providerOrganization = new ProviderOrganization + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + ProviderId = provider.Id + }; + + _providerOrganizationRepository.GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + var providerPlan = new ProviderPlan + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseAnnually + }; + + _providerPlanRepository.GetByProviderId(provider.Id).Returns([providerPlan]); + + return (provider, providerOrganization, providerUser, providerPlan); + } + + #endregion +} + +public class MockDataProtector( + Organization organization, + string providerAdminEmail) : IDataProtector +{ + public string Token = $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"; + + public IDataProtector CreateProtector(string purpose) => this; + + public byte[] Protect(byte[] plaintext) => Encoding.UTF8.GetBytes(Token); + + public byte[] Unprotect(byte[] protectedData) => Encoding.UTF8.GetBytes(Token); +} diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index ab1000d631..2661a0eff6 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -116,7 +116,7 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange - provider.Type = ProviderType.MultiOrganizationEnterprise; + provider.Type = ProviderType.BusinessUnit; var providerPlanRepository = sutProvider.GetDependency(); var existingPlan = new ProviderPlan diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs index 4fce78c05a..9ecb4b0511 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs @@ -71,7 +71,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var subscription = new Subscription @@ -98,7 +98,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var subscription = new Subscription @@ -141,7 +141,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var result = ProviderPriceAdapter.GetActivePriceId(provider, planType); diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index 4a255eefb2..cd30e841b4 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -14,9 +14,6 @@ - - - diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 0b1e4035df..6dc33e4909 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -133,10 +133,10 @@ public class ProvidersController : Controller return View(new CreateResellerProviderModel()); } - [HttpGet("providers/create/multi-organization-enterprise")] - public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null) + [HttpGet("providers/create/business-unit")] + public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null) { - return View(new CreateMultiOrganizationEnterpriseProviderModel + return View(new CreateBusinessUnitProviderModel { OwnerEmail = ownerEmail, EnterpriseSeatMinimum = enterpriseMinimumSeats @@ -157,7 +157,7 @@ public class ProvidersController : Controller { ProviderType.Msp => RedirectToAction("CreateMsp"), ProviderType.Reseller => RedirectToAction("CreateReseller"), - ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"), + ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"), _ => View(model) }; } @@ -198,10 +198,10 @@ public class ProvidersController : Controller return RedirectToAction("Edit", new { id = provider.Id }); } - [HttpPost("providers/create/multi-organization-enterprise")] + [HttpPost("providers/create/business-unit")] [ValidateAntiForgeryToken] [RequirePermission(Permission.Provider_Create)] - public async Task CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model) + public async Task CreateBusinessUnit(CreateBusinessUnitProviderModel model) { if (!ModelState.IsValid) { @@ -209,7 +209,7 @@ public class ProvidersController : Controller } var provider = model.ToProvider(); - await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync( + await _createProviderCommand.CreateBusinessUnitAsync( provider, model.OwnerEmail, model.Plan.Value, @@ -307,7 +307,7 @@ public class ProvidersController : Controller ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: { var existingMoePlan = providerPlans.Single(); diff --git a/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs similarity index 76% rename from src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs rename to src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs index ef7210a9ef..b57d90e33b 100644 --- a/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs @@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities; namespace Bit.Admin.AdminConsole.Models; -public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject +public class CreateBusinessUnitProviderModel : IValidatableObject { [Display(Name = "Owner Email")] public string OwnerEmail { get; set; } @@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject { return new Provider { - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; } @@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject { if (string.IsNullOrWhiteSpace(OwnerEmail)) { - var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); + var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); } if (EnterpriseSeatMinimum < 0) { - var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseSeatMinimum); + var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseSeatMinimum); yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative."); } if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly) { - var planDisplayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); + var planDisplayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly."); } } diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index bcdf602c07..7f8ffb224e 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -34,7 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; - if (Type == ProviderType.MultiOrganizationEnterprise) + if (Type == ProviderType.BusinessUnit) { var plan = providerPlans.SingleOrDefault(); EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0; @@ -100,7 +100,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); } break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: if (Plan == null) { var displayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); diff --git a/src/Admin/AdminConsole/Models/ProviderViewModel.cs b/src/Admin/AdminConsole/Models/ProviderViewModel.cs index 724e6220b3..bcb96df006 100644 --- a/src/Admin/AdminConsole/Models/ProviderViewModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderViewModel.cs @@ -40,7 +40,7 @@ public class ProviderViewModel ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats)); } } - else if (Provider.Type == ProviderType.MultiOrganizationEnterprise) + else if (Provider.Type == ProviderType.BusinessUnit) { var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly) .Sum(po => po.OccupiedSeats).GetValueOrDefault(0); diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 3ac716a6d4..f240cb192f 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -1,8 +1,13 @@ @using Bit.Admin.Enums; @using Bit.Admin.Models +@using Bit.Core +@using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums -@using Bit.Core.Enums +@using Bit.Core.Billing.Extensions +@using Bit.Core.Services +@using Microsoft.AspNetCore.Mvc.TagHelpers @inject Bit.Admin.Services.IAccessControlService AccessControlService +@inject IFeatureService FeatureService @model OrganizationEditModel @{ ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name; @@ -13,6 +18,13 @@ var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete); var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); + + var canConvertToBusinessUnit = + FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) && + 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 }; } @section Scripts { @@ -114,6 +126,15 @@ Enterprise Trial } + @if (canConvertToBusinessUnit) + { + + Convert to Business Unit + + } @if (canUnlinkFromProvider && Model.Provider is not null) { + + } + @if (Model.Errors?.Any() ?? false) + { + @foreach (var error in Model.Errors) + { + + } + } +

This organization has a business unit conversion in progress.

+ +
+ + +
+ +
+ + + + +
+ + +
+ @if (Model.ProviderId.HasValue) + { + + Go to Provider + + } +
+} +else +{ +

Convert @Model.Organization.Name to Business Unit

+ @if (Model.Errors?.Any() ?? false) + { + @foreach (var error in Model.Errors) + { + + } + } +
+
+
+ + +
+ +
+} diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs index 4edcd742b4..704fd770bb 100644 --- a/src/Admin/Enums/Permissions.cs +++ b/src/Admin/Enums/Permissions.cs @@ -38,6 +38,7 @@ public enum Permission Org_Billing_View, Org_Billing_Edit, Org_Billing_LaunchGateway, + Org_Billing_ConvertToBusinessUnit, Provider_List_View, Provider_Create, diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index 3b510781be..f342dfce7c 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -42,6 +42,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Provider_List_View, Permission.Provider_Create, Permission.Provider_View, @@ -90,6 +91,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Org_InitiateTrial, Permission.Provider_List_View, Permission.Provider_Create, @@ -166,6 +168,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Org_RequestDelete, Permission.Provider_Edit, Permission.Provider_View, diff --git a/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs b/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs index bdfec26242..9cc5b89ee8 100644 --- a/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs @@ -22,6 +22,7 @@ public class ProfileProviderResponseModel : ResponseModel UserId = provider.UserId; UseEvents = provider.UseEvents; ProviderStatus = provider.ProviderStatus; + ProviderType = provider.ProviderType; } public Guid Id { get; set; } @@ -35,4 +36,5 @@ public class ProfileProviderResponseModel : ResponseModel public Guid? UserId { get; set; } public bool UseEvents { get; set; } public ProviderStatusType ProviderStatus { get; set; } + public ProviderType ProviderType { get; set; } } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 2ec503281e..1d4ebc1511 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -2,6 +2,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; @@ -18,7 +19,9 @@ namespace Bit.Api.Billing.Controllers; [Route("organizations/{organizationId:guid}/billing")] [Authorize("Application")] public class OrganizationBillingController( + IBusinessUnitConverter businessUnitConverter, ICurrentContext currentContext, + IFeatureService featureService, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, @@ -296,4 +299,40 @@ public class OrganizationBillingController( return TypedResults.Ok(); } + + [HttpPost("setup-business-unit")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task SetupBusinessUnitAsync( + [FromRoute] Guid organizationId, + [FromBody] SetupBusinessUnitRequestBody requestBody) + { + var enableOrganizationBusinessUnitConversion = + featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion); + + if (!enableOrganizationBusinessUnitConversion) + { + return Error.NotFound(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return Error.NotFound(); + } + + if (!await currentContext.OrganizationUser(organizationId)) + { + return Error.Unauthorized(); + } + + var providerId = await businessUnitConverter.FinalizeConversion( + organization, + requestBody.UserId, + requestBody.Token, + requestBody.ProviderKey, + requestBody.OrganizationKey); + + return TypedResults.Ok(providerId); + } } diff --git a/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs new file mode 100644 index 0000000000..c4b87a01f5 --- /dev/null +++ b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests; + +public class SetupBusinessUnitRequestBody +{ + [Required] + public Guid UserId { get; set; } + + [Required] + public string Token { get; set; } + + [Required] + public string ProviderKey { get; set; } + + [Required] + public string OrganizationKey { get; set; } +} diff --git a/src/Core/AdminConsole/Enums/Provider/ProviderType.cs b/src/Core/AdminConsole/Enums/Provider/ProviderType.cs index e244b9391e..8e229ed508 100644 --- a/src/Core/AdminConsole/Enums/Provider/ProviderType.cs +++ b/src/Core/AdminConsole/Enums/Provider/ProviderType.cs @@ -8,6 +8,6 @@ public enum ProviderType : byte Msp = 0, [Display(ShortName = "Reseller", Name = "Reseller", Description = "Creates Bitwarden Portal page for client organization billing management", Order = 1000)] Reseller = 1, - [Display(ShortName = "MOE", Name = "Multi-organization Enterprises", Description = "Creates provider portal for multi-organization management", Order = 1)] - MultiOrganizationEnterprise = 2, + [Display(ShortName = "Business Unit", Name = "Business Unit", Description = "Creates provider portal for business unit management", Order = 1)] + BusinessUnit = 2, } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs index 23f4e1d57a..67565bad6d 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs @@ -17,4 +17,5 @@ public class ProviderUserProviderDetails public string Permissions { get; set; } public bool UseEvents { get; set; } public ProviderStatusType ProviderStatus { get; set; } + public ProviderType ProviderType { get; set; } } diff --git a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs index bea3c08a85..b2484bf632 100644 --- a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs +++ b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs @@ -7,5 +7,5 @@ public interface ICreateProviderCommand { Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats); Task CreateResellerAsync(Provider provider); - Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats); + Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats); } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 4fb97e1db7..c8a1496726 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -25,19 +25,19 @@ public static class BillingExtensions public static bool IsBillable(this Provider provider) => provider is { - Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Type: ProviderType.Msp or ProviderType.BusinessUnit, Status: ProviderStatusType.Billable }; public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) => inviteOrganizationProvider is { - Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Type: ProviderType.Msp or ProviderType.BusinessUnit, Status: ProviderStatusType.Billable }; public static bool SupportsConsolidatedBilling(this ProviderType providerType) - => providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise; + => providerType is ProviderType.Msp or ProviderType.BusinessUnit; public static bool IsValidClient(this Organization organization) => organization is diff --git a/src/Core/Billing/Services/IBusinessUnitConverter.cs b/src/Core/Billing/Services/IBusinessUnitConverter.cs new file mode 100644 index 0000000000..06ff883eae --- /dev/null +++ b/src/Core/Billing/Services/IBusinessUnitConverter.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using OneOf; + +namespace Bit.Core.Billing.Services; + +public interface IBusinessUnitConverter +{ + /// + /// Finalizes the process of converting the to a by + /// saving all the necessary key provided by the client and updating the 's subscription to a + /// provider subscription. + /// + /// The organization to convert to a business unit. + /// The ID of the organization member who will be the provider admin. + /// The token sent to the client as part of the process. + /// The encrypted provider key used to enable the . + /// The encrypted organization key used to enable the . + /// The provider ID + Task FinalizeConversion( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey); + + /// + /// Begins the process of converting the to a by + /// creating all the necessary database entities and sending a setup invitation to the . + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + /// Either the newly created provider ID or a list of validation failures. + Task>> InitiateConversion( + Organization organization, + string providerAdminEmail); + + /// + /// Checks if the has a business unit conversion in progress and, if it does, resends the + /// setup invitation to the provider admin. + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + Task ResendConversionInvite( + Organization organization, + string providerAdminEmail); + + /// + /// Checks if the has a business unit conversion in progress and, if it does, resets that conversion + /// by deleting all the database entities created as part of . + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + Task ResetConversion( + Organization organization, + string providerAdminEmail); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 33dec32e34..842c6b6341 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -147,6 +147,7 @@ public static class FeatureFlagKeys public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; + public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs new file mode 100644 index 0000000000..59da019839 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs @@ -0,0 +1,19 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ You have been invited to set up a new Business Unit Portal within Bitwarden. +
+
+
+ + Set Up Business Unit Portal Now + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs new file mode 100644 index 0000000000..b2973f32c2 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} + You have been invited to set up a new Business Unit Portal within Bitwarden. To continue, click the following link: + + {{{Url}}} +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs b/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs new file mode 100644 index 0000000000..328d37058b --- /dev/null +++ b/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Models.Mail.Billing; + +public class BusinessUnitConversionInviteModel : BaseMailModel +{ + public string OrganizationId { get; set; } + public string Email { get; set; } + public string Token { get; set; } + + public string Url => + $"{WebVaultUrl}/providers/setup-business-unit?organizationId={OrganizationId}&email={Email}&token={Token}"; +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 48e0464905..805c143173 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -70,6 +70,7 @@ public interface IMailService Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage); Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName); Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email); + Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email); Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email); Task SendProviderConfirmedEmailAsync(string providerName, string email); Task SendProviderUserRemoved(string providerName, string email); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 7bcf2c0ef5..81e17e7c6f 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Models.Mail; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; +using Bit.Core.Models.Mail.Billing; using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; using Bit.Core.SecretsManager.Models.Mail; @@ -949,6 +950,22 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email) + { + var message = CreateDefaultMessage("Set Up Business Unit", email); + var model = new BusinessUnitConversionInviteModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + OrganizationId = organization.Id.ToString(), + Email = WebUtility.UrlEncode(email), + Token = WebUtility.UrlEncode(token) + }; + await AddMessageContentAsync(message, "Billing.BusinessUnitConversionInvite", model); + message.Category = "BusinessUnitConversionInvite"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email) { var message = CreateDefaultMessage($"Join {providerName}", email); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index f6b27b0670..381af2fd1c 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -212,6 +212,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email) + { + return Task.FromResult(0); + } + public Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email) { return Task.FromResult(0); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index a5f4f0bd9d..26c782000e 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -332,7 +332,7 @@ public class OrganizationRepository : Repository PlanConstants.EnterprisePlanTypes.Concat(PlanConstants.TeamsPlanTypes), - ProviderType.MultiOrganizationEnterprise => PlanConstants.EnterprisePlanTypes, + ProviderType.BusinessUnit => PlanConstants.EnterprisePlanTypes, _ => [] }; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs index 6469ffc9ce..231f587429 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs @@ -35,6 +35,7 @@ public class ProviderUserProviderDetailsReadByUserIdStatusQuery : IQuery sutProvider) { // Arrange // Act - var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + var actual = await sutProvider.Sut.CreateBusinessUnit(model); // Assert Assert.NotNull(actual); await sutProvider.GetDependency() .Received(Quantity.Exactly(1)) - .CreateMultiOrganizationEnterpriseAsync( - Arg.Is(x => x.Type == ProviderType.MultiOrganizationEnterprise), + .CreateBusinessUnitAsync( + Arg.Is(x => x.Type == ProviderType.BusinessUnit), model.OwnerEmail, Arg.Is(y => y == model.Plan), model.EnterpriseSeatMinimum); @@ -102,16 +102,16 @@ public class ProvidersControllerTests [BitAutoData] [SutProviderCustomize] [Theory] - public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToExpectedPage_AfterCreatingProvider( - CreateMultiOrganizationEnterpriseProviderModel model, + public async Task CreateBusinessUnitAsync_RedirectsToExpectedPage_AfterCreatingProvider( + CreateBusinessUnitProviderModel model, Guid expectedProviderId, SutProvider sutProvider) { // Arrange sutProvider.GetDependency() .When(x => - x.CreateMultiOrganizationEnterpriseAsync( - Arg.Is(y => y.Type == ProviderType.MultiOrganizationEnterprise), + x.CreateBusinessUnitAsync( + Arg.Is(y => y.Type == ProviderType.BusinessUnit), model.OwnerEmail, Arg.Is(y => y == model.Plan), model.EnterpriseSeatMinimum)) @@ -122,7 +122,7 @@ public class ProvidersControllerTests }); // Act - var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + var actual = await sutProvider.Sut.CreateBusinessUnit(model); // Assert Assert.NotNull(actual);