mirror of
https://github.com/bitwarden/server.git
synced 2025-04-04 12:40:22 -05:00
[Core] Add BusinessUnitConverter
This commit is contained in:
parent
0df02d421d
commit
ad26e4f3b2
@ -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
|
||||
}
|
@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
|
||||
}
|
||||
}
|
||||
|
58
src/Core/Billing/Services/IBusinessUnitConverter.cs
Normal file
58
src/Core/Billing/Services/IBusinessUnitConverter.cs
Normal 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);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user