mirror of
https://github.com/bitwarden/server.git
synced 2025-04-28 00:02:26 -05:00
Merge branch 'main' into add-docker-arm64-builds
This commit is contained in:
commit
849e61fb7c
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@ -20,12 +20,19 @@
|
|||||||
# Database Operations for database changes
|
# Database Operations for database changes
|
||||||
src/Sql/** @bitwarden/dept-dbops
|
src/Sql/** @bitwarden/dept-dbops
|
||||||
util/EfShared/** @bitwarden/dept-dbops
|
util/EfShared/** @bitwarden/dept-dbops
|
||||||
util/Migrator/** @bitwarden/dept-dbops
|
util/Migrator/** @bitwarden/team-platform-dev # The Platform team owns the Migrator project code
|
||||||
|
util/Migrator/DbScripts/** @bitwarden/dept-dbops
|
||||||
|
util/Migrator/DbScripts_finalization/** @bitwarden/dept-dbops
|
||||||
|
util/Migrator/DbScripts_transition/** @bitwarden/dept-dbops
|
||||||
|
util/Migrator/MySql/** @bitwarden/dept-dbops
|
||||||
util/MySqlMigrations/** @bitwarden/dept-dbops
|
util/MySqlMigrations/** @bitwarden/dept-dbops
|
||||||
util/PostgresMigrations/** @bitwarden/dept-dbops
|
util/PostgresMigrations/** @bitwarden/dept-dbops
|
||||||
util/SqlServerEFScaffold/** @bitwarden/dept-dbops
|
util/SqlServerEFScaffold/** @bitwarden/dept-dbops
|
||||||
util/SqliteMigrations/** @bitwarden/dept-dbops
|
util/SqliteMigrations/** @bitwarden/dept-dbops
|
||||||
|
|
||||||
|
# Shared util projects
|
||||||
|
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
|
||||||
|
|
||||||
# Auth team
|
# Auth team
|
||||||
**/Auth @bitwarden/team-auth-dev
|
**/Auth @bitwarden/team-auth-dev
|
||||||
bitwarden_license/src/Sso @bitwarden/team-auth-dev
|
bitwarden_license/src/Sso @bitwarden/team-auth-dev
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2025.3.3</Version>
|
<Version>2025.4.1</Version>
|
||||||
|
|
||||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test"
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -319,6 +321,10 @@ Global
|
|||||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
|
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@ -370,6 +376,7 @@ Global
|
|||||||
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
|
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||||
|
@ -35,7 +35,6 @@ public class ProviderBillingService(
|
|||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<ProviderBillingService> logger,
|
ILogger<ProviderBillingService> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IPaymentService paymentService,
|
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
@ -148,36 +147,29 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||||
{
|
{
|
||||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
var (provider, providerPlanId, newPlanType) = command;
|
||||||
|
|
||||||
if (plan == null)
|
var providerPlan = await providerPlanRepository.GetByIdAsync(providerPlanId);
|
||||||
|
|
||||||
|
if (providerPlan == null)
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Provider plan not found.");
|
throw new BadRequestException("Provider plan not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan.PlanType == command.NewPlan)
|
if (providerPlan.PlanType == newPlanType)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType);
|
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||||
var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan);
|
|
||||||
|
|
||||||
plan.PlanType = command.NewPlan;
|
var oldPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||||
await providerPlanRepository.ReplaceAsync(plan);
|
var newPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, newPlanType);
|
||||||
|
|
||||||
Subscription subscription;
|
providerPlan.PlanType = newPlanType;
|
||||||
try
|
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||||
{
|
|
||||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
throw new ConflictException("Subscription not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => x.Price.Id == oldPriceId);
|
||||||
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
|
||||||
|
|
||||||
var updateOptions = new SubscriptionUpdateOptions
|
var updateOptions = new SubscriptionUpdateOptions
|
||||||
{
|
{
|
||||||
@ -185,7 +177,7 @@ public class ProviderBillingService(
|
|||||||
[
|
[
|
||||||
new SubscriptionItemOptions
|
new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId,
|
Price = newPriceId,
|
||||||
Quantity = oldSubscriptionItem!.Quantity
|
Quantity = oldSubscriptionItem!.Quantity
|
||||||
},
|
},
|
||||||
new SubscriptionItemOptions
|
new SubscriptionItemOptions
|
||||||
@ -196,12 +188,14 @@ public class ProviderBillingService(
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, updateOptions);
|
||||||
|
|
||||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||||
// 2. Assign PlanType & PlanName to Organization
|
// 2. Assign PlanType & PlanName to Organization
|
||||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
|
||||||
|
|
||||||
|
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
|
||||||
|
|
||||||
foreach (var providerOrganization in providerOrganizations)
|
foreach (var providerOrganization in providerOrganizations)
|
||||||
{
|
{
|
||||||
@ -210,8 +204,8 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||||
}
|
}
|
||||||
organization.PlanType = command.NewPlan;
|
organization.PlanType = newPlanType;
|
||||||
organization.Plan = newPlanConfiguration.Name;
|
organization.Plan = newPlan.Name;
|
||||||
await organizationRepository.ReplaceAsync(organization);
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -405,7 +399,7 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||||
|
|
||||||
var update = CurrySeatScalingUpdate(
|
var scaleQuantityTo = CurrySeatScalingUpdate(
|
||||||
provider,
|
provider,
|
||||||
providerPlan,
|
providerPlan,
|
||||||
newlyAssignedSeatTotal);
|
newlyAssignedSeatTotal);
|
||||||
@ -428,9 +422,7 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
||||||
newlyAssignedSeatTotal > seatMinimum)
|
newlyAssignedSeatTotal > seatMinimum)
|
||||||
{
|
{
|
||||||
await update(
|
await scaleQuantityTo(newlyAssignedSeatTotal);
|
||||||
seatMinimum,
|
|
||||||
newlyAssignedSeatTotal);
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* Above the limit => Above the limit:
|
* Above the limit => Above the limit:
|
||||||
@ -439,9 +431,7 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
||||||
newlyAssignedSeatTotal > seatMinimum)
|
newlyAssignedSeatTotal > seatMinimum)
|
||||||
{
|
{
|
||||||
await update(
|
await scaleQuantityTo(newlyAssignedSeatTotal);
|
||||||
currentlyAssignedSeatTotal,
|
|
||||||
newlyAssignedSeatTotal);
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* Above the limit => Below the limit:
|
* Above the limit => Below the limit:
|
||||||
@ -450,9 +440,7 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
||||||
newlyAssignedSeatTotal <= seatMinimum)
|
newlyAssignedSeatTotal <= seatMinimum)
|
||||||
{
|
{
|
||||||
await update(
|
await scaleQuantityTo(seatMinimum);
|
||||||
currentlyAssignedSeatTotal,
|
|
||||||
seatMinimum);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -586,9 +574,11 @@ public class ProviderBillingService(
|
|||||||
throw new BillingException();
|
throw new BillingException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var priceId = ProviderPriceAdapter.GetActivePriceId(provider, providerPlan.PlanType);
|
||||||
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
|
Price = priceId,
|
||||||
Quantity = providerPlan.SeatMinimum
|
Quantity = providerPlan.SeatMinimum
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -654,43 +644,37 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||||
{
|
{
|
||||||
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
var (provider, updatedPlanConfigurations) = command;
|
||||||
|
|
||||||
|
if (updatedPlanConfigurations.Any(x => x.SeatsMinimum < 0))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Subscription subscription;
|
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||||
try
|
|
||||||
{
|
|
||||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
throw new ConflictException("Subscription not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
|
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||||
|
|
||||||
foreach (var newPlanConfiguration in command.Configuration)
|
foreach (var updatedPlanConfiguration in updatedPlanConfigurations)
|
||||||
{
|
{
|
||||||
|
var (updatedPlanType, updatedSeatMinimum) = updatedPlanConfiguration;
|
||||||
|
|
||||||
var providerPlan =
|
var providerPlan =
|
||||||
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
|
providerPlans.Single(providerPlan => providerPlan.PlanType == updatedPlanType);
|
||||||
|
|
||||||
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
|
if (providerPlan.SeatMinimum != updatedSeatMinimum)
|
||||||
{
|
{
|
||||||
var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan);
|
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, updatedPlanType);
|
||||||
|
|
||||||
var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId;
|
|
||||||
|
|
||||||
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
||||||
|
|
||||||
if (providerPlan.PurchasedSeats == 0)
|
if (providerPlan.PurchasedSeats == 0)
|
||||||
{
|
{
|
||||||
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
|
if (providerPlan.AllocatedSeats > updatedSeatMinimum)
|
||||||
{
|
{
|
||||||
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
|
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - updatedSeatMinimum;
|
||||||
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
@ -705,7 +689,7 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
Id = subscriptionItem.Id,
|
Id = subscriptionItem.Id,
|
||||||
Price = priceId,
|
Price = priceId,
|
||||||
Quantity = newPlanConfiguration.SeatsMinimum
|
Quantity = updatedSeatMinimum
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -713,9 +697,9 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
||||||
|
|
||||||
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
|
if (updatedSeatMinimum <= totalSeats)
|
||||||
{
|
{
|
||||||
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
|
providerPlan.PurchasedSeats = totalSeats - updatedSeatMinimum;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -724,12 +708,12 @@ public class ProviderBillingService(
|
|||||||
{
|
{
|
||||||
Id = subscriptionItem.Id,
|
Id = subscriptionItem.Id,
|
||||||
Price = priceId,
|
Price = priceId,
|
||||||
Quantity = newPlanConfiguration.SeatsMinimum
|
Quantity = updatedSeatMinimum
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
|
providerPlan.SeatMinimum = updatedSeatMinimum;
|
||||||
|
|
||||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||||
}
|
}
|
||||||
@ -737,23 +721,33 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
if (subscriptionItemOptionsList.Count > 0)
|
if (subscriptionItemOptionsList.Count > 0)
|
||||||
{
|
{
|
||||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
|
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Func<int, int, Task> CurrySeatScalingUpdate(
|
private Func<int, Task> CurrySeatScalingUpdate(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
ProviderPlan providerPlan,
|
ProviderPlan providerPlan,
|
||||||
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
|
int newlyAssignedSeats) => async newlySubscribedSeats =>
|
||||||
{
|
{
|
||||||
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
|
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||||
|
|
||||||
await paymentService.AdjustSeats(
|
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||||
provider,
|
|
||||||
plan,
|
var item = subscription.Items.First(item => item.Price.Id == priceId);
|
||||||
currentlySubscribedSeats,
|
|
||||||
newlySubscribedSeats);
|
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
Items = [
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Price = priceId,
|
||||||
|
Quantity = newlySubscribedSeats
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
|
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
|
||||||
? newlySubscribedSeats - providerPlan.SeatMinimum
|
? newlySubscribedSeats - providerPlan.SeatMinimum
|
||||||
|
@ -0,0 +1,133 @@
|
|||||||
|
// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
|
||||||
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.Billing;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
|
public static class ProviderPriceAdapter
|
||||||
|
{
|
||||||
|
public static class MSP
|
||||||
|
{
|
||||||
|
public static class Active
|
||||||
|
{
|
||||||
|
public const string Enterprise = "provider-portal-enterprise-monthly-2025";
|
||||||
|
public const string Teams = "provider-portal-teams-monthly-2025";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Legacy
|
||||||
|
{
|
||||||
|
public const string Enterprise = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||||
|
public const string Teams = "password-manager-provider-portal-teams-monthly-2024";
|
||||||
|
public static readonly List<string> List = [Enterprise, Teams];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class BusinessUnit
|
||||||
|
{
|
||||||
|
public static class Active
|
||||||
|
{
|
||||||
|
public const string Annually = "business-unit-portal-enterprise-annually-2025";
|
||||||
|
public const string Monthly = "business-unit-portal-enterprise-monthly-2025";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Legacy
|
||||||
|
{
|
||||||
|
public const string Annually = "password-manager-provider-portal-enterprise-annually-2024";
|
||||||
|
public const string Monthly = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||||
|
public static readonly List<string> List = [Annually, Monthly];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> and <paramref name="subscription"/> to determine
|
||||||
|
/// whether the <paramref name="provider"/> is on active or legacy pricing and then returns a Stripe price ID for the provided
|
||||||
|
/// <paramref name="planType"/> based on that determination.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||||
|
/// <param name="subscription">The provider's subscription.</param>
|
||||||
|
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||||
|
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||||
|
public static string GetPriceId(
|
||||||
|
Provider provider,
|
||||||
|
Subscription subscription,
|
||||||
|
PlanType planType)
|
||||||
|
{
|
||||||
|
var priceIds = subscription.Items.Select(item => item.Price.Id);
|
||||||
|
|
||||||
|
var invalidPlanType =
|
||||||
|
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||||
|
|
||||||
|
return provider.Type switch
|
||||||
|
{
|
||||||
|
ProviderType.Msp => MSP.Legacy.List.Intersect(priceIds).Any()
|
||||||
|
? planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Legacy.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Legacy.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
}
|
||||||
|
: planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
|
||||||
|
? planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Legacy.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
}
|
||||||
|
: planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
_ => throw new BillingException(
|
||||||
|
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> to return the active Stripe price ID for the provided
|
||||||
|
/// <paramref name="planType"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||||
|
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||||
|
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||||
|
public static string GetActivePriceId(
|
||||||
|
Provider provider,
|
||||||
|
PlanType planType)
|
||||||
|
{
|
||||||
|
var invalidPlanType =
|
||||||
|
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||||
|
|
||||||
|
return provider.Type switch
|
||||||
|
{
|
||||||
|
ProviderType.Msp => planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
ProviderType.MultiOrganizationEnterprise => planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
_ => throw new BillingException(
|
||||||
|
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,11 @@
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
|
|
||||||
namespace Bit.Scim.Models;
|
namespace Bit.Scim.Models;
|
||||||
|
|
||||||
@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel
|
|||||||
{
|
{
|
||||||
public ScimUserRequestModel()
|
public ScimUserRequestModel()
|
||||||
: base(false)
|
: base(false)
|
||||||
{ }
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
@ -25,6 +29,31 @@ public class ScimUserRequestModel : BaseScimUserModel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersRequest ToRequest(
|
||||||
|
ScimProviderType scimProvider,
|
||||||
|
InviteOrganization inviteOrganization,
|
||||||
|
DateTimeOffset performedAt)
|
||||||
|
{
|
||||||
|
var email = EmailForInvite(scimProvider);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || !Active)
|
||||||
|
{
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InviteOrganizationUsersRequest(
|
||||||
|
invites:
|
||||||
|
[
|
||||||
|
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
|
||||||
|
email: email,
|
||||||
|
externalId: ExternalIdForInvite()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty, // SCIM does not have a user id
|
||||||
|
performedAt: performedAt);
|
||||||
|
}
|
||||||
|
|
||||||
private string EmailForInvite(ScimProviderType scimProvider)
|
private string EmailForInvite(ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
var email = PrimaryEmail?.ToLowerInvariant();
|
var email = PrimaryEmail?.ToLowerInvariant();
|
||||||
|
@ -1,39 +1,99 @@
|
|||||||
using Bit.Core.Enums;
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Scim.Context;
|
using Bit.Scim.Context;
|
||||||
using Bit.Scim.Models;
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Users.Interfaces;
|
using Bit.Scim.Users.Interfaces;
|
||||||
|
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
|
||||||
|
|
||||||
namespace Bit.Scim.Users;
|
namespace Bit.Scim.Users;
|
||||||
|
|
||||||
public class PostUserCommand : IPostUserCommand
|
public class PostUserCommand(
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IOrganizationService organizationService,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IScimContext scimContext,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IPricingClient pricingClient)
|
||||||
|
: IPostUserCommand
|
||||||
{
|
{
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
|
||||||
private readonly IOrganizationService _organizationService;
|
|
||||||
private readonly IPaymentService _paymentService;
|
|
||||||
private readonly IScimContext _scimContext;
|
|
||||||
|
|
||||||
public PostUserCommand(
|
|
||||||
IOrganizationRepository organizationRepository,
|
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
|
||||||
IOrganizationService organizationService,
|
|
||||||
IPaymentService paymentService,
|
|
||||||
IScimContext scimContext)
|
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
|
||||||
_organizationUserRepository = organizationUserRepository;
|
{
|
||||||
_organizationService = organizationService;
|
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
|
||||||
_paymentService = paymentService;
|
}
|
||||||
_scimContext = scimContext;
|
|
||||||
|
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
|
||||||
|
ScimUserRequestModel model,
|
||||||
|
Guid organizationId,
|
||||||
|
ScimProviderType scimProvider)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization is null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
|
||||||
|
var request = model.ToRequest(
|
||||||
|
scimProvider: scimProvider,
|
||||||
|
inviteOrganization: new InviteOrganization(organization, plan),
|
||||||
|
performedAt: timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var orgUsers = await organizationUserRepository
|
||||||
|
.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
|
if (orgUsers.Any(existingUser =>
|
||||||
|
request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
throw new ConflictException("User already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
var invitedOrganizationUserId = result switch
|
||||||
|
{
|
||||||
|
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
|
||||||
|
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors
|
||||||
|
.Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null,
|
||||||
|
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
|
||||||
|
_ => throw new InvalidOperationException()
|
||||||
|
};
|
||||||
|
|
||||||
|
var organizationUser = invitedOrganizationUserId.HasValue
|
||||||
|
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return organizationUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
|
||||||
|
ScimUserRequestModel model,
|
||||||
|
Guid organizationId,
|
||||||
|
ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
var scimProvider = _scimContext.RequestScimProvider;
|
|
||||||
var invite = model.ToOrganizationUserInvite(scimProvider);
|
var invite = model.ToOrganizationUserInvite(scimProvider);
|
||||||
|
|
||||||
var email = invite.Emails.Single();
|
var email = invite.Emails.Single();
|
||||||
@ -44,7 +104,7 @@ public class PostUserCommand : IPostUserCommand
|
|||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||||
if (orgUserByEmail != null)
|
if (orgUserByEmail != null)
|
||||||
{
|
{
|
||||||
@ -57,13 +117,21 @@ public class PostUserCommand : IPostUserCommand
|
|||||||
throw new ConflictException();
|
throw new ConflictException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
|
||||||
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
||||||
|
|
||||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
|
||||||
invite, externalId);
|
EventSystemUser.SCIM,
|
||||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
invite,
|
||||||
|
externalId);
|
||||||
|
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ using Bit.Commercial.Core.Billing;
|
|||||||
using Bit.Commercial.Core.Billing.Models;
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
@ -115,6 +116,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
||||||
|
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
var existingPlan = new ProviderPlan
|
var existingPlan = new ProviderPlan
|
||||||
{
|
{
|
||||||
@ -132,10 +135,7 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
|
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(
|
|
||||||
Arg.Is(provider.GatewaySubscriptionId),
|
|
||||||
Arg.Is(provider.Id))
|
|
||||||
.Returns(new Subscription
|
.Returns(new Subscription
|
||||||
{
|
{
|
||||||
Id = provider.GatewaySubscriptionId,
|
Id = provider.GatewaySubscriptionId,
|
||||||
@ -158,7 +158,7 @@ public class ProviderBillingServiceTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
var command =
|
var command =
|
||||||
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId);
|
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
|
||||||
.Returns(StaticStore.GetPlan(command.NewPlan));
|
.Returns(StaticStore.GetPlan(command.NewPlan));
|
||||||
@ -170,6 +170,8 @@ public class ProviderBillingServiceTests
|
|||||||
await providerPlanRepository.Received(1)
|
await providerPlanRepository.Received(1)
|
||||||
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
|
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
await stripeAdapter.Received(1)
|
await stripeAdapter.Received(1)
|
||||||
.SubscriptionUpdateAsync(
|
.SubscriptionUpdateAsync(
|
||||||
Arg.Is(provider.GatewaySubscriptionId),
|
Arg.Is(provider.GatewaySubscriptionId),
|
||||||
@ -405,6 +407,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 50 seats currently assigned with a seat minimum of 100
|
// 50 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -427,11 +446,9 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
|
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
|
||||||
await sutProvider.GetDependency<IPaymentService>().DidNotReceiveWithAnyArgs().AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(
|
||||||
Arg.Any<Provider>(),
|
Arg.Any<string>(),
|
||||||
Arg.Any<Bit.Core.Models.StaticStore.Plan>(),
|
Arg.Any<SubscriptionUpdateOptions>());
|
||||||
Arg.Any<int>(),
|
|
||||||
Arg.Any<int>());
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
pPlan => pPlan.AllocatedSeats == 60));
|
pPlan => pPlan.AllocatedSeats == 60));
|
||||||
@ -474,6 +491,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 95 seats currently assigned with a seat minimum of 100
|
// 95 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -496,11 +530,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
|
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
providerPlan.SeatMinimum!.Value,
|
options =>
|
||||||
105);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == 105));
|
||||||
|
|
||||||
// 105 total seats - 100 minimum = 5 purchased seats
|
// 105 total seats - 100 minimum = 5 purchased seats
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -544,6 +579,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 110 seats currently assigned with a seat minimum of 100
|
// 110 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -566,11 +618,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 110 current + 10 seat scale up = 120 seats
|
// 110 current + 10 seat scale up = 120 seats
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
110,
|
options =>
|
||||||
120);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == 120));
|
||||||
|
|
||||||
// 120 total seats - 100 seat minimum = 20 purchased seats
|
// 120 total seats - 100 seat minimum = 20 purchased seats
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -614,6 +667,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 110 seats currently assigned with a seat minimum of 100
|
// 110 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -636,11 +706,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
|
||||||
|
|
||||||
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
|
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
110,
|
options =>
|
||||||
providerPlan.SeatMinimum!.Value);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == providerPlan.SeatMinimum!.Value));
|
||||||
|
|
||||||
// Being below the seat minimum means no purchased seats.
|
// Being below the seat minimum means no purchased seats.
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -977,6 +1048,7 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
Provider provider)
|
Provider provider)
|
||||||
{
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
provider.GatewaySubscriptionId = null;
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
var customer = new Customer
|
var customer = new Customer
|
||||||
@ -1020,9 +1092,6 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
.Returns(providerPlans);
|
.Returns(providerPlans);
|
||||||
|
|
||||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
|
||||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||||
|
|
||||||
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||||
@ -1045,9 +1114,9 @@ public class ProviderBillingServiceTests
|
|||||||
sub.Customer == "customer_id" &&
|
sub.Customer == "customer_id" &&
|
||||||
sub.DaysUntilDue == 30 &&
|
sub.DaysUntilDue == 30 &&
|
||||||
sub.Items.Count == 2 &&
|
sub.Items.Count == 2 &&
|
||||||
sub.Items.ElementAt(0).Price == teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId &&
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
sub.Items.ElementAt(0).Quantity == 100 &&
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
sub.Items.ElementAt(1).Price == enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId &&
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
sub.OffSession == true &&
|
sub.OffSession == true &&
|
||||||
@ -1069,8 +1138,7 @@ public class ProviderBillingServiceTests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.TeamsMonthly, -10),
|
(PlanType.TeamsMonthly, -10),
|
||||||
(PlanType.EnterpriseMonthly, 50)
|
(PlanType.EnterpriseMonthly, 50)
|
||||||
@ -1089,6 +1157,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1118,9 +1188,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
provider.Id).Returns(subscription);
|
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1137,8 +1205,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 30),
|
(PlanType.EnterpriseMonthly, 30),
|
||||||
(PlanType.TeamsMonthly, 20)
|
(PlanType.TeamsMonthly, 20)
|
||||||
@ -1170,6 +1237,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1199,7 +1268,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1216,8 +1285,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 70),
|
(PlanType.EnterpriseMonthly, 70),
|
||||||
(PlanType.TeamsMonthly, 50)
|
(PlanType.TeamsMonthly, 50)
|
||||||
@ -1249,6 +1317,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1278,7 +1348,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1295,8 +1365,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 60),
|
(PlanType.EnterpriseMonthly, 60),
|
||||||
(PlanType.TeamsMonthly, 60)
|
(PlanType.TeamsMonthly, 60)
|
||||||
@ -1322,6 +1391,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1351,7 +1422,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1368,8 +1439,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 80),
|
(PlanType.EnterpriseMonthly, 80),
|
||||||
(PlanType.TeamsMonthly, 80)
|
(PlanType.TeamsMonthly, 80)
|
||||||
@ -1401,6 +1471,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1430,7 +1502,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1447,8 +1519,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 70),
|
(PlanType.EnterpriseMonthly, 70),
|
||||||
(PlanType.TeamsMonthly, 30)
|
(PlanType.TeamsMonthly, 30)
|
||||||
|
@ -0,0 +1,151 @@
|
|||||||
|
using Bit.Commercial.Core.Billing;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Stripe;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Test.Billing;
|
||||||
|
|
||||||
|
public class ProviderPriceAdapterTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)]
|
||||||
|
[InlineData("password-manager-provider-portal-teams-monthly-2024", PlanType.TeamsMonthly)]
|
||||||
|
public void GetPriceId_MSP_Legacy_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.Msp
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||||
|
[InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)]
|
||||||
|
public void GetPriceId_MSP_Active_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.Msp
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("password-manager-provider-portal-enterprise-annually-2024", PlanType.EnterpriseAnnually)]
|
||||||
|
[InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)]
|
||||||
|
public void GetPriceId_BusinessUnit_Legacy_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.MultiOrganizationEnterprise
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)]
|
||||||
|
[InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||||
|
public void GetPriceId_BusinessUnit_Active_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.MultiOrganizationEnterprise
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||||
|
[InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)]
|
||||||
|
public void GetActivePriceId_MSP_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.Msp
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)]
|
||||||
|
[InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||||
|
public void GetActivePriceId_BusinessUnit_Succeeds(string priceId, PlanType planType)
|
||||||
|
{
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.MultiOrganizationEnterprise
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
|
||||||
|
|
||||||
|
Assert.Equal(result, priceId);
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,12 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Services;
|
||||||
using Bit.Scim.IntegrationTest.Factories;
|
using Bit.Scim.IntegrationTest.Factories;
|
||||||
using Bit.Scim.Models;
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Utilities;
|
using Bit.Scim.Utilities;
|
||||||
using Bit.Test.Common.Helpers;
|
using Bit.Test.Common.Helpers;
|
||||||
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Bit.Scim.IntegrationTest.Controllers.v2;
|
namespace Bit.Scim.IntegrationTest.Controllers.v2;
|
||||||
@ -276,9 +279,18 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task Post_Success()
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task Post_Success(bool isScimInviteUserOptimizationEnabled)
|
||||||
{
|
{
|
||||||
|
var localFactory = new ScimApplicationFactory();
|
||||||
|
localFactory.SubstituteService((IFeatureService featureService)
|
||||||
|
=> featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
|
||||||
|
.Returns(isScimInviteUserOptimizationEnabled));
|
||||||
|
|
||||||
|
localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
|
||||||
|
|
||||||
var email = "user5@example.com";
|
var email = "user5@example.com";
|
||||||
var displayName = "Test User 5";
|
var displayName = "Test User 5";
|
||||||
var externalId = "UE";
|
var externalId = "UE";
|
||||||
@ -306,7 +318,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||||
};
|
};
|
||||||
|
|
||||||
var context = await _factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
|
var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
||||||
|
|
||||||
@ -316,7 +328,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
|
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
|
||||||
|
|
||||||
var databaseContext = _factory.GetDatabaseContext();
|
var databaseContext = localFactory.GetDatabaseContext();
|
||||||
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
|
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ public class PostUserCommandTests
|
|||||||
ExternalId = externalId,
|
ExternalId = externalId,
|
||||||
Emails = emails,
|
Emails = emails,
|
||||||
Active = true,
|
Active = true,
|
||||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
Schemas = [ScimConstants.Scim2SchemaUser]
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
@ -39,13 +39,16 @@ public class PostUserCommandTests
|
|||||||
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationService>()
|
sutProvider.GetDependency<IOrganizationService>()
|
||||||
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
.InviteUserAsync(organizationId,
|
||||||
|
invitingUserId: null,
|
||||||
|
EventSystemUser.SCIM,
|
||||||
Arg.Is<OrganizationUserInvite>(i =>
|
Arg.Is<OrganizationUserInvite>(i =>
|
||||||
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
||||||
i.Type == OrganizationUserType.User &&
|
i.Type == OrganizationUserType.User &&
|
||||||
!i.Collections.Any() &&
|
!i.Collections.Any() &&
|
||||||
!i.Groups.Any() &&
|
!i.Groups.Any() &&
|
||||||
i.AccessSecretsManager), externalId)
|
i.AccessSecretsManager),
|
||||||
|
externalId)
|
||||||
.Returns(newUser);
|
.Returns(newUser);
|
||||||
|
|
||||||
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
||||||
|
@ -300,8 +300,7 @@ public class ProvidersController : Controller
|
|||||||
{
|
{
|
||||||
case ProviderType.Msp:
|
case ProviderType.Msp:
|
||||||
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
|
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
|
||||||
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
||||||
@ -314,15 +313,14 @@ public class ProvidersController : Controller
|
|||||||
|
|
||||||
// 1. Change the plan and take over any old values.
|
// 1. Change the plan and take over any old values.
|
||||||
var changeMoePlanCommand = new ChangeProviderPlanCommand(
|
var changeMoePlanCommand = new ChangeProviderPlanCommand(
|
||||||
|
provider,
|
||||||
existingMoePlan.Id,
|
existingMoePlan.Id,
|
||||||
model.Plan!.Value,
|
model.Plan!.Value);
|
||||||
provider.GatewaySubscriptionId);
|
|
||||||
await _providerBillingService.ChangePlan(changeMoePlanCommand);
|
await _providerBillingService.ChangePlan(changeMoePlanCommand);
|
||||||
|
|
||||||
// 2. Update the seat minimums.
|
// 2. Update the seat minimums.
|
||||||
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
|
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
|
||||||
]);
|
]);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Bit.Api.Auth.Models.Request;
|
using Bit.Api.Auth.Models.Request;
|
||||||
using Bit.Api.Auth.Models.Request.Accounts;
|
|
||||||
using Bit.Api.Models.Request;
|
using Bit.Api.Models.Request;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Core.Auth.Models.Api.Request;
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
@ -125,7 +124,7 @@ public class DevicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{identifier}/retrieve-keys")]
|
[HttpPost("{identifier}/retrieve-keys")]
|
||||||
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier, [FromBody] SecretVerificationRequestModel model)
|
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
|
||||||
{
|
{
|
||||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
|
||||||
@ -134,14 +133,7 @@ public class DevicesController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _userService.VerifySecretAsync(user, model.Secret))
|
|
||||||
{
|
|
||||||
await Task.Delay(2000);
|
|
||||||
throw new BadRequestException(string.Empty, "User verification failed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
|
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
|
||||||
|
|
||||||
if (device == null)
|
if (device == null)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
|
@ -8,6 +8,7 @@ using Bit.Api.Tools.Models.Request;
|
|||||||
using Bit.Api.Vault.Models.Request;
|
using Bit.Api.Vault.Models.Request;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.Auth.Entities;
|
using Bit.Core.Auth.Entities;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
@ -43,6 +44,7 @@ public class AccountsKeyManagementController : Controller
|
|||||||
_organizationUserValidator;
|
_organizationUserValidator;
|
||||||
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
||||||
_webauthnKeyValidator;
|
_webauthnKeyValidator;
|
||||||
|
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
|
||||||
|
|
||||||
public AccountsKeyManagementController(IUserService userService,
|
public AccountsKeyManagementController(IUserService userService,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
@ -57,7 +59,8 @@ public class AccountsKeyManagementController : Controller
|
|||||||
emergencyAccessValidator,
|
emergencyAccessValidator,
|
||||||
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
||||||
organizationUserValidator,
|
organizationUserValidator,
|
||||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator)
|
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
|
||||||
|
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
|
||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
@ -71,6 +74,7 @@ public class AccountsKeyManagementController : Controller
|
|||||||
_emergencyAccessValidator = emergencyAccessValidator;
|
_emergencyAccessValidator = emergencyAccessValidator;
|
||||||
_organizationUserValidator = organizationUserValidator;
|
_organizationUserValidator = organizationUserValidator;
|
||||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||||
|
_deviceValidator = deviceValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("regenerate-keys")]
|
[HttpPost("regenerate-keys")]
|
||||||
@ -109,6 +113,7 @@ public class AccountsKeyManagementController : Controller
|
|||||||
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
|
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
|
||||||
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
|
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
|
||||||
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
|
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
|
||||||
|
DeviceKeys = await _deviceValidator.ValidateAsync(user, model.AccountUnlockData.DeviceKeyUnlockData),
|
||||||
|
|
||||||
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
|
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
|
||||||
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
|
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
|
||||||
|
@ -3,6 +3,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations;
|
|||||||
using Bit.Api.Auth.Models.Request;
|
using Bit.Api.Auth.Models.Request;
|
||||||
using Bit.Api.Auth.Models.Request.Accounts;
|
using Bit.Api.Auth.Models.Request.Accounts;
|
||||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
|
|
||||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||||
|
|
||||||
@ -13,4 +14,5 @@ public class UnlockDataRequestModel
|
|||||||
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
|
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
|
||||||
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
|
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
|
||||||
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }
|
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }
|
||||||
|
public required IEnumerable<OtherDeviceKeysUpdateRequestModel> DeviceKeyUnlockData { get; set; }
|
||||||
}
|
}
|
||||||
|
53
src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
Normal file
53
src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
|
using Bit.Core.Auth.Utilities;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
|
||||||
|
namespace Bit.Api.KeyManagement.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Device implementation for <see cref="IRotationValidator{T,R}"/>
|
||||||
|
/// </summary>
|
||||||
|
public class DeviceRotationValidator : IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>
|
||||||
|
{
|
||||||
|
private readonly IDeviceRepository _deviceRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates a new <see cref="DeviceRotationValidator"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="deviceRepository">Retrieves all user <see cref="Device"/>s</param>
|
||||||
|
public DeviceRotationValidator(IDeviceRepository deviceRepository)
|
||||||
|
{
|
||||||
|
_deviceRepository = deviceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Device>> ValidateAsync(User user, IEnumerable<OtherDeviceKeysUpdateRequestModel> devices)
|
||||||
|
{
|
||||||
|
var result = new List<Device>();
|
||||||
|
|
||||||
|
var existingTrustedDevices = (await _deviceRepository.GetManyByUserIdAsync(user.Id)).Where(d => d.IsTrusted()).ToList();
|
||||||
|
if (existingTrustedDevices.Count == 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var existing in existingTrustedDevices)
|
||||||
|
{
|
||||||
|
var device = devices.FirstOrDefault(c => c.DeviceId == existing.Id);
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("All existing trusted devices must be included in the rotation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.EncryptedUserKey == null || device.EncryptedPublicKey == null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Rotated encryption keys must be provided for all devices that are trusted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(device.ToDevice(existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
@ -17,20 +17,20 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator<IEnumerable<
|
|||||||
|
|
||||||
public async Task<IEnumerable<WebAuthnLoginRotateKeyData>> ValidateAsync(User user, IEnumerable<WebAuthnLoginRotateKeyRequestModel> keysToRotate)
|
public async Task<IEnumerable<WebAuthnLoginRotateKeyData>> ValidateAsync(User user, IEnumerable<WebAuthnLoginRotateKeyRequestModel> keysToRotate)
|
||||||
{
|
{
|
||||||
// 2024-06: Remove after 3 releases, for backward compatibility
|
|
||||||
if (keysToRotate == null)
|
|
||||||
{
|
|
||||||
return new List<WebAuthnLoginRotateKeyData>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new List<WebAuthnLoginRotateKeyData>();
|
var result = new List<WebAuthnLoginRotateKeyData>();
|
||||||
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
||||||
if (existing == null || !existing.Any())
|
if (existing == null)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var ea in existing)
|
var validCredentials = existing.Where(credential => credential.SupportsPrf);
|
||||||
|
if (!validCredentials.Any())
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var ea in validCredentials)
|
||||||
{
|
{
|
||||||
var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id);
|
var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id);
|
||||||
if (keyToRotate == null)
|
if (keyToRotate == null)
|
||||||
|
@ -22,6 +22,7 @@ public class NotificationResponseModel : ResponseModel
|
|||||||
Title = notificationStatusDetails.Title;
|
Title = notificationStatusDetails.Title;
|
||||||
Body = notificationStatusDetails.Body;
|
Body = notificationStatusDetails.Body;
|
||||||
Date = notificationStatusDetails.RevisionDate;
|
Date = notificationStatusDetails.RevisionDate;
|
||||||
|
TaskId = notificationStatusDetails.TaskId;
|
||||||
ReadDate = notificationStatusDetails.ReadDate;
|
ReadDate = notificationStatusDetails.ReadDate;
|
||||||
DeletedDate = notificationStatusDetails.DeletedDate;
|
DeletedDate = notificationStatusDetails.DeletedDate;
|
||||||
}
|
}
|
||||||
@ -40,6 +41,8 @@ public class NotificationResponseModel : ResponseModel
|
|||||||
|
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
|
|
||||||
|
public Guid? TaskId { get; set; }
|
||||||
|
|
||||||
public DateTime? ReadDate { get; set; }
|
public DateTime? ReadDate { get; set; }
|
||||||
|
|
||||||
public DateTime? DeletedDate { get; set; }
|
public DateTime? DeletedDate { get; set; }
|
||||||
|
@ -31,7 +31,7 @@ using Bit.Core.Auth.Models.Data;
|
|||||||
using Bit.Core.Auth.Identity.TokenProviders;
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
using Bit.Core.Tools.ImportFeatures;
|
using Bit.Core.Tools.ImportFeatures;
|
||||||
using Bit.Core.Tools.ReportFeatures;
|
using Bit.Core.Tools.ReportFeatures;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
|
|
||||||
#if !OSS
|
#if !OSS
|
||||||
using Bit.Commercial.Core.SecretsManager;
|
using Bit.Commercial.Core.SecretsManager;
|
||||||
@ -168,6 +168,9 @@ public class Startup
|
|||||||
services
|
services
|
||||||
.AddScoped<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>,
|
.AddScoped<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>,
|
||||||
WebAuthnLoginKeyRotationValidator>();
|
WebAuthnLoginKeyRotationValidator>();
|
||||||
|
services
|
||||||
|
.AddScoped<IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>,
|
||||||
|
DeviceRotationValidator>();
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
services.AddBaseServices(globalSettings);
|
services.AddBaseServices(globalSettings);
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<UserSecretsId>bitwarden-Billing</UserSecretsId>
|
<UserSecretsId>bitwarden-Billing</UserSecretsId>
|
||||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Billing' " />
|
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Billing' " />
|
||||||
|
@ -87,8 +87,7 @@ public class Startup
|
|||||||
// TODO: no longer be required - see PM-1880
|
// TODO: no longer be required - see PM-1880
|
||||||
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
|
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
|
||||||
|
|
||||||
// Mvc
|
services.AddControllers(config =>
|
||||||
services.AddMvc(config =>
|
|
||||||
{
|
{
|
||||||
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
|
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
|
||||||
});
|
});
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "Index";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h2>Index</h2>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
|||||||
@model LoginModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Login";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="row justify-content-md-center">
|
|
||||||
<div class="col-4">
|
|
||||||
<p>Please enter your email address below to log in.</p>
|
|
||||||
<form asp-action="" method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="Email" class="sr-only">Email Address</label>
|
|
||||||
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
|
||||||
required autofocus>
|
|
||||||
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
|
||||||
<small class="form-text text-body-secondary">We'll email you a secure login link.</small>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-block" type="submit">Continue</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,14 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "Error";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
|
||||||
<p>
|
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
|
|
||||||
</p>
|
|
@ -1,41 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>@ViewData["Title"] | Bitwarden Billing Portal</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
|
|
||||||
integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
|
|
||||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"
|
|
||||||
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="~/styles/billing.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
|
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand" href="#"><i class="fa fa-lg fa-fw fa-shield"></i> Billing</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
|
|
||||||
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
|
||||||
<ul class="navbar-nav mr-auto">
|
|
||||||
<li class="nav-item active">
|
|
||||||
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="#">Link</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main role="main" class="container">
|
|
||||||
@RenderBody()
|
|
||||||
</main>
|
|
||||||
|
|
||||||
@RenderSection("Scripts", required: false)
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,3 +0,0 @@
|
|||||||
@using Bit.Billing
|
|
||||||
@using Bit.Billing.Models
|
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
|
@ -1,3 +0,0 @@
|
|||||||
@{
|
|
||||||
Layout = "_Layout";
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
.custom-select.input-validation-error ~ .invalid-feedback,
|
|
||||||
.custom-select.input-validation-error ~ .invalid-tooltip,
|
|
||||||
.form-control.input-validation-error ~ .invalid-feedback,
|
|
||||||
.form-control.input-validation-error ~ .invalid-tooltip {
|
|
||||||
display: block;
|
|
||||||
}
|
|
@ -313,5 +313,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
|||||||
UseSecretsManager = license.UseSecretsManager;
|
UseSecretsManager = license.UseSecretsManager;
|
||||||
SmSeats = license.SmSeats;
|
SmSeats = license.SmSeats;
|
||||||
SmServiceAccounts = license.SmServiceAccounts;
|
SmServiceAccounts = license.SmServiceAccounts;
|
||||||
|
UseRiskInsights = license.UseRiskInsights;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ public class OrganizationIntegration : ITableObject<Guid>
|
|||||||
public Guid OrganizationId { get; set; }
|
public Guid OrganizationId { get; set; }
|
||||||
public IntegrationType Type { get; set; }
|
public IntegrationType Type { get; set; }
|
||||||
public string? Configuration { get; set; }
|
public string? Configuration { get; set; }
|
||||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||||
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ public class OrganizationIntegrationConfiguration : ITableObject<Guid>
|
|||||||
public EventType EventType { get; set; }
|
public EventType EventType { get; set; }
|
||||||
public string? Configuration { get; set; }
|
public string? Configuration { get; set; }
|
||||||
public string? Template { get; set; }
|
public string? Template { get; set; }
|
||||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||||
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
namespace Bit.Core.AdminConsole.Errors;
|
namespace Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
public record Error<T>(string Message, T ErroredValue);
|
public record Error<T>(string Message, T ErroredValue);
|
||||||
|
|
||||||
|
public static class ErrorMappers
|
||||||
|
{
|
||||||
|
public static Error<B> ToError<A, B>(this Error<A> errorA, B erroredValue) => new(errorA.Message, erroredValue);
|
||||||
|
}
|
||||||
|
6
src/Core/AdminConsole/Errors/InvalidResultTypeError.cs
Normal file
6
src/Core/AdminConsole/Errors/InvalidResultTypeError.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
public record InvalidResultTypeError<T>(T Value) : Error<T>(Code, Value)
|
||||||
|
{
|
||||||
|
public const string Code = "Invalid result type.";
|
||||||
|
};
|
35
src/Core/AdminConsole/Models/Business/InviteOrganization.cs
Normal file
35
src/Core/AdminConsole/Models/Business/InviteOrganization.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
public record InviteOrganization
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; init; }
|
||||||
|
public int? Seats { get; init; }
|
||||||
|
public int? MaxAutoScaleSeats { get; init; }
|
||||||
|
public int? SmSeats { get; init; }
|
||||||
|
public int? SmMaxAutoScaleSeats { get; init; }
|
||||||
|
public Plan Plan { get; init; }
|
||||||
|
public string GatewayCustomerId { get; init; }
|
||||||
|
public string GatewaySubscriptionId { get; init; }
|
||||||
|
public bool UseSecretsManager { get; init; }
|
||||||
|
|
||||||
|
public InviteOrganization()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganization(Organization organization, Plan plan)
|
||||||
|
{
|
||||||
|
OrganizationId = organization.Id;
|
||||||
|
Seats = organization.Seats;
|
||||||
|
MaxAutoScaleSeats = organization.MaxAutoscaleSeats;
|
||||||
|
SmSeats = organization.SmSeats;
|
||||||
|
SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats;
|
||||||
|
Plan = plan;
|
||||||
|
GatewayCustomerId = organization.GatewayCustomerId;
|
||||||
|
GatewaySubscriptionId = organization.GatewaySubscriptionId;
|
||||||
|
UseSecretsManager = organization.UseSecretsManager;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Bit.Core.Models.Data.Organizations;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationConfigurationDetails
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid OrganizationIntegrationId { get; set; }
|
||||||
|
public IntegrationType IntegrationType { get; set; }
|
||||||
|
public EventType EventType { get; set; }
|
||||||
|
public string? Configuration { get; set; }
|
||||||
|
public string? IntegrationConfiguration { get; set; }
|
||||||
|
public string? Template { get; set; }
|
||||||
|
|
||||||
|
public JsonObject MergedConfiguration
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var integrationJson = IntegrationConfigurationJson;
|
||||||
|
|
||||||
|
foreach (var kvp in ConfigurationJson)
|
||||||
|
{
|
||||||
|
integrationJson[kvp.Key] = kvp.Value?.DeepClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return integrationJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject ConfigurationJson
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configuration = Configuration ?? string.Empty;
|
||||||
|
return JsonNode.Parse(configuration) as JsonObject ?? new JsonObject();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject IntegrationConfigurationJson
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var integration = IntegrationConfiguration ?? string.Empty;
|
||||||
|
return JsonNode.Parse(integration) as JsonObject ?? new JsonObject();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -148,7 +148,8 @@ public class SelfHostedOrganizationDetails : Organization
|
|||||||
LimitCollectionDeletion = LimitCollectionDeletion,
|
LimitCollectionDeletion = LimitCollectionDeletion,
|
||||||
LimitItemDeletion = LimitItemDeletion,
|
LimitItemDeletion = LimitItemDeletion,
|
||||||
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
|
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
|
||||||
Status = Status
|
Status = Status,
|
||||||
|
UseRiskInsights = UseRiskInsights,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -154,6 +154,12 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Custom users can not delete admins.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged)
|
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged)
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Member is not managed by the organization.");
|
throw new BadRequestException("Member is not managed by the organization.");
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public static class ErrorMapper
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the ErrorT to a Bit.Exception class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="error"></param>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static Exception MapToBitException<T>(Error<T> error) =>
|
||||||
|
error switch
|
||||||
|
{
|
||||||
|
UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message),
|
||||||
|
_ => new BadRequestException(error.Message)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This maps the ErrorT object to the Bit.Exception class.
|
||||||
|
///
|
||||||
|
/// This should be replaced by an IActionResult mapper when possible.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="errors"></param>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static Exception MapToBitException<T>(ICollection<Error<T>> errors) =>
|
||||||
|
errors switch
|
||||||
|
{
|
||||||
|
not null when errors.Count == 1 => MapToBitException(errors.First()),
|
||||||
|
not null when errors.Count > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))),
|
||||||
|
_ => new BadRequestException()
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "Failed to invite users";
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record NoUsersToInviteError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "No users to invite";
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record UserAlreadyExistsError(ScimInviteOrganizationUsersResponse Response) : Error<ScimInviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "User already exists";
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the contract for inviting organization users via SCIM (System for Cross-domain Identity Management).
|
||||||
|
/// Provides functionality for handling single email invitation requests within an organization context.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInviteOrganizationUsersCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an invitation to add an organization user via SCIM (System for Cross-domain Identity Management) system.
|
||||||
|
/// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value.
|
||||||
|
/// Success will be the successful return object.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">
|
||||||
|
/// Contains the details for inviting a single organization user via email.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>Response from InviteScimOrganiation<see cref="ScimInviteOrganizationUsersResponse"/></returns>
|
||||||
|
Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request);
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is for sending the invite to an organization user.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISendOrganizationInvitesCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This sends emails out to organization users for a given organization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"><see cref="SendInvitesRequest"/></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task SendInvitesAsync(SendInvitesRequest request);
|
||||||
|
}
|
@ -0,0 +1,282 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Enums;
|
||||||
|
using Bit.Core.Tools.Models.Business;
|
||||||
|
using Bit.Core.Tools.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersCommand(IEventService eventService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IInviteUsersValidator inviteUsersValidator,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IReferenceEventService referenceEventService,
|
||||||
|
ICurrentContext currentContext,
|
||||||
|
IApplicationCacheService applicationCacheService,
|
||||||
|
IMailService mailService,
|
||||||
|
ILogger<InviteOrganizationUsersCommand> logger,
|
||||||
|
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||||
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
||||||
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
|
IProviderUserRepository providerUserRepository
|
||||||
|
) : IInviteOrganizationUsersCommand
|
||||||
|
{
|
||||||
|
|
||||||
|
public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached.";
|
||||||
|
|
||||||
|
public async Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var result = await InviteOrganizationUsersAsync(request);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case Failure<InviteOrganizationUsersResponse> failure:
|
||||||
|
return new Failure<ScimInviteOrganizationUsersResponse>(
|
||||||
|
failure.Errors.Select(error => new Error<ScimInviteOrganizationUsersResponse>(error.Message,
|
||||||
|
new ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault()
|
||||||
|
})));
|
||||||
|
|
||||||
|
case Success<InviteOrganizationUsersResponse> success when success.Value.InvitedUsers.Any():
|
||||||
|
var user = success.Value.InvitedUsers.First();
|
||||||
|
|
||||||
|
await eventService.LogOrganizationUserEventAsync<IOrganizationUser>(
|
||||||
|
organizationUser: user,
|
||||||
|
type: EventType.OrganizationUser_Invited,
|
||||||
|
systemUser: EventSystemUser.SCIM,
|
||||||
|
date: request.PerformedAt.UtcDateTime);
|
||||||
|
|
||||||
|
return new Success<ScimInviteOrganizationUsersResponse>(new ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
InvitedUser = user
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new Failure<ScimInviteOrganizationUsersResponse>(
|
||||||
|
new InvalidResultTypeError<ScimInviteOrganizationUsersResponse>(
|
||||||
|
new ScimInviteOrganizationUsersResponse()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult<InviteOrganizationUsersResponse>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray();
|
||||||
|
|
||||||
|
if (invitesToSend.Length == 0)
|
||||||
|
{
|
||||||
|
return new Failure<InviteOrganizationUsersResponse>(new NoUsersToInviteError(
|
||||||
|
new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
Invites = invitesToSend.ToArray(),
|
||||||
|
InviteOrganization = request.InviteOrganization,
|
||||||
|
PerformedBy = request.PerformedBy,
|
||||||
|
PerformedAt = request.PerformedAt,
|
||||||
|
OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId),
|
||||||
|
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationResult is Invalid<InviteOrganizationUsersValidationRequest> invalid)
|
||||||
|
{
|
||||||
|
return invalid.MapToFailure(r => new InviteOrganizationUsersResponse(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedRequest = validationResult as Valid<InviteOrganizationUsersValidationRequest>;
|
||||||
|
|
||||||
|
var organizationUserToInviteEntities = invitesToSend
|
||||||
|
.Select(x => x.MapToDataModel(request.PerformedAt, validatedRequest!.Value.InviteOrganization))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(validatedRequest!.Value.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await organizationUserRepository.CreateManyAsync(organizationUserToInviteEntities);
|
||||||
|
|
||||||
|
await AdjustPasswordManagerSeatsAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
await AdjustSecretsManagerSeatsAsync(validatedRequest);
|
||||||
|
|
||||||
|
await SendAdditionalEmailsAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
await SendInvitesAsync(organizationUserToInviteEntities, organization);
|
||||||
|
|
||||||
|
await PublishReferenceEventAsync(validatedRequest, organization);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, FailedToInviteUsersError.Code);
|
||||||
|
|
||||||
|
await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));
|
||||||
|
|
||||||
|
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
|
||||||
|
await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats);
|
||||||
|
|
||||||
|
await RevertPasswordManagerChangesAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
return new Failure<InviteOrganizationUsersResponse>(
|
||||||
|
new FailedToInviteUsersError(
|
||||||
|
new InviteOrganizationUsersResponse(validatedRequest.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Success<InviteOrganizationUsersResponse>(
|
||||||
|
new InviteOrganizationUsersResponse(
|
||||||
|
invitedOrganizationUsers: organizationUserToInviteEntities.Select(x => x.OrganizationUser).ToArray(),
|
||||||
|
organizationId: organization!.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<OrganizationUserInvite>> FilterExistingUsersAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var existingEmails = new HashSet<string>(await organizationUserRepository.SelectKnownEmailsAsync(
|
||||||
|
request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return request.Invites
|
||||||
|
.Where(invite => !existingEmails.Contains(invite.Email))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0)
|
||||||
|
{
|
||||||
|
// When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add.
|
||||||
|
// However, this might lead to a problem if we don't actually update stripe but throw any ways.
|
||||||
|
// stripe could not be updated, and then we would decrement the number of seats in stripe accidentally.
|
||||||
|
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd;
|
||||||
|
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove);
|
||||||
|
|
||||||
|
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
|
||||||
|
|
||||||
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
|
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertSecretsManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization, int? initialSmSeats)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
|
||||||
|
{
|
||||||
|
var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(
|
||||||
|
organization: organization,
|
||||||
|
plan: validatedResult.Value.InviteOrganization.Plan,
|
||||||
|
autoscaling: false)
|
||||||
|
{
|
||||||
|
SmSeats = initialSmSeats
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishReferenceEventAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult,
|
||||||
|
Organization organization) =>
|
||||||
|
await referenceEventService.RaiseEventAsync(
|
||||||
|
new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext)
|
||||||
|
{
|
||||||
|
Users = validatedResult.Value.Invites.Length
|
||||||
|
});
|
||||||
|
|
||||||
|
private async Task SendInvitesAsync(IEnumerable<CreateOrganizationUser> users, Organization organization) =>
|
||||||
|
await sendOrganizationInvitesCommand.SendInvitesAsync(
|
||||||
|
new SendInvitesRequest(
|
||||||
|
users.Select(x => x.OrganizationUser),
|
||||||
|
organization));
|
||||||
|
|
||||||
|
private async Task SendAdditionalEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ownerEmails = await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization);
|
||||||
|
|
||||||
|
await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
|
||||||
|
validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats!.Value, ownerEmails);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, IssueNotifyingOwnersOfSeatLimitReached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<string>> GetOwnerEmailAddressesAsync(InviteOrganization organization)
|
||||||
|
{
|
||||||
|
var providerOrganization = await providerOrganizationRepository
|
||||||
|
.GetByOrganizationId(organization.OrganizationId);
|
||||||
|
|
||||||
|
if (providerOrganization == null)
|
||||||
|
{
|
||||||
|
return (await organizationUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(organization.OrganizationId, OrganizationUserType.Owner))
|
||||||
|
.Select(x => x.Email)
|
||||||
|
.Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await providerUserRepository
|
||||||
|
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed))
|
||||||
|
.Select(u => u.Email).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdjustSecretsManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
|
||||||
|
{
|
||||||
|
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(validatedResult.Value.SecretsManagerSubscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
|
||||||
|
|
||||||
|
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
|
||||||
|
|
||||||
|
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
|
||||||
|
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
|
|
||||||
|
await referenceEventService.RaiseEventAsync(
|
||||||
|
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
|
||||||
|
{
|
||||||
|
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
|
||||||
|
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
|
||||||
|
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
|
||||||
|
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Object for associating the <see cref="OrganizationUser"/> with their assigned collections
|
||||||
|
/// <see cref="CollectionAccessSelection"/> and Group Ids.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateOrganizationUser
|
||||||
|
{
|
||||||
|
public OrganizationUser OrganizationUser { get; set; }
|
||||||
|
public CollectionAccessSelection[] Collections { get; set; } = [];
|
||||||
|
public Guid[] Groups { get; set; } = [];
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public static class CreateOrganizationUserExtensions
|
||||||
|
{
|
||||||
|
public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite,
|
||||||
|
DateTimeOffset performedAt,
|
||||||
|
InviteOrganization organization) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OrganizationUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.OrganizationId,
|
||||||
|
Email = organizationUserInvite.Email.ToLowerInvariant(),
|
||||||
|
Type = organizationUserInvite.Type,
|
||||||
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
AccessSecretsManager = organizationUserInvite.AccessSecretsManager,
|
||||||
|
ExternalId = string.IsNullOrWhiteSpace(organizationUserInvite.ExternalId) ? null : organizationUserInvite.ExternalId,
|
||||||
|
CreationDate = performedAt.UtcDateTime,
|
||||||
|
RevisionDate = performedAt.UtcDateTime
|
||||||
|
},
|
||||||
|
Collections = organizationUserInvite.AssignedCollections,
|
||||||
|
Groups = organizationUserInvite.Groups
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public static class InviteOrganizationUserErrorMessages
|
||||||
|
{
|
||||||
|
public const string InvalidEmailErrorMessage = "The email address is not valid.";
|
||||||
|
public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.";
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersRequest
|
||||||
|
{
|
||||||
|
public OrganizationUserInvite[] Invites { get; } = [];
|
||||||
|
public InviteOrganization InviteOrganization { get; }
|
||||||
|
public Guid PerformedBy { get; }
|
||||||
|
public DateTimeOffset PerformedAt { get; }
|
||||||
|
|
||||||
|
public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites,
|
||||||
|
InviteOrganization inviteOrganization,
|
||||||
|
Guid performedBy,
|
||||||
|
DateTimeOffset performedAt)
|
||||||
|
{
|
||||||
|
Invites = invites;
|
||||||
|
InviteOrganization = inviteOrganization;
|
||||||
|
PerformedBy = performedBy;
|
||||||
|
PerformedAt = performedAt;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersResponse(Guid organizationId)
|
||||||
|
{
|
||||||
|
public IEnumerable<OrganizationUser> InvitedUsers { get; } = [];
|
||||||
|
public Guid OrganizationId { get; } = organizationId;
|
||||||
|
|
||||||
|
public InviteOrganizationUsersResponse(InviteOrganizationUsersValidationRequest usersValidationRequest)
|
||||||
|
: this(usersValidationRequest.InviteOrganization.OrganizationId)
|
||||||
|
{
|
||||||
|
InvitedUsers = usersValidationRequest.Invites.Select(x => new OrganizationUser { Email = x.Email });
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersResponse(IEnumerable<OrganizationUser> invitedOrganizationUsers, Guid organizationId)
|
||||||
|
: this(organizationId)
|
||||||
|
{
|
||||||
|
InvitedUsers = invitedOrganizationUsers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
public OrganizationUser InvitedUser { get; init; }
|
||||||
|
|
||||||
|
public ScimInviteOrganizationUsersResponse()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScimInviteOrganizationUsersResponse(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var userToInvite = request.Invites.First();
|
||||||
|
|
||||||
|
InvitedUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Email = userToInvite.Email,
|
||||||
|
ExternalId = userToInvite.ExternalId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
public InviteOrganizationUsersValidationRequest()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request)
|
||||||
|
{
|
||||||
|
Invites = request.Invites;
|
||||||
|
InviteOrganization = request.InviteOrganization;
|
||||||
|
PerformedBy = request.PerformedBy;
|
||||||
|
PerformedAt = request.PerformedAt;
|
||||||
|
OccupiedPmSeats = request.OccupiedPmSeats;
|
||||||
|
OccupiedSmSeats = request.OccupiedSmSeats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request,
|
||||||
|
PasswordManagerSubscriptionUpdate subscriptionUpdate,
|
||||||
|
SecretsManagerSubscriptionUpdate smSubscriptionUpdate)
|
||||||
|
: this(request)
|
||||||
|
{
|
||||||
|
PasswordManagerSubscriptionUpdate = subscriptionUpdate;
|
||||||
|
SecretsManagerSubscriptionUpdate = smSubscriptionUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite[] Invites { get; init; } = [];
|
||||||
|
public InviteOrganization InviteOrganization { get; init; }
|
||||||
|
public Guid PerformedBy { get; init; }
|
||||||
|
public DateTimeOffset PerformedAt { get; init; }
|
||||||
|
public int OccupiedPmSeats { get; init; }
|
||||||
|
public int OccupiedSmSeats { get; init; }
|
||||||
|
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; }
|
||||||
|
public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; }
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class OrganizationUserInvite
|
||||||
|
{
|
||||||
|
public string Email { get; private init; }
|
||||||
|
public CollectionAccessSelection[] AssignedCollections { get; private init; }
|
||||||
|
public OrganizationUserType Type { get; private init; }
|
||||||
|
public Permissions Permissions { get; private init; }
|
||||||
|
public string ExternalId { get; private init; }
|
||||||
|
public bool AccessSecretsManager { get; private init; }
|
||||||
|
public Guid[] Groups { get; private init; }
|
||||||
|
|
||||||
|
public OrganizationUserInvite(string email, string externalId) :
|
||||||
|
this(
|
||||||
|
email: email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) :
|
||||||
|
this(invite.Email,
|
||||||
|
invite.AssignedCollections,
|
||||||
|
invite.Groups,
|
||||||
|
invite.Type,
|
||||||
|
invite.Permissions,
|
||||||
|
invite.ExternalId,
|
||||||
|
accessSecretsManager)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite(string email,
|
||||||
|
IEnumerable<CollectionAccessSelection> assignedCollections,
|
||||||
|
IEnumerable<Guid> groups,
|
||||||
|
OrganizationUserType type,
|
||||||
|
Permissions permissions,
|
||||||
|
string externalId,
|
||||||
|
bool accessSecretsManager)
|
||||||
|
{
|
||||||
|
ValidateEmailAddress(email);
|
||||||
|
|
||||||
|
var collections = assignedCollections?.ToArray() ?? [];
|
||||||
|
|
||||||
|
if (collections.Any(x => x.IsValidCollectionAccessConfiguration()))
|
||||||
|
{
|
||||||
|
throw new BadRequestException(InvalidCollectionConfigurationErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Email = email;
|
||||||
|
AssignedCollections = collections;
|
||||||
|
Groups = groups.ToArray();
|
||||||
|
Type = type;
|
||||||
|
Permissions = permissions ?? new Permissions();
|
||||||
|
ExternalId = externalId;
|
||||||
|
AccessSecretsManager = accessSecretsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateEmailAddress(string email)
|
||||||
|
{
|
||||||
|
if (!email.IsValidEmail())
|
||||||
|
{
|
||||||
|
throw new BadRequestException($"{email} {InvalidEmailErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to send invitations to a group of organization users.
|
||||||
|
/// </summary>
|
||||||
|
public class SendInvitesRequest
|
||||||
|
{
|
||||||
|
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization) =>
|
||||||
|
(Users, Organization) = (users.ToArray(), organization);
|
||||||
|
|
||||||
|
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization, bool initOrganization) =>
|
||||||
|
(Users, Organization, InitOrganization) = (users.ToArray(), organization, initOrganization);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Organization Users to send emails to.
|
||||||
|
/// </summary>
|
||||||
|
public OrganizationUser[] Users { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The organization to invite the users to.
|
||||||
|
/// </summary>
|
||||||
|
public Organization Organization { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is for when the organization is being created and this is the owners initial invite
|
||||||
|
/// </summary>
|
||||||
|
public bool InitOrganization { get; init; }
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Auth.Models.Business;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Auth.Repositories;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Mail;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
public class SendOrganizationInvitesCommand(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ISsoConfigRepository ssoConfigurationRepository,
|
||||||
|
IPolicyRepository policyRepository,
|
||||||
|
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||||
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
|
||||||
|
IMailService mailService) : ISendOrganizationInvitesCommand
|
||||||
|
{
|
||||||
|
public async Task SendInvitesAsync(SendInvitesRequest request)
|
||||||
|
{
|
||||||
|
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(request.Users, request.Organization, request.InitOrganization);
|
||||||
|
|
||||||
|
await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(IEnumerable<OrganizationUser> orgUsers,
|
||||||
|
Organization organization, bool initOrganization = false)
|
||||||
|
{
|
||||||
|
// Materialize the sequence into a list to avoid multiple enumeration warnings
|
||||||
|
var orgUsersList = orgUsers.ToList();
|
||||||
|
|
||||||
|
// Email links must include information about the org and user for us to make routing decisions client side
|
||||||
|
// Given an org user, determine if existing BW user exists
|
||||||
|
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
|
||||||
|
var existingUsers = await userRepository.GetManyByEmailsAsync(orgUserEmails);
|
||||||
|
|
||||||
|
// hash existing users emails list for O(1) lookups
|
||||||
|
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
|
||||||
|
|
||||||
|
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
|
||||||
|
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
|
||||||
|
ou => ou.Id,
|
||||||
|
ou => existingUserEmailsHashSet.Contains(ou.Email)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine if org has SSO enabled and if user is required to login with SSO
|
||||||
|
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
|
||||||
|
var orgSsoEnabled = organization.UseSso && (await ssoConfigurationRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
|
||||||
|
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
|
||||||
|
// need to check the policy if the org has SSO enabled.
|
||||||
|
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
||||||
|
organization.UsePolicies &&
|
||||||
|
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
||||||
|
|
||||||
|
// Generate the list of org users and expiring tokens
|
||||||
|
// create helper function to create expiring tokens
|
||||||
|
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
|
||||||
|
{
|
||||||
|
var orgUserInviteTokenable = orgUserInviteTokenableFactory.CreateToken(orgUser);
|
||||||
|
var protectedToken = dataProtectorTokenFactory.Protect(orgUserInviteTokenable);
|
||||||
|
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
|
||||||
|
|
||||||
|
return new OrganizationInvitesInfo(
|
||||||
|
organization,
|
||||||
|
orgSsoEnabled,
|
||||||
|
orgSsoLoginRequiredPolicyEnabled,
|
||||||
|
orgUsersWithExpTokens,
|
||||||
|
orgUserHasExistingUserDict,
|
||||||
|
initOrganization
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public static class CollectionAccessSelectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This validates the permissions on the given assigned collection
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsValidCollectionAccessConfiguration(this CollectionAccessSelection collectionAccessSelection) =>
|
||||||
|
collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public record CannotAutoScaleOnSelfHostError(EnvironmentRequest Invalid) : Error<EnvironmentRequest>(Code, Invalid)
|
||||||
|
{
|
||||||
|
public const string Code = "Cannot auto scale self-host.";
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public class EnvironmentRequest
|
||||||
|
{
|
||||||
|
public bool IsSelfHosted { get; init; }
|
||||||
|
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; init; }
|
||||||
|
|
||||||
|
public EnvironmentRequest(IGlobalSettings globalSettings, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate)
|
||||||
|
{
|
||||||
|
IsSelfHosted = globalSettings.SelfHosted;
|
||||||
|
PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public interface IInviteUsersEnvironmentValidator : IValidator<EnvironmentRequest>;
|
||||||
|
|
||||||
|
public class InviteUsersEnvironmentValidator : IInviteUsersEnvironmentValidator
|
||||||
|
{
|
||||||
|
public Task<ValidationResult<EnvironmentRequest>> ValidateAsync(EnvironmentRequest value) =>
|
||||||
|
Task.FromResult<ValidationResult<EnvironmentRequest>>(
|
||||||
|
value.IsSelfHosted && value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 ?
|
||||||
|
new Invalid<EnvironmentRequest>(new CannotAutoScaleOnSelfHostError(value)) :
|
||||||
|
new Valid<EnvironmentRequest>(value));
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public interface IInviteUsersValidator : IValidator<InviteOrganizationUsersValidationRequest>;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersValidator(
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator,
|
||||||
|
IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand,
|
||||||
|
IPaymentService paymentService) : IInviteUsersValidator
|
||||||
|
{
|
||||||
|
public async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateAsync(
|
||||||
|
InviteOrganizationUsersValidationRequest request)
|
||||||
|
{
|
||||||
|
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(request);
|
||||||
|
|
||||||
|
var passwordManagerValidationResult =
|
||||||
|
await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate);
|
||||||
|
|
||||||
|
if (passwordManagerValidationResult is Invalid<PasswordManagerSubscriptionUpdate> invalidSubscriptionUpdate)
|
||||||
|
{
|
||||||
|
return invalidSubscriptionUpdate.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the organization has the Secrets Manager Standalone Discount, all users are added to secrets manager.
|
||||||
|
// This is an expensive call, so we're doing it now to delay the check as long as possible.
|
||||||
|
if (await paymentService.HasSecretsManagerStandalone(request.InviteOrganization))
|
||||||
|
{
|
||||||
|
request = new InviteOrganizationUsersValidationRequest(request)
|
||||||
|
{
|
||||||
|
Invites = request.Invites
|
||||||
|
.Select(x => new OrganizationUserInvite(x, accessSecretsManager: true))
|
||||||
|
.ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.InviteOrganization.UseSecretsManager && request.Invites.Any(x => x.AccessSecretsManager))
|
||||||
|
{
|
||||||
|
return await ValidateSecretsManagerSubscriptionUpdateAsync(request, subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
|
||||||
|
request,
|
||||||
|
subscriptionUpdate,
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateSecretsManagerSubscriptionUpdateAsync(
|
||||||
|
InviteOrganizationUsersValidationRequest request,
|
||||||
|
PasswordManagerSubscriptionUpdate subscriptionUpdate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
|
||||||
|
var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(
|
||||||
|
organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId),
|
||||||
|
plan: request.InviteOrganization.Plan,
|
||||||
|
autoscaling: true);
|
||||||
|
|
||||||
|
var seatsToAdd = GetSecretManagerSeatAdjustment(request);
|
||||||
|
|
||||||
|
if (seatsToAdd > 0)
|
||||||
|
{
|
||||||
|
smSubscriptionUpdate.AdjustSeats(seatsToAdd);
|
||||||
|
|
||||||
|
await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
|
||||||
|
request,
|
||||||
|
subscriptionUpdate,
|
||||||
|
smSubscriptionUpdate));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationUsersValidationRequest>(
|
||||||
|
new Error<InviteOrganizationUsersValidationRequest>(ex.Message, request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This calculates the number of SM seats to add to the organization seat total.
|
||||||
|
///
|
||||||
|
/// If they have a current seat limit (it can be null), we want to figure out how many are available (seats -
|
||||||
|
/// occupied seats). Then, we'll subtract the available seats from the number of users we're trying to invite.
|
||||||
|
///
|
||||||
|
/// If it's negative, we have available seats and do not need to increase, so we go with 0.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
private static int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) =>
|
||||||
|
request.InviteOrganization.SmSeats.HasValue
|
||||||
|
? Math.Max(
|
||||||
|
request.Invites.Count(x => x.AccessSecretsManager) -
|
||||||
|
(request.InviteOrganization.SmSeats.Value -
|
||||||
|
request.OccupiedSmSeats),
|
||||||
|
0)
|
||||||
|
: 0;
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
|
||||||
|
public record OrganizationNoPaymentMethodFoundError(InviteOrganization InvalidRequest)
|
||||||
|
: Error<InviteOrganization>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "No payment method found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OrganizationNoSubscriptionFoundError(InviteOrganization InvalidRequest)
|
||||||
|
: Error<InviteOrganization>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "No subscription found.";
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
|
||||||
|
public interface IInviteUsersOrganizationValidator : IValidator<InviteOrganization>;
|
||||||
|
|
||||||
|
public class InviteUsersOrganizationValidator : IInviteUsersOrganizationValidator
|
||||||
|
{
|
||||||
|
public Task<ValidationResult<InviteOrganization>> ValidateAsync(InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
if (inviteOrganization.Seats is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Valid<InviteOrganization>(inviteOrganization));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewayCustomerId))
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Invalid<InviteOrganization>(new OrganizationNoPaymentMethodFoundError(inviteOrganization)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewaySubscriptionId))
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Invalid<InviteOrganization>(new OrganizationNoSubscriptionFoundError(inviteOrganization)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(new Valid<InviteOrganization>(inviteOrganization));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public record PasswordManagerSeatLimitHasBeenReachedError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerPlanDoesNotAllowAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Plan does not allow additional seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(GetErrorMessage(InvalidRequest), InvalidRequest)
|
||||||
|
{
|
||||||
|
private static string GetErrorMessage(PasswordManagerSubscriptionUpdate invalidRequest) =>
|
||||||
|
string.Format(Code, invalidRequest.PasswordManagerPlan.MaxAdditionalSeats);
|
||||||
|
|
||||||
|
public const string Code = "Organization plan allows a maximum of {0} additional seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerMustHaveSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "You do not have any Password Manager seats!";
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public interface IInviteUsersPasswordManagerValidator : IValidator<PasswordManagerSubscriptionUpdate>;
|
||||||
|
|
||||||
|
public class InviteUsersPasswordManagerValidator(
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator,
|
||||||
|
IInviteUsersOrganizationValidator inviteUsersOrganizationValidator,
|
||||||
|
IProviderRepository providerRepository,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IOrganizationRepository organizationRepository
|
||||||
|
) : IInviteUsersPasswordManagerValidator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This is for validating if the organization can add additional users.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subscriptionUpdate"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static ValidationResult<PasswordManagerSubscriptionUpdate> ValidatePasswordManager(PasswordManagerSubscriptionUpdate subscriptionUpdate)
|
||||||
|
{
|
||||||
|
if (subscriptionUpdate.Seats is null)
|
||||||
|
{
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.SeatsRequiredToAdd == 0)
|
||||||
|
{
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.PasswordManagerPlan.BaseSeats + subscriptionUpdate.SeatsRequiredToAdd <= 0)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.MaxSeatsReached)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.PasswordManagerPlan.HasAdditionalSeatsOption is false)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerPlanDoesNotAllowAdditionalSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apparently MaxAdditionalSeats is never set. Can probably be removed.
|
||||||
|
if (subscriptionUpdate.UpdatedSeatTotal - subscriptionUpdate.PasswordManagerPlan.BaseSeats > subscriptionUpdate.PasswordManagerPlan.MaxAdditionalSeats)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ValidationResult<PasswordManagerSubscriptionUpdate>> ValidateAsync(PasswordManagerSubscriptionUpdate request)
|
||||||
|
{
|
||||||
|
switch (ValidatePasswordManager(request))
|
||||||
|
{
|
||||||
|
case Valid<PasswordManagerSubscriptionUpdate> valid
|
||||||
|
when valid.Value.SeatsRequiredToAdd is 0:
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(request);
|
||||||
|
|
||||||
|
case Invalid<PasswordManagerSubscriptionUpdate> invalid:
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await inviteUsersEnvironmentValidator.ValidateAsync(new EnvironmentRequest(globalSettings, request)) is Invalid<EnvironmentRequest> invalidEnvironment)
|
||||||
|
{
|
||||||
|
return invalidEnvironment.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||||
|
|
||||||
|
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||||
|
{
|
||||||
|
return organizationValidation.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
|
||||||
|
if (provider is not null)
|
||||||
|
{
|
||||||
|
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
|
||||||
|
|
||||||
|
if (providerValidationResult is Invalid<InviteOrganizationProvider> invalidProviderValidation)
|
||||||
|
{
|
||||||
|
return invalidProviderValidation.Map(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var paymentSubscription = await paymentService.GetSubscriptionAsync(
|
||||||
|
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
|
||||||
|
|
||||||
|
var paymentValidationResult = InviteUserPaymentValidation.Validate(
|
||||||
|
new PaymentsSubscription(paymentSubscription, request.InviteOrganization));
|
||||||
|
|
||||||
|
if (paymentValidationResult is Invalid<PaymentsSubscription> invalidPaymentValidation)
|
||||||
|
{
|
||||||
|
return invalidPaymentValidation.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(request);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,89 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public class PasswordManagerSubscriptionUpdate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Seats the organization has
|
||||||
|
/// </summary>
|
||||||
|
public int? Seats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max number of seats that the organization can have
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxAutoScaleSeats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seats currently occupied by current users
|
||||||
|
/// </summary>
|
||||||
|
public int OccupiedSeats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Users to add to the organization seats
|
||||||
|
/// </summary>
|
||||||
|
public int NewUsersToAdd { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of seats available for users
|
||||||
|
/// </summary>
|
||||||
|
public int? AvailableSeats => Seats - OccupiedSeats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of seats to scale the organization by.
|
||||||
|
///
|
||||||
|
/// If Organization has no seat limit (Seats is null), then there are no new seats to add.
|
||||||
|
/// </summary>
|
||||||
|
public int SeatsRequiredToAdd => AvailableSeats.HasValue ? Math.Max(NewUsersToAdd - AvailableSeats.Value, 0) : 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// New total of seats for the organization
|
||||||
|
/// </summary>
|
||||||
|
public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If the new seat total is equal to the organization's auto-scale seat count
|
||||||
|
/// </summary>
|
||||||
|
public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value;
|
||||||
|
|
||||||
|
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
|
||||||
|
|
||||||
|
public InviteOrganization InviteOrganization { get; }
|
||||||
|
|
||||||
|
private PasswordManagerSubscriptionUpdate(int? organizationSeats,
|
||||||
|
int? organizationAutoScaleSeatLimit,
|
||||||
|
int currentSeats,
|
||||||
|
int newUsersToAdd,
|
||||||
|
Plan.PasswordManagerPlanFeatures plan,
|
||||||
|
InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
Seats = organizationSeats;
|
||||||
|
MaxAutoScaleSeats = organizationAutoScaleSeatLimit;
|
||||||
|
OccupiedSeats = currentSeats;
|
||||||
|
NewUsersToAdd = newUsersToAdd;
|
||||||
|
PasswordManagerPlan = plan;
|
||||||
|
InviteOrganization = inviteOrganization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PasswordManagerSubscriptionUpdate(InviteOrganization inviteOrganization, int occupiedSeats, int newUsersToAdd) :
|
||||||
|
this(
|
||||||
|
organizationSeats: inviteOrganization.Seats,
|
||||||
|
organizationAutoScaleSeatLimit: inviteOrganization.MaxAutoScaleSeats,
|
||||||
|
currentSeats: occupiedSeats,
|
||||||
|
newUsersToAdd: newUsersToAdd,
|
||||||
|
plan: inviteOrganization.Plan.PasswordManager,
|
||||||
|
inviteOrganization: inviteOrganization)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public PasswordManagerSubscriptionUpdate(InviteOrganizationUsersValidationRequest usersValidationRequest) :
|
||||||
|
this(
|
||||||
|
organizationSeats: usersValidationRequest.InviteOrganization.Seats,
|
||||||
|
organizationAutoScaleSeatLimit: usersValidationRequest.InviteOrganization.MaxAutoScaleSeats,
|
||||||
|
currentSeats: usersValidationRequest.OccupiedPmSeats,
|
||||||
|
newUsersToAdd: usersValidationRequest.Invites.Length,
|
||||||
|
plan: usersValidationRequest.InviteOrganization.Plan.PasswordManager,
|
||||||
|
inviteOrganization: usersValidationRequest.InviteOrganization)
|
||||||
|
{ }
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
|
|
||||||
|
public record PaymentCancelledSubscriptionError(PaymentsSubscription InvalidRequest)
|
||||||
|
: Error<PaymentsSubscription>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "You do not have an active subscription. Reinstate your subscription to make changes.";
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public static class InviteUserPaymentValidation
|
||||||
|
{
|
||||||
|
public static ValidationResult<PaymentsSubscription> Validate(PaymentsSubscription subscription)
|
||||||
|
{
|
||||||
|
if (subscription.ProductTierType is ProductTierType.Free)
|
||||||
|
{
|
||||||
|
return new Valid<PaymentsSubscription>(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled)
|
||||||
|
{
|
||||||
|
return new Invalid<PaymentsSubscription>(new PaymentCancelledSubscriptionError(subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PaymentsSubscription>(subscription);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
|
||||||
|
public class PaymentsSubscription
|
||||||
|
{
|
||||||
|
public ProductTierType ProductTierType { get; init; }
|
||||||
|
public string SubscriptionStatus { get; init; }
|
||||||
|
|
||||||
|
public PaymentsSubscription() { }
|
||||||
|
|
||||||
|
public PaymentsSubscription(SubscriptionInfo subscriptionInfo, InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty;
|
||||||
|
ProductTierType = inviteOrganization.Plan.ProductTier;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public record ProviderBillableSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached. Please contact your provider to add more seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProviderResellerSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached. Contact your provider to purchase additional seats.";
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public class InviteOrganizationProvider
|
||||||
|
{
|
||||||
|
public Guid ProviderId { get; init; }
|
||||||
|
public ProviderType Type { get; init; }
|
||||||
|
public ProviderStatusType Status { get; init; }
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public InviteOrganizationProvider(Entities.Provider.Provider provider)
|
||||||
|
{
|
||||||
|
ProviderId = provider.Id;
|
||||||
|
Type = provider.Type;
|
||||||
|
Status = provider.Status;
|
||||||
|
Enabled = provider.Enabled;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public static class InvitingUserOrganizationProviderValidator
|
||||||
|
{
|
||||||
|
public static ValidationResult<InviteOrganizationProvider> Validate(InviteOrganizationProvider inviteOrganizationProvider)
|
||||||
|
{
|
||||||
|
if (inviteOrganizationProvider is not { Enabled: true })
|
||||||
|
{
|
||||||
|
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteOrganizationProvider.IsBillable())
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationProvider>(new ProviderBillableSeatLimitError(inviteOrganizationProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteOrganizationProvider.Type == ProviderType.Reseller)
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationProvider>(new ProviderResellerSeatLimitError(inviteOrganizationProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
|
||||||
|
}
|
||||||
|
}
|
@ -25,7 +25,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
public const string UserNotFoundErrorMessage = "User not found.";
|
public const string UserNotFoundErrorMessage = "User not found.";
|
||||||
public const string UsersInvalidErrorMessage = "Users invalid.";
|
public const string UsersInvalidErrorMessage = "Users invalid.";
|
||||||
public const string RemoveYourselfErrorMessage = "You cannot remove yourself.";
|
public const string RemoveYourselfErrorMessage = "You cannot remove yourself.";
|
||||||
public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can delete other owners.";
|
public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can remove other owners.";
|
||||||
|
public const string RemoveAdminByCustomUserErrorMessage = "Custom users can not remove admins.";
|
||||||
public const string RemoveLastConfirmedOwnerErrorMessage = "Organization must have at least one confirmed owner.";
|
public const string RemoveLastConfirmedOwnerErrorMessage = "Organization must have at least one confirmed owner.";
|
||||||
public const string RemoveClaimedAccountErrorMessage = "Cannot remove member accounts claimed by the organization. To offboard a member, revoke or delete the account.";
|
public const string RemoveClaimedAccountErrorMessage = "Cannot remove member accounts claimed by the organization. To offboard a member, revoke or delete the account.";
|
||||||
|
|
||||||
@ -153,6 +154,11 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(orgUser.OrganizationId))
|
||||||
|
{
|
||||||
|
throw new BadRequestException(RemoveAdminByCustomUserErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
|
||||||
{
|
{
|
||||||
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });
|
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });
|
||||||
|
@ -16,7 +16,6 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUs
|
|||||||
public class RestoreOrganizationUserCommand(
|
public class RestoreOrganizationUserCommand(
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IFeatureService featureService,
|
|
||||||
IPushNotificationService pushNotificationService,
|
IPushNotificationService pushNotificationService,
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -41,8 +40,7 @@ public class RestoreOrganizationUserCommand(
|
|||||||
await RepositoryRestoreUserAsync(organizationUser);
|
await RepositoryRestoreUserAsync(organizationUser);
|
||||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||||
|
|
||||||
if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) &&
|
if (organizationUser.UserId.HasValue)
|
||||||
organizationUser.UserId.HasValue)
|
|
||||||
{
|
{
|
||||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||||
}
|
}
|
||||||
@ -54,8 +52,7 @@ public class RestoreOrganizationUserCommand(
|
|||||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
|
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
|
||||||
systemUser);
|
systemUser);
|
||||||
|
|
||||||
if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) &&
|
if (organizationUser.UserId.HasValue)
|
||||||
organizationUser.UserId.HasValue)
|
|
||||||
{
|
{
|
||||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||||
}
|
}
|
||||||
@ -87,7 +84,10 @@ public class RestoreOrganizationUserCommand(
|
|||||||
.twoFactorIsEnabled;
|
.twoFactorIsEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
|
if (organization.PlanType == PlanType.Free)
|
||||||
|
{
|
||||||
|
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
|
||||||
|
}
|
||||||
|
|
||||||
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
|
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
|
||||||
|
|
||||||
@ -100,7 +100,7 @@ public class RestoreOrganizationUserCommand(
|
|||||||
|
|
||||||
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
|
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
|
||||||
{
|
{
|
||||||
var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value);
|
var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId!.Value);
|
||||||
var otherOrgs = await organizationRepository.GetManyByUserIdAsync(organizationUser.UserId.Value);
|
var otherOrgs = await organizationRepository.GetManyByUserIdAsync(organizationUser.UserId.Value);
|
||||||
|
|
||||||
var orgOrgUserDict = relatedOrgUsersFromOtherOrgs
|
var orgOrgUserDict = relatedOrgUsersFromOtherOrgs
|
||||||
@ -110,13 +110,16 @@ public class RestoreOrganizationUserCommand(
|
|||||||
CheckForOtherFreeOrganizationOwnership(organizationUser, orgOrgUserDict);
|
CheckForOtherFreeOrganizationOwnership(organizationUser, orgOrgUserDict);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Dictionary<OrganizationUser, Organization>> GetRelatedOrganizationUsersAndOrganizations(
|
private async Task<Dictionary<OrganizationUser, Organization>> GetRelatedOrganizationUsersAndOrganizationsAsync(
|
||||||
IEnumerable<OrganizationUser> organizationUsers)
|
List<OrganizationUser> organizationUsers)
|
||||||
{
|
{
|
||||||
var allUserIds = organizationUsers.Select(x => x.UserId.Value);
|
var allUserIds = organizationUsers
|
||||||
|
.Where(x => x.UserId.HasValue)
|
||||||
|
.Select(x => x.UserId.Value);
|
||||||
|
|
||||||
var otherOrganizationUsers = (await organizationUserRepository.GetManyByManyUsersAsync(allUserIds))
|
var otherOrganizationUsers = (await organizationUserRepository.GetManyByManyUsersAsync(allUserIds))
|
||||||
.Where(x => organizationUsers.Any(y => y.Id == x.Id) == false);
|
.Where(x => organizationUsers.Any(y => y.Id == x.Id) == false)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var otherOrgs = await organizationRepository.GetManyByIdsAsync(otherOrganizationUsers
|
var otherOrgs = await organizationRepository.GetManyByIdsAsync(otherOrganizationUsers
|
||||||
.Select(x => x.OrganizationId)
|
.Select(x => x.OrganizationId)
|
||||||
@ -130,7 +133,9 @@ public class RestoreOrganizationUserCommand(
|
|||||||
Dictionary<OrganizationUser, Organization> otherOrgUsersAndOrgs)
|
Dictionary<OrganizationUser, Organization> otherOrgUsersAndOrgs)
|
||||||
{
|
{
|
||||||
var ownerOrAdminList = new[] { OrganizationUserType.Owner, OrganizationUserType.Admin };
|
var ownerOrAdminList = new[] { OrganizationUserType.Owner, OrganizationUserType.Admin };
|
||||||
if (otherOrgUsersAndOrgs.Any(x =>
|
|
||||||
|
if (ownerOrAdminList.Any(x => organizationUser.Type == x) &&
|
||||||
|
otherOrgUsersAndOrgs.Any(x =>
|
||||||
x.Key.UserId == organizationUser.UserId &&
|
x.Key.UserId == organizationUser.UserId &&
|
||||||
ownerOrAdminList.Any(userType => userType == x.Key.Type) &&
|
ownerOrAdminList.Any(userType => userType == x.Key.Type) &&
|
||||||
x.Key.Status == OrganizationUserStatusType.Confirmed &&
|
x.Key.Status == OrganizationUserStatusType.Confirmed &&
|
||||||
@ -170,7 +175,7 @@ public class RestoreOrganizationUserCommand(
|
|||||||
var organizationUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
|
var organizationUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
|
||||||
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
|
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
|
||||||
|
|
||||||
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizations(filteredUsers);
|
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers);
|
||||||
|
|
||||||
var result = new List<Tuple<OrganizationUser, string>>();
|
var result = new List<Tuple<OrganizationUser, string>>();
|
||||||
|
|
||||||
@ -201,15 +206,17 @@ public class RestoreOrganizationUserCommand(
|
|||||||
|
|
||||||
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
|
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
|
||||||
|
|
||||||
CheckForOtherFreeOrganizationOwnership(organizationUser, orgUsersAndOrgs);
|
if (organization.PlanType == PlanType.Free)
|
||||||
|
{
|
||||||
|
CheckForOtherFreeOrganizationOwnership(organizationUser, orgUsersAndOrgs);
|
||||||
|
}
|
||||||
|
|
||||||
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
|
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
|
||||||
|
|
||||||
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||||
organizationUser.Status = status;
|
organizationUser.Status = status;
|
||||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||||
if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) &&
|
if (organizationUser.UserId.HasValue)
|
||||||
organizationUser.UserId.HasValue)
|
|
||||||
{
|
{
|
||||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||||
}
|
}
|
||||||
|
@ -34,6 +34,8 @@ public class ResetPasswordPolicyRequirementFactory : BasePolicyRequirementFactor
|
|||||||
|
|
||||||
protected override IEnumerable<OrganizationUserType> ExemptRoles => [];
|
protected override IEnumerable<OrganizationUserType> ExemptRoles => [];
|
||||||
|
|
||||||
|
protected override IEnumerable<OrganizationUserStatusType> ExemptStatuses => [OrganizationUserStatusType.Revoked];
|
||||||
|
|
||||||
public override ResetPasswordPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
public override ResetPasswordPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
||||||
{
|
{
|
||||||
var result = policyDetails
|
var result = policyDetails
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations;
|
||||||
|
|
||||||
|
namespace Bit.Core.Repositories;
|
||||||
|
|
||||||
|
public interface IOrganizationIntegrationConfigurationRepository : IRepository<OrganizationIntegrationConfiguration, Guid>
|
||||||
|
{
|
||||||
|
Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
|
||||||
|
Guid organizationId,
|
||||||
|
IntegrationType integrationType,
|
||||||
|
EventType eventType);
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.Repositories;
|
||||||
|
|
||||||
|
public interface IOrganizationIntegrationRepository : IRepository<OrganizationIntegration, Guid>
|
||||||
|
{
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.KeyManagement.UserKey;
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
@ -68,4 +69,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
|||||||
/// <param name="role">The role to search for</param>
|
/// <param name="role">The role to search for</param>
|
||||||
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
|
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
|
||||||
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
|
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
|
||||||
|
|
||||||
|
Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection);
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,13 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
|||||||
using Bit.Core.AdminConsole.Models.Business;
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models.Business;
|
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
@ -26,18 +26,17 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Models.Mail;
|
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
using Bit.Core.Platform.Push;
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tokens;
|
|
||||||
using Bit.Core.Tools.Enums;
|
using Bit.Core.Tools.Enums;
|
||||||
using Bit.Core.Tools.Models.Business;
|
using Bit.Core.Tools.Models.Business;
|
||||||
using Bit.Core.Tools.Services;
|
using Bit.Core.Tools.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
|
|
||||||
namespace Bit.Core.Services;
|
namespace Bit.Core.Services;
|
||||||
|
|
||||||
@ -58,7 +57,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly IPaymentService _paymentService;
|
private readonly IPaymentService _paymentService;
|
||||||
private readonly IPolicyRepository _policyRepository;
|
private readonly IPolicyRepository _policyRepository;
|
||||||
private readonly IPolicyService _policyService;
|
private readonly IPolicyService _policyService;
|
||||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
|
||||||
private readonly ISsoUserRepository _ssoUserRepository;
|
private readonly ISsoUserRepository _ssoUserRepository;
|
||||||
private readonly IReferenceEventService _referenceEventService;
|
private readonly IReferenceEventService _referenceEventService;
|
||||||
private readonly IGlobalSettings _globalSettings;
|
private readonly IGlobalSettings _globalSettings;
|
||||||
@ -70,13 +68,12 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||||
private readonly IProviderRepository _providerRepository;
|
private readonly IProviderRepository _providerRepository;
|
||||||
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
|
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
|
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||||
|
|
||||||
public OrganizationService(
|
public OrganizationService(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -94,7 +91,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
IPolicyService policyService,
|
IPolicyService policyService,
|
||||||
ISsoConfigRepository ssoConfigRepository,
|
|
||||||
ISsoUserRepository ssoUserRepository,
|
ISsoUserRepository ssoUserRepository,
|
||||||
IReferenceEventService referenceEventService,
|
IReferenceEventService referenceEventService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
@ -104,15 +100,14 @@ public class OrganizationService : IOrganizationService
|
|||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
|
||||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IPolicyRequirementQuery policyRequirementQuery)
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -129,7 +124,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
_paymentService = paymentService;
|
_paymentService = paymentService;
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
_ssoConfigRepository = ssoConfigRepository;
|
|
||||||
_ssoUserRepository = ssoUserRepository;
|
_ssoUserRepository = ssoUserRepository;
|
||||||
_referenceEventService = referenceEventService;
|
_referenceEventService = referenceEventService;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
@ -141,13 +135,12 @@ public class OrganizationService : IOrganizationService
|
|||||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||||
_providerRepository = providerRepository;
|
_providerRepository = providerRepository;
|
||||||
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
|
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
|
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||||
@ -1055,74 +1048,14 @@ public class OrganizationService : IOrganizationService
|
|||||||
await SendInviteAsync(orgUser, org, initOrganization);
|
await SendInviteAsync(orgUser, org, initOrganization);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
|
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
|
||||||
{
|
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
|
||||||
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
|
|
||||||
|
|
||||||
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) =>
|
||||||
}
|
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
|
||||||
|
users: [orgUser],
|
||||||
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
|
organization: organization,
|
||||||
{
|
initOrganization: initOrganization));
|
||||||
// convert single org user into array of 1 org user
|
|
||||||
var orgUsers = new[] { orgUser };
|
|
||||||
|
|
||||||
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization);
|
|
||||||
|
|
||||||
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(
|
|
||||||
IEnumerable<OrganizationUser> orgUsers,
|
|
||||||
Organization organization,
|
|
||||||
bool initOrganization = false)
|
|
||||||
{
|
|
||||||
// Materialize the sequence into a list to avoid multiple enumeration warnings
|
|
||||||
var orgUsersList = orgUsers.ToList();
|
|
||||||
|
|
||||||
// Email links must include information about the org and user for us to make routing decisions client side
|
|
||||||
// Given an org user, determine if existing BW user exists
|
|
||||||
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
|
|
||||||
var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails);
|
|
||||||
|
|
||||||
// hash existing users emails list for O(1) lookups
|
|
||||||
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
|
|
||||||
|
|
||||||
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
|
|
||||||
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
|
|
||||||
ou => ou.Id,
|
|
||||||
ou => existingUserEmailsHashSet.Contains(ou.Email)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine if org has SSO enabled and if user is required to login with SSO
|
|
||||||
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
|
|
||||||
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
|
|
||||||
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
|
|
||||||
// need to check the policy if the org has SSO enabled.
|
|
||||||
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
|
||||||
organization.UsePolicies &&
|
|
||||||
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
|
||||||
|
|
||||||
// Generate the list of org users and expiring tokens
|
|
||||||
// create helper function to create expiring tokens
|
|
||||||
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
|
|
||||||
{
|
|
||||||
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
|
|
||||||
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
|
|
||||||
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
|
|
||||||
|
|
||||||
return new OrganizationInvitesInfo(
|
|
||||||
organization,
|
|
||||||
orgSsoEnabled,
|
|
||||||
orgSsoLoginRequiredPolicyEnabled,
|
|
||||||
orgUsersWithExpTokens,
|
|
||||||
orgUserHasExistingUserDict,
|
|
||||||
initOrganization
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
@ -1791,7 +1724,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
await RepositoryRevokeUserAsync(organizationUser);
|
await RepositoryRevokeUserAsync(organizationUser);
|
||||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
||||||
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
if (organizationUser.UserId.HasValue)
|
||||||
{
|
{
|
||||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||||
}
|
}
|
||||||
@ -1803,7 +1736,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
await RepositoryRevokeUserAsync(organizationUser);
|
await RepositoryRevokeUserAsync(organizationUser);
|
||||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser);
|
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser);
|
||||||
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
if (organizationUser.UserId.HasValue)
|
||||||
{
|
{
|
||||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||||
}
|
}
|
||||||
@ -1872,7 +1805,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
await _organizationUserRepository.RevokeAsync(organizationUser.Id);
|
await _organizationUserRepository.RevokeAsync(organizationUser.Id);
|
||||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
if (organizationUser.UserId.HasValue)
|
||||||
{
|
{
|
||||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||||
}
|
}
|
||||||
|
@ -6,10 +6,39 @@ public abstract record ValidationResult<T>;
|
|||||||
|
|
||||||
public record Valid<T> : ValidationResult<T>
|
public record Valid<T> : ValidationResult<T>
|
||||||
{
|
{
|
||||||
|
public Valid() { }
|
||||||
|
|
||||||
|
public Valid(T Value)
|
||||||
|
{
|
||||||
|
this.Value = Value;
|
||||||
|
}
|
||||||
|
|
||||||
public T Value { get; init; }
|
public T Value { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Invalid<T> : ValidationResult<T>
|
public record Invalid<T> : ValidationResult<T>
|
||||||
{
|
{
|
||||||
public IEnumerable<Error<T>> Errors { get; init; }
|
public IEnumerable<Error<T>> Errors { get; init; } = [];
|
||||||
|
|
||||||
|
public string ErrorMessageString => string.Join(" ", Errors.Select(e => e.Message));
|
||||||
|
|
||||||
|
public Invalid() { }
|
||||||
|
|
||||||
|
public Invalid(Error<T> error) : this([error]) { }
|
||||||
|
|
||||||
|
public Invalid(IEnumerable<Error<T>> errors)
|
||||||
|
{
|
||||||
|
Errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ValidationResultMappers
|
||||||
|
{
|
||||||
|
public static ValidationResult<B> Map<A, B>(this ValidationResult<A> validationResult, B invalidValue) =>
|
||||||
|
validationResult switch
|
||||||
|
{
|
||||||
|
Valid<A> => new Valid<B>(invalidValue),
|
||||||
|
Invalid<A> invalid => new Invalid<B>(invalid.Errors.Select(x => x.ToError(invalidValue))),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
namespace Bit.Core.Auth.Models.Api.Request;
|
namespace Bit.Core.Auth.Models.Api.Request;
|
||||||
@ -7,6 +8,13 @@ public class OtherDeviceKeysUpdateRequestModel : DeviceKeysUpdateRequestModel
|
|||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public Guid DeviceId { get; set; }
|
public Guid DeviceId { get; set; }
|
||||||
|
|
||||||
|
public Device ToDevice(Device existingDevice)
|
||||||
|
{
|
||||||
|
existingDevice.EncryptedPublicKey = EncryptedPublicKey;
|
||||||
|
existingDevice.EncryptedUserKey = EncryptedUserKey;
|
||||||
|
return existingDevice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeviceKeysUpdateRequestModel
|
public class DeviceKeysUpdateRequestModel
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Utilities;
|
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
|
||||||
@ -19,7 +18,7 @@ public class DeviceAuthRequestResponseModel : ResponseModel
|
|||||||
Type = deviceAuthDetails.Type,
|
Type = deviceAuthDetails.Type,
|
||||||
Identifier = deviceAuthDetails.Identifier,
|
Identifier = deviceAuthDetails.Identifier,
|
||||||
CreationDate = deviceAuthDetails.CreationDate,
|
CreationDate = deviceAuthDetails.CreationDate,
|
||||||
IsTrusted = deviceAuthDetails.IsTrusted()
|
IsTrusted = deviceAuthDetails.IsTrusted,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
|
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
|
||||||
|
@ -289,6 +289,12 @@ public class AuthRequestService : IAuthRequestService
|
|||||||
{
|
{
|
||||||
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
|
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
|
||||||
|
|
||||||
|
if (adminEmails.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("There are no admin emails to send to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(
|
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(
|
||||||
adminEmails,
|
adminEmails,
|
||||||
organizationUser.OrganizationId,
|
organizationUser.OrganizationId,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
@ -28,6 +29,13 @@ public static class BillingExtensions
|
|||||||
Status: ProviderStatusType.Billable
|
Status: ProviderStatusType.Billable
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) =>
|
||||||
|
inviteOrganizationProvider is
|
||||||
|
{
|
||||||
|
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
|
||||||
|
Status: ProviderStatusType.Billable
|
||||||
|
};
|
||||||
|
|
||||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||||
|
|
||||||
|
@ -36,6 +36,7 @@ public static class OrganizationLicenseConstants
|
|||||||
public const string SmServiceAccounts = nameof(SmServiceAccounts);
|
public const string SmServiceAccounts = nameof(SmServiceAccounts);
|
||||||
public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion);
|
public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion);
|
||||||
public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems);
|
public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems);
|
||||||
|
public const string UseRiskInsights = nameof(UseRiskInsights);
|
||||||
public const string Expires = nameof(Expires);
|
public const string Expires = nameof(Expires);
|
||||||
public const string Refresh = nameof(Refresh);
|
public const string Refresh = nameof(Refresh);
|
||||||
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
|
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
|
||||||
|
@ -47,6 +47,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
|
|||||||
new(nameof(OrganizationLicenseConstants.LimitCollectionCreationDeletion),
|
new(nameof(OrganizationLicenseConstants.LimitCollectionCreationDeletion),
|
||||||
(entity.LimitCollectionCreation || entity.LimitCollectionDeletion).ToString()),
|
(entity.LimitCollectionCreation || entity.LimitCollectionDeletion).ToString()),
|
||||||
new(nameof(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()),
|
new(nameof(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()),
|
||||||
|
new(nameof(OrganizationLicenseConstants.UseRiskInsights), entity.UseRiskInsights.ToString()),
|
||||||
new(nameof(OrganizationLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
|
new(nameof(OrganizationLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
|
||||||
new(nameof(OrganizationLicenseConstants.Expires), expires.ToString(CultureInfo.InvariantCulture)),
|
new(nameof(OrganizationLicenseConstants.Expires), expires.ToString(CultureInfo.InvariantCulture)),
|
||||||
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
|
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
@ -309,8 +309,7 @@ public class ProviderMigrator(
|
|||||||
.SeatMinimum ?? 0;
|
.SeatMinimum ?? 0;
|
||||||
|
|
||||||
var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
|
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
|
||||||
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
|
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
|
||||||
|
@ -75,6 +75,7 @@ public abstract record Plan
|
|||||||
// Seats
|
// Seats
|
||||||
public string StripePlanId { get; init; }
|
public string StripePlanId { get; init; }
|
||||||
public string StripeSeatPlanId { get; init; }
|
public string StripeSeatPlanId { get; init; }
|
||||||
|
[Obsolete("No longer used to retrieve a provider's price ID. Use ProviderPriceAdapter instead.")]
|
||||||
public string StripeProviderPortalSeatPlanId { get; init; }
|
public string StripeProviderPortalSeatPlanId { get; init; }
|
||||||
public decimal BasePrice { get; init; }
|
public decimal BasePrice { get; init; }
|
||||||
public decimal SeatPrice { get; init; }
|
public decimal SeatPrice { get; init; }
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Services.Contracts;
|
namespace Bit.Core.Billing.Services.Contracts;
|
||||||
|
|
||||||
public record ChangeProviderPlanCommand(
|
public record ChangeProviderPlanCommand(
|
||||||
|
Provider Provider,
|
||||||
Guid ProviderPlanId,
|
Guid ProviderPlanId,
|
||||||
PlanType NewPlan,
|
PlanType NewPlan);
|
||||||
string GatewaySubscriptionId);
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Services.Contracts;
|
namespace Bit.Core.Billing.Services.Contracts;
|
||||||
|
|
||||||
/// <param name="Id">The ID of the provider to update the seat minimums for.</param>
|
/// <param name="Provider">The provider to update the seat minimums for.</param>
|
||||||
/// <param name="Configuration">The new seat minimums for the provider.</param>
|
/// <param name="Configuration">The new seat minimums for the provider.</param>
|
||||||
public record UpdateProviderSeatMinimumsCommand(
|
public record UpdateProviderSeatMinimumsCommand(
|
||||||
Guid Id,
|
Provider Provider,
|
||||||
string GatewaySubscriptionId,
|
|
||||||
IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration);
|
IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration);
|
||||||
|
@ -42,7 +42,7 @@ public class OrganizationBillingService(
|
|||||||
|
|
||||||
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
||||||
? await CreateCustomerAsync(organization, customerSetup)
|
? await CreateCustomerAsync(organization, customerSetup)
|
||||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] });
|
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
|
||||||
|
|
||||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||||
|
|
||||||
|
@ -104,11 +104,11 @@ public static class FeatureFlagKeys
|
|||||||
/* Admin Console Team */
|
/* Admin Console Team */
|
||||||
public const string AccountDeprovisioning = "pm-10308-account-deprovisioning";
|
public const string AccountDeprovisioning = "pm-10308-account-deprovisioning";
|
||||||
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
||||||
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
|
|
||||||
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
|
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
|
||||||
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
|
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
|
||||||
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
||||||
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
||||||
|
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
|
||||||
|
|
||||||
/* Auth Team */
|
/* Auth Team */
|
||||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||||
@ -197,6 +197,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||||
public const string SecurityTasks = "security-tasks";
|
public const string SecurityTasks = "security-tasks";
|
||||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||||
|
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
|
||||||
|
|
||||||
public static List<string> GetAllKeys()
|
public static List<string> GetAllKeys()
|
||||||
{
|
{
|
||||||
|
@ -20,6 +20,7 @@ public class RotateUserAccountKeysData
|
|||||||
public IEnumerable<EmergencyAccess> EmergencyAccesses { get; set; }
|
public IEnumerable<EmergencyAccess> EmergencyAccesses { get; set; }
|
||||||
public IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
|
public IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
|
||||||
public IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }
|
public IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }
|
||||||
|
public IEnumerable<Device> DeviceKeys { get; set; }
|
||||||
|
|
||||||
// User vault data encrypted by the userkey
|
// User vault data encrypted by the userkey
|
||||||
public IEnumerable<Cipher> Ciphers { get; set; }
|
public IEnumerable<Cipher> Ciphers { get; set; }
|
||||||
|
@ -20,6 +20,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
private readonly ISendRepository _sendRepository;
|
private readonly ISendRepository _sendRepository;
|
||||||
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
|
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
private readonly IDeviceRepository _deviceRepository;
|
||||||
private readonly IPushNotificationService _pushService;
|
private readonly IPushNotificationService _pushService;
|
||||||
private readonly IdentityErrorDescriber _identityErrorDescriber;
|
private readonly IdentityErrorDescriber _identityErrorDescriber;
|
||||||
private readonly IWebAuthnCredentialRepository _credentialRepository;
|
private readonly IWebAuthnCredentialRepository _credentialRepository;
|
||||||
@ -42,6 +43,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository,
|
public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository,
|
||||||
ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository,
|
ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository,
|
||||||
IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository,
|
IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IDeviceRepository deviceRepository,
|
||||||
IPasswordHasher<User> passwordHasher,
|
IPasswordHasher<User> passwordHasher,
|
||||||
IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository)
|
IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository)
|
||||||
{
|
{
|
||||||
@ -52,6 +54,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
_sendRepository = sendRepository;
|
_sendRepository = sendRepository;
|
||||||
_emergencyAccessRepository = emergencyAccessRepository;
|
_emergencyAccessRepository = emergencyAccessRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
|
_deviceRepository = deviceRepository;
|
||||||
_pushService = pushService;
|
_pushService = pushService;
|
||||||
_identityErrorDescriber = errors;
|
_identityErrorDescriber = errors;
|
||||||
_credentialRepository = credentialRepository;
|
_credentialRepository = credentialRepository;
|
||||||
@ -127,6 +130,11 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys));
|
saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.DeviceKeys.Any())
|
||||||
|
{
|
||||||
|
saveEncryptedDataActions.Add(_deviceRepository.UpdateKeysForRotationAsync(user.Id, model.DeviceKeys));
|
||||||
|
}
|
||||||
|
|
||||||
await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
|
await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
|
||||||
await _pushService.PushLogOutAsync(user.Id);
|
await _pushService.PushLogOutAsync(user.Id);
|
||||||
return IdentityResult.Success;
|
return IdentityResult.Success;
|
||||||
|
@ -6,11 +6,8 @@
|
|||||||
<table border="0" cellpadding="0" cellspacing="0" width="100%"
|
<table border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||||
style="padding-left:30px; padding-right: 5px; padding-top: 20px;">
|
style="padding-left:30px; padding-right: 5px; padding-top: 20px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 24px; color: #ffffff; line-height: 32px; font-weight: 500; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 24px; color: #ffffff; line-height: 32px; font-weight: 500; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
{{OrgName}} has identified {{TaskCount}} critical {{plurality TaskCount "login" "logins"}} that {{plurality TaskCount "requires" "require"}} a password change
|
||||||
{{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless
|
|
||||||
TaskCountPlural}}s{{/unless}} a
|
|
||||||
password change
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
{{#>FullTextLayout}}
|
{{#>FullTextLayout}}
|
||||||
{{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless
|
{{OrgName}} has identified {{TaskCount}} critical {{plurality TaskCount "login" "logins"}} that {{plurality TaskCount "requires" "require"}} a password change
|
||||||
TaskCountPlural}}s{{/unless}} a
|
|
||||||
password change
|
|
||||||
|
|
||||||
{{>@partial-block}}
|
{{>@partial-block}}
|
||||||
|
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
using Bit.Core.Billing;
|
|
||||||
using Bit.Core.Billing.Enums;
|
|
||||||
using Bit.Core.Billing.Extensions;
|
|
||||||
using Stripe;
|
|
||||||
using Plan = Bit.Core.Models.StaticStore.Plan;
|
|
||||||
|
|
||||||
namespace Bit.Core.Models.Business;
|
|
||||||
|
|
||||||
public class ProviderSubscriptionUpdate : SubscriptionUpdate
|
|
||||||
{
|
|
||||||
private readonly string _planId;
|
|
||||||
private readonly int _previouslyPurchasedSeats;
|
|
||||||
private readonly int _newlyPurchasedSeats;
|
|
||||||
|
|
||||||
protected override List<string> PlanIds => [_planId];
|
|
||||||
|
|
||||||
public ProviderSubscriptionUpdate(
|
|
||||||
Plan plan,
|
|
||||||
int previouslyPurchasedSeats,
|
|
||||||
int newlyPurchasedSeats)
|
|
||||||
{
|
|
||||||
if (!plan.Type.SupportsConsolidatedBilling())
|
|
||||||
{
|
|
||||||
throw new BillingException(
|
|
||||||
message: $"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing");
|
|
||||||
}
|
|
||||||
|
|
||||||
_planId = plan.PasswordManager.StripeProviderPortalSeatPlanId;
|
|
||||||
_previouslyPurchasedSeats = previouslyPurchasedSeats;
|
|
||||||
_newlyPurchasedSeats = newlyPurchasedSeats;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
|
|
||||||
{
|
|
||||||
var subscriptionItem = FindSubscriptionItem(subscription, _planId);
|
|
||||||
|
|
||||||
return
|
|
||||||
[
|
|
||||||
new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Id = subscriptionItem.Id,
|
|
||||||
Price = _planId,
|
|
||||||
Quantity = _previouslyPurchasedSeats
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
|
|
||||||
{
|
|
||||||
var subscriptionItem = FindSubscriptionItem(subscription, _planId);
|
|
||||||
|
|
||||||
return
|
|
||||||
[
|
|
||||||
new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Id = subscriptionItem.Id,
|
|
||||||
Price = _planId,
|
|
||||||
Quantity = _newlyPurchasedSeats
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +1,7 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
using Bit.Core.AdminConsole.Errors;
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
namespace Bit.Core.Models.Commands;
|
namespace Bit.Core.Models.Commands;
|
||||||
|
|
||||||
@ -40,10 +41,23 @@ public class Success<T>(T value) : CommandResult<T>
|
|||||||
public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
|
public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
|
||||||
{
|
{
|
||||||
public List<string> ErrorMessages { get; } = errorMessages.ToList();
|
public List<string> ErrorMessages { get; } = errorMessages.ToList();
|
||||||
|
public Error<T>[] Errors { get; set; } = [];
|
||||||
|
|
||||||
public string ErrorMessage => string.Join(" ", ErrorMessages);
|
public string ErrorMessage => string.Join(" ", ErrorMessages);
|
||||||
|
|
||||||
public Failure(string error) : this([error]) { }
|
public Failure(string error) : this([error])
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Failure(IEnumerable<Error<T>> errors) : this(errors.Select(e => e.Message))
|
||||||
|
{
|
||||||
|
Errors = errors.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Failure(Error<T> error) : this([error.Message])
|
||||||
|
{
|
||||||
|
Errors = [error];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Partial<T> : CommandResult<T>
|
public class Partial<T> : CommandResult<T>
|
||||||
@ -57,3 +71,18 @@ public class Partial<T> : CommandResult<T>
|
|||||||
Failures = failedItems.ToArray();
|
Failures = failedItems.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class CommandResultExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This is to help map between the InvalidT ValidationResult and the FailureT CommandResult types.
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="invalidResult">This is the invalid type from validating the object.</param>
|
||||||
|
/// <param name="mappingFunction">This function will map between the two types for the inner ErrorT</param>
|
||||||
|
/// <typeparam name="A">Invalid object's type</typeparam>
|
||||||
|
/// <typeparam name="B">Failure object's type</typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static CommandResult<B> MapToFailure<A, B>(this Invalid<A> invalidResult, Func<A, B> mappingFunction) =>
|
||||||
|
new Failure<B>(invalidResult.Errors.Select(errorA => errorA.ToError(mappingFunction(errorA.ErroredValue))));
|
||||||
|
}
|
||||||
|
@ -6,8 +6,6 @@ public class SecurityTaskNotificationViewModel : BaseMailModel
|
|||||||
|
|
||||||
public int TaskCount { get; set; }
|
public int TaskCount { get; set; }
|
||||||
|
|
||||||
public bool TaskCountPlural => TaskCount != 1;
|
|
||||||
|
|
||||||
public List<string> AdminOwnerEmails { get; set; }
|
public List<string> AdminOwnerEmails { get; set; }
|
||||||
|
|
||||||
public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt";
|
public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt";
|
||||||
|
@ -56,6 +56,7 @@ public class NotificationPushNotification
|
|||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
public Guid? OrganizationId { get; set; }
|
public Guid? OrganizationId { get; set; }
|
||||||
public Guid? InstallationId { get; set; }
|
public Guid? InstallationId { get; set; }
|
||||||
|
public Guid? TaskId { get; set; }
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
public string? Body { get; set; }
|
public string? Body { get; set; }
|
||||||
public DateTime CreationDate { get; set; }
|
public DateTime CreationDate { get; set; }
|
||||||
|
@ -19,6 +19,7 @@ public class NotificationStatusDetails
|
|||||||
public string? Body { get; set; }
|
public string? Body { get; set; }
|
||||||
public DateTime CreationDate { get; set; }
|
public DateTime CreationDate { get; set; }
|
||||||
public DateTime RevisionDate { get; set; }
|
public DateTime RevisionDate { get; set; }
|
||||||
|
public Guid? TaskId { get; set; }
|
||||||
// Notification Status fields
|
// Notification Status fields
|
||||||
public DateTime? ReadDate { get; set; }
|
public DateTime? ReadDate { get; set; }
|
||||||
public DateTime? DeletedDate { get; set; }
|
public DateTime? DeletedDate { get; set; }
|
||||||
|
@ -212,6 +212,7 @@ public class NotificationHubPushNotificationService : IPushNotificationService
|
|||||||
UserId = notification.UserId,
|
UserId = notification.UserId,
|
||||||
OrganizationId = notification.OrganizationId,
|
OrganizationId = notification.OrganizationId,
|
||||||
InstallationId = installationId,
|
InstallationId = installationId,
|
||||||
|
TaskId = notification.TaskId,
|
||||||
Title = notification.Title,
|
Title = notification.Title,
|
||||||
Body = notification.Body,
|
Body = notification.Body,
|
||||||
CreationDate = notification.CreationDate,
|
CreationDate = notification.CreationDate,
|
||||||
@ -263,6 +264,7 @@ public class NotificationHubPushNotificationService : IPushNotificationService
|
|||||||
UserId = notification.UserId,
|
UserId = notification.UserId,
|
||||||
OrganizationId = notification.OrganizationId,
|
OrganizationId = notification.OrganizationId,
|
||||||
InstallationId = installationId,
|
InstallationId = installationId,
|
||||||
|
TaskId = notification.TaskId,
|
||||||
Title = notification.Title,
|
Title = notification.Title,
|
||||||
Body = notification.Body,
|
Body = notification.Body,
|
||||||
CreationDate = notification.CreationDate,
|
CreationDate = notification.CreationDate,
|
||||||
|
@ -13,6 +13,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||||
using Bit.Core.Models.Business.Tokenables;
|
using Bit.Core.Models.Business.Tokenables;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationCollections;
|
using Bit.Core.OrganizationFeatures.OrganizationCollections;
|
||||||
@ -174,6 +179,14 @@ public static class OrganizationServiceCollectionExtensions
|
|||||||
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
|
||||||
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
|
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
|
||||||
|
|
||||||
|
services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
|
||||||
|
services.AddScoped<ISendOrganizationInvitesCommand, SendOrganizationInvitesCommand>();
|
||||||
|
|
||||||
|
services.AddScoped<IInviteUsersValidator, InviteOrganizationUsersValidator>();
|
||||||
|
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
||||||
|
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
|
||||||
|
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user