1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 20:50:21 -05:00

[Core] Add BusinessUnitConverter

This commit is contained in:
Alex Morask 2025-04-02 13:06:29 -04:00
parent 0df02d421d
commit ad26e4f3b2
No known key found for this signature in database
GPG Key ID: 23E38285B743E3A8
3 changed files with 518 additions and 0 deletions

View File

@ -0,0 +1,459 @@
#nullable enable
using System.Diagnostics.CodeAnalysis;
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;
public class BusinessUnitConverter(
IDataProtectionProvider dataProtectionProvider,
GlobalSettings globalSettings,
ILogger<BusinessUnitConverter> 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<Guid> 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<string, string>
{
[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<OneOf<Guid, List<string>>> 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<Task>
{
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<Provider, ProviderOrganization, ProviderUser, Task> 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<List<string>?> ValidateInitiationAsync(
Organization organization,
User? user)
{
var problems = new List<string>();
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
}

View File

@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
services.AddTransient<IProviderBillingService, ProviderBillingService>();
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
}
}

View File

@ -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
{
/// <summary>
/// Finalizes the process of converting the <paramref name="organization"/> to a <see cref="ProviderType.BusinessUnit"/> by
/// saving all the necessary key provided by the client and updating the <paramref name="organization"/>'s subscription to a
/// provider subscription.
/// </summary>
/// <param name="organization">The organization to convert to a business unit.</param>
/// <param name="userId">The ID of the organization member who will be the provider admin.</param>
/// <param name="token">The token sent to the client as part of the <see cref="InitiateConversion"/> process.</param>
/// <param name="providerKey">The encrypted provider key used to enable the <see cref="ProviderUser"/>.</param>
/// <param name="organizationKey">The encrypted organization key used to enable the <see cref="ProviderOrganization"/>.</param>
/// <returns>The provider ID</returns>
Task<Guid> FinalizeConversion(
Organization organization,
Guid userId,
string token,
string providerKey,
string organizationKey);
/// <summary>
/// Begins the process of converting the <paramref name="organization"/> to a <see cref="ProviderType.BusinessUnit"/> by
/// creating all the necessary database entities and sending a setup invitation to the <paramref name="providerAdminEmail"/>.
/// </summary>
/// <param name="organization">The organization to convert to a business unit.</param>
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
/// <returns>Either the newly created provider ID or a list of validation failures.</returns>
Task<OneOf<Guid, List<string>>> InitiateConversion(
Organization organization,
string providerAdminEmail);
/// <summary>
/// Checks if the <paramref name="organization"/> has a business unit conversion in progress and, if it does, resends the
/// setup invitation to the provider admin.
/// </summary>
/// <param name="organization">The organization to convert to a business unit.</param>
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
Task ResendConversionInvite(
Organization organization,
string providerAdminEmail);
/// <summary>
/// Checks if the <paramref name="organization"/> has a business unit conversion in progress and, if it does, resets that conversion
/// by deleting all the database entities created as part of <see cref="InitiateConversion"/>.
/// </summary>
/// <param name="organization">The organization to convert to a business unit.</param>
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
Task ResetConversion(
Organization organization,
string providerAdminEmail);
}