mirror of
https://github.com/bitwarden/server.git
synced 2025-04-26 07:12:20 -05:00
Merge branch 'main' into add-docker-arm64-builds
This commit is contained in:
commit
94853c3e6e
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@ -20,16 +20,25 @@
|
|||||||
# 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
|
||||||
src/Identity @bitwarden/team-auth-dev
|
src/Identity @bitwarden/team-auth-dev
|
||||||
|
src/Core/Identity @bitwarden/team-auth-dev
|
||||||
|
src/Core/IdentityServer @bitwarden/team-auth-dev
|
||||||
|
|
||||||
# Key Management team
|
# Key Management team
|
||||||
**/KeyManagement @bitwarden/team-key-management-dev
|
**/KeyManagement @bitwarden/team-key-management-dev
|
||||||
|
13
.github/renovate.json5
vendored
13
.github/renovate.json5
vendored
@ -9,6 +9,19 @@
|
|||||||
"nuget",
|
"nuget",
|
||||||
],
|
],
|
||||||
packageRules: [
|
packageRules: [
|
||||||
|
{
|
||||||
|
// Group all release-related workflows for GitHub Actions together for BRE.
|
||||||
|
groupName: "github-action",
|
||||||
|
matchManagers: ["github-actions"],
|
||||||
|
matchFileNames: [
|
||||||
|
".github/workflows/publish.yml",
|
||||||
|
".github/workflows/release.yml",
|
||||||
|
".github/workflows/repository-management.yml"
|
||||||
|
],
|
||||||
|
commitMessagePrefix: "[deps] BRE:",
|
||||||
|
reviewers: ["team:dept-bre"],
|
||||||
|
addLabels: ["hold"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
groupName: "dockerfile minor",
|
groupName: "dockerfile minor",
|
||||||
matchManagers: ["dockerfile"],
|
matchManagers: ["dockerfile"],
|
||||||
|
47
.github/workflows/build.yml
vendored
47
.github/workflows/build.yml
vendored
@ -523,56 +523,16 @@ jobs:
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
trigger-ee-updates:
|
setup-ephemeral-environment:
|
||||||
name: Trigger Ephemeral Environment updates
|
name: Setup Ephemeral Environment
|
||||||
if: |
|
|
||||||
needs.build-container.outputs.has_secrets == 'true'
|
|
||||||
&& github.event_name == 'pull_request'
|
|
||||||
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs: build-container
|
needs: build-container
|
||||||
steps:
|
|
||||||
- name: Log in to Azure - CI subscription
|
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
|
||||||
with:
|
|
||||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
|
||||||
|
|
||||||
- name: Retrieve GitHub PAT secrets
|
|
||||||
id: retrieve-secret-pat
|
|
||||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
|
||||||
with:
|
|
||||||
keyvault: "bitwarden-ci"
|
|
||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
|
||||||
|
|
||||||
- name: Trigger Ephemeral Environment update
|
|
||||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
|
||||||
with:
|
|
||||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
|
||||||
script: |
|
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
|
||||||
owner: 'bitwarden',
|
|
||||||
repo: 'devops',
|
|
||||||
workflow_id: '_update_ephemeral_tags.yml',
|
|
||||||
ref: 'main',
|
|
||||||
inputs: {
|
|
||||||
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
trigger-ephemeral-environment-sync:
|
|
||||||
name: Trigger Ephemeral Environment Sync
|
|
||||||
needs:
|
|
||||||
- build-container
|
|
||||||
- trigger-ee-updates
|
|
||||||
if: |
|
if: |
|
||||||
needs.build-container.outputs.has_secrets == 'true'
|
needs.build-container.outputs.has_secrets == 'true'
|
||||||
&& github.event_name == 'pull_request'
|
&& github.event_name == 'pull_request'
|
||||||
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
||||||
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
|
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
|
||||||
with:
|
with:
|
||||||
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
|
|
||||||
project: server
|
project: server
|
||||||
sync_environment: true
|
|
||||||
pull_request_number: ${{ github.event.number }}
|
pull_request_number: ${{ github.event.number }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
@ -587,8 +547,7 @@ jobs:
|
|||||||
- build-mssqlmigratorutility
|
- build-mssqlmigratorutility
|
||||||
- self-host-build
|
- self-host-build
|
||||||
- trigger-k8s-deploy
|
- trigger-k8s-deploy
|
||||||
- trigger-ee-updates
|
- setup-ephemeral-environment
|
||||||
- trigger-ephemeral-environment-sync
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check if any job failed
|
- name: Check if any job failed
|
||||||
if: |
|
if: |
|
||||||
|
36
.github/workflows/ephemeral-environment.yml
vendored
36
.github/workflows/ephemeral-environment.yml
vendored
@ -5,34 +5,12 @@ on:
|
|||||||
types: [labeled]
|
types: [labeled]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
trigger-ee-updates:
|
setup-ephemeral-environment:
|
||||||
name: Trigger Ephemeral Environment updates
|
name: Setup Ephemeral Environment
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
if: github.event.label.name == 'ephemeral-environment'
|
if: github.event.label.name == 'ephemeral-environment'
|
||||||
steps:
|
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
|
||||||
- name: Log in to Azure - CI subscription
|
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
|
||||||
with:
|
with:
|
||||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
project: server
|
||||||
|
pull_request_number: ${{ github.event.number }}
|
||||||
- name: Retrieve GitHub PAT secrets
|
sync_environment: true
|
||||||
id: retrieve-secret-pat
|
secrets: inherit
|
||||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
|
||||||
with:
|
|
||||||
keyvault: "bitwarden-ci"
|
|
||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
|
||||||
|
|
||||||
- name: Trigger Ephemeral Environment update
|
|
||||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
|
||||||
with:
|
|
||||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
|
||||||
script: |
|
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
|
||||||
owner: 'bitwarden',
|
|
||||||
repo: 'devops',
|
|
||||||
workflow_id: '_update_ephemeral_tags.yml',
|
|
||||||
ref: 'main',
|
|
||||||
inputs: {
|
|
||||||
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2025.3.3</Version>
|
<Version>2025.4.3</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}
|
||||||
|
@ -48,7 +48,7 @@ public class CreateProviderCommand : ICreateProviderCommand
|
|||||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
|
public async Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
|
||||||
{
|
{
|
||||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||||
|
|
||||||
|
@ -692,10 +692,10 @@ public class ProviderService : IProviderService
|
|||||||
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
|
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ProviderType.MultiOrganizationEnterprise:
|
case ProviderType.BusinessUnit:
|
||||||
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
|
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
|
||||||
{
|
{
|
||||||
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
throw new BadRequestException($"Business Unit Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ProviderType.Reseller:
|
case ProviderType.Reseller:
|
||||||
|
@ -0,0 +1,462 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Entities;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Billing.Repositories;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OneOf;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
|
[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)]
|
||||||
|
public class BusinessUnitConverter(
|
||||||
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
|
GlobalSettings globalSettings,
|
||||||
|
ILogger<BusinessUnitConverter> logger,
|
||||||
|
IMailService mailService,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IPricingClient pricingClient,
|
||||||
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
|
IProviderPlanRepository providerPlanRepository,
|
||||||
|
IProviderRepository providerRepository,
|
||||||
|
IProviderUserRepository providerUserRepository,
|
||||||
|
IStripeAdapter stripeAdapter,
|
||||||
|
ISubscriberService subscriberService,
|
||||||
|
IUserRepository userRepository) : IBusinessUnitConverter
|
||||||
|
{
|
||||||
|
private readonly IDataProtector _dataProtector =
|
||||||
|
dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector");
|
||||||
|
|
||||||
|
public async Task<Guid> FinalizeConversion(
|
||||||
|
Organization organization,
|
||||||
|
Guid userId,
|
||||||
|
string token,
|
||||||
|
string providerKey,
|
||||||
|
string organizationKey)
|
||||||
|
{
|
||||||
|
var user = await userRepository.GetByIdAsync(userId);
|
||||||
|
|
||||||
|
var (subscription, provider, providerOrganization, providerUser) = await ValidateFinalizationAsync(organization, user, token);
|
||||||
|
|
||||||
|
var existingPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
var updatedPlan = await pricingClient.GetPlanOrThrow(existingPlan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly);
|
||||||
|
|
||||||
|
// Bring organization under management.
|
||||||
|
organization.Plan = updatedPlan.Name;
|
||||||
|
organization.PlanType = updatedPlan.Type;
|
||||||
|
organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections;
|
||||||
|
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
|
||||||
|
organization.UsePolicies = updatedPlan.HasPolicies;
|
||||||
|
organization.UseSso = updatedPlan.HasSso;
|
||||||
|
organization.UseGroups = updatedPlan.HasGroups;
|
||||||
|
organization.UseEvents = updatedPlan.HasEvents;
|
||||||
|
organization.UseDirectory = updatedPlan.HasDirectory;
|
||||||
|
organization.UseTotp = updatedPlan.HasTotp;
|
||||||
|
organization.Use2fa = updatedPlan.Has2fa;
|
||||||
|
organization.UseApi = updatedPlan.HasApi;
|
||||||
|
organization.UseResetPassword = updatedPlan.HasResetPassword;
|
||||||
|
organization.SelfHost = updatedPlan.HasSelfHost;
|
||||||
|
organization.UsersGetPremium = updatedPlan.UsersGetPremium;
|
||||||
|
organization.UseCustomPermissions = updatedPlan.HasCustomPermissions;
|
||||||
|
organization.UseScim = updatedPlan.HasScim;
|
||||||
|
organization.UseKeyConnector = updatedPlan.HasKeyConnector;
|
||||||
|
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
|
||||||
|
organization.BillingEmail = provider.BillingEmail!;
|
||||||
|
organization.GatewayCustomerId = null;
|
||||||
|
organization.GatewaySubscriptionId = null;
|
||||||
|
organization.ExpirationDate = null;
|
||||||
|
organization.MaxAutoscaleSeats = null;
|
||||||
|
organization.Status = OrganizationStatusType.Managed;
|
||||||
|
|
||||||
|
// Enable organization access via key exchange.
|
||||||
|
providerOrganization.Key = organizationKey;
|
||||||
|
|
||||||
|
// Complete provider setup.
|
||||||
|
provider.Gateway = GatewayType.Stripe;
|
||||||
|
provider.GatewayCustomerId = subscription.CustomerId;
|
||||||
|
provider.GatewaySubscriptionId = subscription.Id;
|
||||||
|
provider.Status = ProviderStatusType.Billable;
|
||||||
|
|
||||||
|
// Enable provider access via key exchange.
|
||||||
|
providerUser.Key = providerKey;
|
||||||
|
providerUser.Status = ProviderUserStatusType.Confirmed;
|
||||||
|
|
||||||
|
// Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them.
|
||||||
|
await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||||
|
{
|
||||||
|
CustomFields = []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[StripeConstants.MetadataKeys.OrganizationId] = string.Empty,
|
||||||
|
[StripeConstants.MetadataKeys.ProviderId] = provider.Id.ToString(),
|
||||||
|
["convertedFrom"] = organization.Id.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||||
|
{
|
||||||
|
CustomFields = [
|
||||||
|
new CustomerInvoiceSettingsCustomFieldOptions
|
||||||
|
{
|
||||||
|
Name = provider.SubscriberType(),
|
||||||
|
Value = provider.DisplayName()?.Length <= 30
|
||||||
|
? provider.DisplayName()
|
||||||
|
: provider.DisplayName()?[..30]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
Metadata = metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the existing password manager price on the subscription.
|
||||||
|
var passwordManagerItem = subscription.Items.First(item =>
|
||||||
|
{
|
||||||
|
var priceId = existingPlan.HasNonSeatBasedPasswordManagerPlan()
|
||||||
|
? existingPlan.PasswordManager.StripePlanId
|
||||||
|
: existingPlan.PasswordManager.StripeSeatPlanId;
|
||||||
|
|
||||||
|
return item.Price.Id == priceId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the new business unit price.
|
||||||
|
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, updatedPlan.Type);
|
||||||
|
|
||||||
|
// Replace the existing password manager price with the new business unit price.
|
||||||
|
var updateSubscription =
|
||||||
|
stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
|
||||||
|
new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
Items = [
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = passwordManagerItem.Id,
|
||||||
|
Deleted = true
|
||||||
|
},
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Price = updatedPriceId,
|
||||||
|
Quantity = organization.Seats
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Metadata = metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(updateCustomer, updateSubscription);
|
||||||
|
|
||||||
|
// Complete database updates for provider setup.
|
||||||
|
await Task.WhenAll(
|
||||||
|
organizationRepository.ReplaceAsync(organization),
|
||||||
|
providerOrganizationRepository.ReplaceAsync(providerOrganization),
|
||||||
|
providerRepository.ReplaceAsync(provider),
|
||||||
|
providerUserRepository.ReplaceAsync(providerUser));
|
||||||
|
|
||||||
|
return provider.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OneOf<Guid, List<string>>> InitiateConversion(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
var user = await userRepository.GetByEmailAsync(providerAdminEmail);
|
||||||
|
|
||||||
|
var problems = await ValidateInitiationAsync(organization, user);
|
||||||
|
|
||||||
|
if (problems is { Count: > 0 })
|
||||||
|
{
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await providerRepository.CreateAsync(new Provider
|
||||||
|
{
|
||||||
|
Name = organization.Name,
|
||||||
|
BillingEmail = organization.BillingEmail,
|
||||||
|
Status = ProviderStatusType.Pending,
|
||||||
|
UseEvents = true,
|
||||||
|
Type = ProviderType.BusinessUnit
|
||||||
|
});
|
||||||
|
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
|
||||||
|
var managedPlanType = plan.IsAnnual
|
||||||
|
? PlanType.EnterpriseAnnually
|
||||||
|
: PlanType.EnterpriseMonthly;
|
||||||
|
|
||||||
|
var createProviderOrganization = providerOrganizationRepository.CreateAsync(new ProviderOrganization
|
||||||
|
{
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
OrganizationId = organization.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
var createProviderPlan = providerPlanRepository.CreateAsync(new ProviderPlan
|
||||||
|
{
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = managedPlanType,
|
||||||
|
SeatMinimum = 0,
|
||||||
|
PurchasedSeats = organization.Seats,
|
||||||
|
AllocatedSeats = organization.Seats
|
||||||
|
});
|
||||||
|
|
||||||
|
var createProviderUser = providerUserRepository.CreateAsync(new ProviderUser
|
||||||
|
{
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
UserId = user!.Id,
|
||||||
|
Email = user.Email,
|
||||||
|
Status = ProviderUserStatusType.Invited,
|
||||||
|
Type = ProviderUserType.ProviderAdmin
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(createProviderOrganization, createProviderPlan, createProviderUser);
|
||||||
|
|
||||||
|
await SendInviteAsync(organization, user.Email);
|
||||||
|
|
||||||
|
return provider.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ResendConversionInvite(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail) =>
|
||||||
|
IfConversionInProgressAsync(organization, providerAdminEmail,
|
||||||
|
async (_, _, providerUser) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(providerUser.Email))
|
||||||
|
{
|
||||||
|
await SendInviteAsync(organization, providerUser.Email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task ResetConversion(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail) =>
|
||||||
|
IfConversionInProgressAsync(organization, providerAdminEmail,
|
||||||
|
async (provider, providerOrganization, providerUser) =>
|
||||||
|
{
|
||||||
|
var tasks = new List<Task>
|
||||||
|
{
|
||||||
|
providerOrganizationRepository.DeleteAsync(providerOrganization),
|
||||||
|
providerUserRepository.DeleteAsync(providerUser)
|
||||||
|
};
|
||||||
|
|
||||||
|
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||||
|
|
||||||
|
if (providerPlans is { Count: > 0 })
|
||||||
|
{
|
||||||
|
tasks.AddRange(providerPlans.Select(providerPlanRepository.DeleteAsync));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
await providerRepository.DeleteAsync(provider);
|
||||||
|
});
|
||||||
|
|
||||||
|
#region Utilities
|
||||||
|
|
||||||
|
private async Task IfConversionInProgressAsync(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail,
|
||||||
|
Func<Provider, ProviderOrganization, ProviderUser, Task> callback)
|
||||||
|
{
|
||||||
|
var user = await userRepository.GetByEmailAsync(providerAdminEmail);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
|
if (provider is not
|
||||||
|
{
|
||||||
|
Type: ProviderType.BusinessUnit,
|
||||||
|
Status: ProviderStatusType.Pending
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id);
|
||||||
|
|
||||||
|
if (providerUser is
|
||||||
|
{
|
||||||
|
Type: ProviderUserType.ProviderAdmin,
|
||||||
|
Status: ProviderUserStatusType.Invited
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||||
|
await callback(provider, providerOrganization!, providerUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendInviteAsync(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
var token = _dataProtector.Protect(
|
||||||
|
$"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||||
|
|
||||||
|
await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync(
|
||||||
|
Organization organization,
|
||||||
|
User? user,
|
||||||
|
string token)
|
||||||
|
{
|
||||||
|
if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise)
|
||||||
|
{
|
||||||
|
Fail("Organization must be on an enterprise plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription = await subscriberService.GetSubscription(organization);
|
||||||
|
|
||||||
|
if (subscription is not
|
||||||
|
{
|
||||||
|
Status:
|
||||||
|
StripeConstants.SubscriptionStatus.Active or
|
||||||
|
StripeConstants.SubscriptionStatus.Trialing or
|
||||||
|
StripeConstants.SubscriptionStatus.PastDue
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Fail("Organization must have a valid subscription.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
Fail("Provider admin must be a Bitwarden user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CoreHelpers.TokenIsValid(
|
||||||
|
"BusinessUnitConversionInvite",
|
||||||
|
_dataProtector,
|
||||||
|
token,
|
||||||
|
user.Email,
|
||||||
|
organization.Id,
|
||||||
|
globalSettings.OrganizationInviteExpirationHours))
|
||||||
|
{
|
||||||
|
Fail("Email token is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationUser =
|
||||||
|
await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
|
||||||
|
|
||||||
|
if (organizationUser is not
|
||||||
|
{
|
||||||
|
Status: OrganizationUserStatusType.Confirmed
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Fail("Provider admin must be a confirmed member of the organization being converted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
|
if (provider is not
|
||||||
|
{
|
||||||
|
Type: ProviderType.BusinessUnit,
|
||||||
|
Status: ProviderStatusType.Pending
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Fail("Linked provider is not a pending business unit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id);
|
||||||
|
|
||||||
|
if (providerUser is not
|
||||||
|
{
|
||||||
|
Type: ProviderUserType.ProviderAdmin,
|
||||||
|
Status: ProviderUserStatusType.Invited
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Fail("Provider admin has not been invited.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||||
|
|
||||||
|
return (subscription, provider, providerOrganization!, providerUser);
|
||||||
|
|
||||||
|
[DoesNotReturn]
|
||||||
|
void Fail(string scopedError)
|
||||||
|
{
|
||||||
|
logger.LogError("Could not finalize business unit conversion for organization ({OrganizationID}): {Error}",
|
||||||
|
organization.Id, scopedError);
|
||||||
|
throw new BillingException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<string>?> ValidateInitiationAsync(
|
||||||
|
Organization organization,
|
||||||
|
User? user)
|
||||||
|
{
|
||||||
|
var problems = new List<string>();
|
||||||
|
|
||||||
|
if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise)
|
||||||
|
{
|
||||||
|
problems.Add("Organization must be on an enterprise plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription = await subscriberService.GetSubscription(organization);
|
||||||
|
|
||||||
|
if (subscription is not
|
||||||
|
{
|
||||||
|
Status:
|
||||||
|
StripeConstants.SubscriptionStatus.Active or
|
||||||
|
StripeConstants.SubscriptionStatus.Trialing or
|
||||||
|
StripeConstants.SubscriptionStatus.PastDue
|
||||||
|
})
|
||||||
|
{
|
||||||
|
problems.Add("Organization must have a valid subscription.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||||
|
|
||||||
|
if (providerOrganization != null)
|
||||||
|
{
|
||||||
|
problems.Add("Organization is already linked to a provider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
problems.Add("Provider admin must be a Bitwarden user.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var organizationUser =
|
||||||
|
await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
|
||||||
|
|
||||||
|
if (organizationUser is not
|
||||||
|
{
|
||||||
|
Status: OrganizationUserStatusType.Confirmed
|
||||||
|
})
|
||||||
|
{
|
||||||
|
problems.Add("Provider admin must be a confirmed member of the organization being converted.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return problems.Count == 0 ? null : problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
@ -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
|
||||||
@ -797,7 +791,7 @@ public class ProviderBillingService(
|
|||||||
Provider provider,
|
Provider provider,
|
||||||
Organization organization)
|
Organization organization)
|
||||||
{
|
{
|
||||||
if (provider.Type == ProviderType.MultiOrganizationEnterprise)
|
if (provider.Type == ProviderType.BusinessUnit)
|
||||||
{
|
{
|
||||||
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
|
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
|
||||||
}
|
}
|
||||||
|
@ -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.BusinessUnit"/>.</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.BusinessUnit => 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.BusinessUnit"/>.</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.BusinessUnit => 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")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
||||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||||
|
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -8,7 +8,7 @@ using Bit.Core.Utilities;
|
|||||||
using Bit.Scim.Context;
|
using Bit.Scim.Context;
|
||||||
using Bit.Scim.Utilities;
|
using Bit.Scim.Utilities;
|
||||||
using Bit.SharedWeb.Utilities;
|
using Bit.SharedWeb.Utilities;
|
||||||
using Duende.IdentityModel;
|
using IdentityModel;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
|
||||||
|
@ -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(
|
||||||
{
|
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
|
||||||
private readonly IOrganizationService _organizationService;
|
|
||||||
private readonly IPaymentService _paymentService;
|
|
||||||
private readonly IScimContext _scimContext;
|
|
||||||
|
|
||||||
public PostUserCommand(
|
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
IOrganizationService organizationService,
|
IOrganizationService organizationService,
|
||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
IScimContext scimContext)
|
IScimContext scimContext,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IPricingClient pricingClient)
|
||||||
|
: IPostUserCommand
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||||
_organizationUserRepository = organizationUserRepository;
|
{
|
||||||
_organizationService = organizationService;
|
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
|
||||||
_paymentService = paymentService;
|
{
|
||||||
_scimContext = scimContext;
|
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Scim.Context;
|
using Bit.Scim.Context;
|
||||||
using Duende.IdentityModel;
|
using IdentityModel;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
@ -19,10 +19,10 @@ using Bit.Core.Tokens;
|
|||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Sso.Models;
|
using Bit.Sso.Models;
|
||||||
using Bit.Sso.Utilities;
|
using Bit.Sso.Utilities;
|
||||||
using Duende.IdentityModel;
|
|
||||||
using Duende.IdentityServer;
|
using Duende.IdentityServer;
|
||||||
using Duende.IdentityServer.Services;
|
using Duende.IdentityServer.Services;
|
||||||
using Duende.IdentityServer.Stores;
|
using Duende.IdentityServer.Stores;
|
||||||
|
using IdentityModel;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -7,9 +7,9 @@ using Bit.Core.Settings;
|
|||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Sso.Models;
|
using Bit.Sso.Models;
|
||||||
using Bit.Sso.Utilities;
|
using Bit.Sso.Utilities;
|
||||||
using Duende.IdentityModel;
|
|
||||||
using Duende.IdentityServer;
|
using Duende.IdentityServer;
|
||||||
using Duende.IdentityServer.Infrastructure;
|
using Duende.IdentityServer.Infrastructure;
|
||||||
|
using IdentityModel;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
@ -63,7 +63,7 @@ public class CreateProviderCommandTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
|
public async Task CreateBusinessUnitAsync_Success(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
User user,
|
User user,
|
||||||
PlanType plan,
|
PlanType plan,
|
||||||
@ -71,13 +71,13 @@ public class CreateProviderCommandTests
|
|||||||
SutProvider<CreateProviderCommand> sutProvider)
|
SutProvider<CreateProviderCommand> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
provider.Type = ProviderType.BusinessUnit;
|
||||||
|
|
||||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||||
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
|
await sutProvider.Sut.CreateBusinessUnitAsync(provider, user.Email, plan, minimumSeats);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
|
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
|
||||||
@ -85,7 +85,7 @@ public class CreateProviderCommandTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
|
public async Task CreateBusinessUnitAsync_UserIdIsInvalid_Throws(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
SutProvider<CreateProviderCommand> sutProvider)
|
SutProvider<CreateProviderCommand> sutProvider)
|
||||||
{
|
{
|
||||||
@ -94,7 +94,7 @@ public class CreateProviderCommandTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
|
() => sutProvider.Sut.CreateBusinessUnitAsync(provider, default, default, default));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Contains("Invalid owner.", exception.Message);
|
Assert.Contains("Invalid owner.", exception.Message);
|
||||||
|
@ -0,0 +1,501 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Text;
|
||||||
|
using Bit.Commercial.Core.Billing;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Entities;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Billing.Repositories;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
|
using Stripe;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Test.Billing;
|
||||||
|
|
||||||
|
public class BusinessUnitConverterTests
|
||||||
|
{
|
||||||
|
private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For<IDataProtectionProvider>();
|
||||||
|
private readonly GlobalSettings _globalSettings = new();
|
||||||
|
private readonly ILogger<BusinessUnitConverter> _logger = Substitute.For<ILogger<BusinessUnitConverter>>();
|
||||||
|
private readonly IMailService _mailService = Substitute.For<IMailService>();
|
||||||
|
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||||
|
private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||||
|
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||||
|
private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>();
|
||||||
|
private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For<IProviderPlanRepository>();
|
||||||
|
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
|
||||||
|
private readonly IProviderUserRepository _providerUserRepository = Substitute.For<IProviderUserRepository>();
|
||||||
|
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||||
|
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
|
||||||
|
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
|
||||||
|
|
||||||
|
private BusinessUnitConverter BuildConverter() => new(
|
||||||
|
_dataProtectionProvider,
|
||||||
|
_globalSettings,
|
||||||
|
_logger,
|
||||||
|
_mailService,
|
||||||
|
_organizationRepository,
|
||||||
|
_organizationUserRepository,
|
||||||
|
_pricingClient,
|
||||||
|
_providerOrganizationRepository,
|
||||||
|
_providerPlanRepository,
|
||||||
|
_providerRepository,
|
||||||
|
_providerUserRepository,
|
||||||
|
_stripeAdapter,
|
||||||
|
_subscriberService,
|
||||||
|
_userRepository);
|
||||||
|
|
||||||
|
#region FinalizeConversion
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task FinalizeConversion_Succeeds_ReturnsProviderId(
|
||||||
|
Organization organization,
|
||||||
|
Guid userId,
|
||||||
|
string providerKey,
|
||||||
|
string organizationKey)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.EnterpriseAnnually2020;
|
||||||
|
|
||||||
|
var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Id = "subscription_id",
|
||||||
|
CustomerId = "customer_id",
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data = [
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Id = "subscription_item_id",
|
||||||
|
Price = new Price
|
||||||
|
{
|
||||||
|
Id = enterpriseAnnually2020.PasswordManager.StripeSeatPlanId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_subscriberService.GetSubscription(organization).Returns(subscription);
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Email = "provider-admin@example.com"
|
||||||
|
};
|
||||||
|
|
||||||
|
_userRepository.GetByIdAsync(userId).Returns(user);
|
||||||
|
|
||||||
|
var token = SetupDataProtection(organization, user.Email);
|
||||||
|
|
||||||
|
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed };
|
||||||
|
|
||||||
|
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||||
|
.Returns(organizationUser);
|
||||||
|
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.BusinessUnit,
|
||||||
|
Status = ProviderStatusType.Pending
|
||||||
|
};
|
||||||
|
|
||||||
|
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
||||||
|
|
||||||
|
var providerUser = new ProviderUser
|
||||||
|
{
|
||||||
|
Type = ProviderUserType.ProviderAdmin,
|
||||||
|
Status = ProviderUserStatusType.Invited
|
||||||
|
};
|
||||||
|
|
||||||
|
_providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||||
|
|
||||||
|
var providerOrganization = new ProviderOrganization();
|
||||||
|
|
||||||
|
_providerOrganizationRepository.GetByOrganizationId(organization.Id).Returns(providerOrganization);
|
||||||
|
|
||||||
|
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
|
||||||
|
.Returns(enterpriseAnnually2020);
|
||||||
|
|
||||||
|
var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
|
||||||
|
|
||||||
|
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
|
||||||
|
.Returns(enterpriseAnnually);
|
||||||
|
|
||||||
|
var businessUnitConverter = BuildConverter();
|
||||||
|
|
||||||
|
await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey);
|
||||||
|
|
||||||
|
await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any<CustomerUpdateOptions>());
|
||||||
|
|
||||||
|
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type);
|
||||||
|
|
||||||
|
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is<SubscriptionUpdateOptions>(
|
||||||
|
arguments =>
|
||||||
|
arguments.Items.Count == 2 &&
|
||||||
|
arguments.Items[0].Id == "subscription_item_id" &&
|
||||||
|
arguments.Items[0].Deleted == true &&
|
||||||
|
arguments.Items[1].Price == updatedPriceId &&
|
||||||
|
arguments.Items[1].Quantity == organization.Seats));
|
||||||
|
|
||||||
|
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(arguments =>
|
||||||
|
arguments.PlanType == PlanType.EnterpriseAnnually &&
|
||||||
|
arguments.Status == OrganizationStatusType.Managed &&
|
||||||
|
arguments.GatewayCustomerId == null &&
|
||||||
|
arguments.GatewaySubscriptionId == null));
|
||||||
|
|
||||||
|
await _providerOrganizationRepository.Received(1).ReplaceAsync(Arg.Is<ProviderOrganization>(arguments =>
|
||||||
|
arguments.Key == organizationKey));
|
||||||
|
|
||||||
|
await _providerRepository.Received(1).ReplaceAsync(Arg.Is<Provider>(arguments =>
|
||||||
|
arguments.Gateway == GatewayType.Stripe &&
|
||||||
|
arguments.GatewayCustomerId == subscription.CustomerId &&
|
||||||
|
arguments.GatewaySubscriptionId == subscription.Id &&
|
||||||
|
arguments.Status == ProviderStatusType.Billable));
|
||||||
|
|
||||||
|
await _providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(arguments =>
|
||||||
|
arguments.Key == providerKey &&
|
||||||
|
arguments.Status == ProviderUserStatusType.Confirmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Because the validation for finalization is not an applicative like initialization is,
|
||||||
|
* I'm just testing one specific failure here. I don't see much value in testing every single opportunity for failure.
|
||||||
|
*/
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task FinalizeConversion_ValidationFails_ThrowsBillingException(
|
||||||
|
Organization organization,
|
||||||
|
Guid userId,
|
||||||
|
string token,
|
||||||
|
string providerKey,
|
||||||
|
string organizationKey)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.EnterpriseAnnually2020;
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||||
|
};
|
||||||
|
|
||||||
|
_subscriberService.GetSubscription(organization).Returns(subscription);
|
||||||
|
|
||||||
|
var businessUnitConverter = BuildConverter();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<BillingException>(() =>
|
||||||
|
businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey));
|
||||||
|
|
||||||
|
await _organizationUserRepository.DidNotReceiveWithAnyArgs()
|
||||||
|
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region InitiateConversion
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task InitiateConversion_Succeeds_ReturnsProviderId(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||||
|
|
||||||
|
_subscriberService.GetSubscription(organization).Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Active
|
||||||
|
});
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Email = providerAdminEmail
|
||||||
|
};
|
||||||
|
|
||||||
|
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||||
|
|
||||||
|
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed };
|
||||||
|
|
||||||
|
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||||
|
.Returns(organizationUser);
|
||||||
|
|
||||||
|
var provider = new Provider { Id = Guid.NewGuid() };
|
||||||
|
|
||||||
|
_providerRepository.CreateAsync(Arg.Is<Provider>(argument =>
|
||||||
|
argument.Name == organization.Name &&
|
||||||
|
argument.BillingEmail == organization.BillingEmail &&
|
||||||
|
argument.Status == ProviderStatusType.Pending &&
|
||||||
|
argument.Type == ProviderType.BusinessUnit)).Returns(provider);
|
||||||
|
|
||||||
|
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||||
|
|
||||||
|
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
|
||||||
|
|
||||||
|
var token = SetupDataProtection(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
var businessUnitConverter = BuildConverter();
|
||||||
|
|
||||||
|
var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
Assert.True(result.IsT0);
|
||||||
|
|
||||||
|
var providerId = result.AsT0;
|
||||||
|
|
||||||
|
Assert.Equal(provider.Id, providerId);
|
||||||
|
|
||||||
|
await _providerOrganizationRepository.Received(1).CreateAsync(
|
||||||
|
Arg.Is<ProviderOrganization>(argument =>
|
||||||
|
argument.ProviderId == provider.Id &&
|
||||||
|
argument.OrganizationId == organization.Id));
|
||||||
|
|
||||||
|
await _providerPlanRepository.Received(1).CreateAsync(
|
||||||
|
Arg.Is<ProviderPlan>(argument =>
|
||||||
|
argument.ProviderId == provider.Id &&
|
||||||
|
argument.PlanType == PlanType.EnterpriseAnnually &&
|
||||||
|
argument.SeatMinimum == 0 &&
|
||||||
|
argument.PurchasedSeats == organization.Seats &&
|
||||||
|
argument.AllocatedSeats == organization.Seats));
|
||||||
|
|
||||||
|
await _providerUserRepository.Received(1).CreateAsync(
|
||||||
|
Arg.Is<ProviderUser>(argument =>
|
||||||
|
argument.ProviderId == provider.Id &&
|
||||||
|
argument.UserId == user.Id &&
|
||||||
|
argument.Email == user.Email &&
|
||||||
|
argument.Status == ProviderUserStatusType.Invited &&
|
||||||
|
argument.Type == ProviderUserType.ProviderAdmin));
|
||||||
|
|
||||||
|
await _mailService.Received(1).SendBusinessUnitConversionInviteAsync(
|
||||||
|
organization,
|
||||||
|
token,
|
||||||
|
user.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task InitiateConversion_ValidationFails_ReturnsErrors(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.TeamsMonthly;
|
||||||
|
|
||||||
|
_subscriberService.GetSubscription(organization).Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||||
|
});
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Email = providerAdminEmail
|
||||||
|
};
|
||||||
|
|
||||||
|
_providerOrganizationRepository.GetByOrganizationId(organization.Id)
|
||||||
|
.Returns(new ProviderOrganization());
|
||||||
|
|
||||||
|
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||||
|
|
||||||
|
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Invited };
|
||||||
|
|
||||||
|
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||||
|
.Returns(organizationUser);
|
||||||
|
|
||||||
|
var businessUnitConverter = BuildConverter();
|
||||||
|
|
||||||
|
var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
Assert.True(result.IsT1);
|
||||||
|
|
||||||
|
var problems = result.AsT1;
|
||||||
|
|
||||||
|
Assert.Contains("Organization must be on an enterprise plan.", problems);
|
||||||
|
|
||||||
|
Assert.Contains("Organization must have a valid subscription.", problems);
|
||||||
|
|
||||||
|
Assert.Contains("Organization is already linked to a provider.", problems);
|
||||||
|
|
||||||
|
Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ResendConversionInvite
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ResendConversionInvite_ConversionInProgress_Succeeds(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
SetupConversionInProgress(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
var token = SetupDataProtection(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
var businessUnitConverter = BuildConverter();
|
||||||
|
|
||||||
|
await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
await _mailService.Received(1).SendBusinessUnitConversionInviteAsync(
|
||||||
|
organization,
|
||||||
|
token,
|
||||||
|
providerAdminEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
SetupDataProtection(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
var businessUnitConverter = BuildConverter();
|
||||||
|
|
||||||
|
await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync(
|
||||||
|
Arg.Any<Organization>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ResetConversion
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ResetConversion_ConversionInProgress_Succeeds(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
var (provider, providerOrganization, providerUser, providerPlan) = SetupConversionInProgress(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
var businessUnitConverter = BuildConverter();
|
||||||
|
|
||||||
|
await businessUnitConverter.ResetConversion(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
await _providerOrganizationRepository.Received(1)
|
||||||
|
.DeleteAsync(providerOrganization);
|
||||||
|
|
||||||
|
await _providerUserRepository.Received(1)
|
||||||
|
.DeleteAsync(providerUser);
|
||||||
|
|
||||||
|
await _providerPlanRepository.Received(1)
|
||||||
|
.DeleteAsync(providerPlan);
|
||||||
|
|
||||||
|
await _providerRepository.Received(1)
|
||||||
|
.DeleteAsync(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task ResetConversion_NoConversionInProgress_DoesNothing(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
var businessUnitConverter = BuildConverter();
|
||||||
|
|
||||||
|
await businessUnitConverter.ResetConversion(organization, providerAdminEmail);
|
||||||
|
|
||||||
|
await _providerOrganizationRepository.DidNotReceiveWithAnyArgs()
|
||||||
|
.DeleteAsync(Arg.Any<ProviderOrganization>());
|
||||||
|
|
||||||
|
await _providerUserRepository.DidNotReceiveWithAnyArgs()
|
||||||
|
.DeleteAsync(Arg.Any<ProviderUser>());
|
||||||
|
|
||||||
|
await _providerPlanRepository.DidNotReceiveWithAnyArgs()
|
||||||
|
.DeleteAsync(Arg.Any<ProviderPlan>());
|
||||||
|
|
||||||
|
await _providerRepository.DidNotReceiveWithAnyArgs()
|
||||||
|
.DeleteAsync(Arg.Any<Provider>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Utilities
|
||||||
|
|
||||||
|
private string SetupDataProtection(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
var dataProtector = new MockDataProtector(organization, providerAdminEmail);
|
||||||
|
_dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector").Returns(dataProtector);
|
||||||
|
return dataProtector.Protect(dataProtector.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Provider, ProviderOrganization, ProviderUser, ProviderPlan) SetupConversionInProgress(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail)
|
||||||
|
{
|
||||||
|
var user = new User { Id = Guid.NewGuid() };
|
||||||
|
|
||||||
|
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||||
|
|
||||||
|
var provider = new Provider
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ProviderType.BusinessUnit,
|
||||||
|
Status = ProviderStatusType.Pending
|
||||||
|
};
|
||||||
|
|
||||||
|
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
||||||
|
|
||||||
|
var providerUser = new ProviderUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
UserId = user.Id,
|
||||||
|
Type = ProviderUserType.ProviderAdmin,
|
||||||
|
Status = ProviderUserStatusType.Invited,
|
||||||
|
Email = providerAdminEmail
|
||||||
|
};
|
||||||
|
|
||||||
|
_providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id)
|
||||||
|
.Returns(providerUser);
|
||||||
|
|
||||||
|
var providerOrganization = new ProviderOrganization
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
ProviderId = provider.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
_providerOrganizationRepository.GetByOrganizationId(organization.Id)
|
||||||
|
.Returns(providerOrganization);
|
||||||
|
|
||||||
|
var providerPlan = new ProviderPlan
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProviderId = provider.Id,
|
||||||
|
PlanType = PlanType.EnterpriseAnnually
|
||||||
|
};
|
||||||
|
|
||||||
|
_providerPlanRepository.GetByProviderId(provider.Id).Returns([providerPlan]);
|
||||||
|
|
||||||
|
return (provider, providerOrganization, providerUser, providerPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MockDataProtector(
|
||||||
|
Organization organization,
|
||||||
|
string providerAdminEmail) : IDataProtector
|
||||||
|
{
|
||||||
|
public string Token = $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}";
|
||||||
|
|
||||||
|
public IDataProtector CreateProtector(string purpose) => this;
|
||||||
|
|
||||||
|
public byte[] Protect(byte[] plaintext) => Encoding.UTF8.GetBytes(Token);
|
||||||
|
|
||||||
|
public byte[] Unprotect(byte[] protectedData) => Encoding.UTF8.GetBytes(Token);
|
||||||
|
}
|
@ -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.BusinessUnit;
|
||||||
|
|
||||||
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.BusinessUnit
|
||||||
|
};
|
||||||
|
|
||||||
|
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.BusinessUnit
|
||||||
|
};
|
||||||
|
|
||||||
|
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.BusinessUnit
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
0
dev/ef_migrate.ps1
Normal file → Executable file
0
dev/ef_migrate.ps1
Normal file → Executable file
@ -14,9 +14,6 @@
|
|||||||
<ProjectReference Include="..\Core\Core.csproj" />
|
<ProjectReference Include="..\Core\Core.csproj" />
|
||||||
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
|
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Billing\Controllers\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<Choose>
|
<Choose>
|
||||||
<When Condition="!$(DefineConstants.Contains('OSS'))">
|
<When Condition="!$(DefineConstants.Contains('OSS'))">
|
||||||
|
@ -462,6 +462,7 @@ public class OrganizationsController : Controller
|
|||||||
organization.UsersGetPremium = model.UsersGetPremium;
|
organization.UsersGetPremium = model.UsersGetPremium;
|
||||||
organization.UseSecretsManager = model.UseSecretsManager;
|
organization.UseSecretsManager = model.UseSecretsManager;
|
||||||
organization.UseRiskInsights = model.UseRiskInsights;
|
organization.UseRiskInsights = model.UseRiskInsights;
|
||||||
|
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
||||||
|
|
||||||
//secrets
|
//secrets
|
||||||
organization.SmSeats = model.SmSeats;
|
organization.SmSeats = model.SmSeats;
|
||||||
|
@ -3,11 +3,13 @@ using System.Net;
|
|||||||
using Bit.Admin.AdminConsole.Models;
|
using Bit.Admin.AdminConsole.Models;
|
||||||
using Bit.Admin.Enums;
|
using Bit.Admin.Enums;
|
||||||
using Bit.Admin.Utilities;
|
using Bit.Admin.Utilities;
|
||||||
|
using Bit.Core;
|
||||||
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.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
@ -23,6 +25,7 @@ using Bit.Core.Settings;
|
|||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
namespace Bit.Admin.AdminConsole.Controllers;
|
namespace Bit.Admin.AdminConsole.Controllers;
|
||||||
|
|
||||||
@ -44,6 +47,7 @@ public class ProvidersController : Controller
|
|||||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||||
private readonly IProviderBillingService _providerBillingService;
|
private readonly IProviderBillingService _providerBillingService;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
|
private readonly IStripeAdapter _stripeAdapter;
|
||||||
private readonly string _stripeUrl;
|
private readonly string _stripeUrl;
|
||||||
private readonly string _braintreeMerchantUrl;
|
private readonly string _braintreeMerchantUrl;
|
||||||
private readonly string _braintreeMerchantId;
|
private readonly string _braintreeMerchantId;
|
||||||
@ -63,7 +67,8 @@ public class ProvidersController : Controller
|
|||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderBillingService providerBillingService,
|
IProviderBillingService providerBillingService,
|
||||||
IWebHostEnvironment webHostEnvironment,
|
IWebHostEnvironment webHostEnvironment,
|
||||||
IPricingClient pricingClient)
|
IPricingClient pricingClient,
|
||||||
|
IStripeAdapter stripeAdapter)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationService = organizationService;
|
_organizationService = organizationService;
|
||||||
@ -79,6 +84,7 @@ public class ProvidersController : Controller
|
|||||||
_providerPlanRepository = providerPlanRepository;
|
_providerPlanRepository = providerPlanRepository;
|
||||||
_providerBillingService = providerBillingService;
|
_providerBillingService = providerBillingService;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
|
_stripeAdapter = stripeAdapter;
|
||||||
_stripeUrl = webHostEnvironment.GetStripeUrl();
|
_stripeUrl = webHostEnvironment.GetStripeUrl();
|
||||||
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
||||||
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||||
@ -133,10 +139,10 @@ public class ProvidersController : Controller
|
|||||||
return View(new CreateResellerProviderModel());
|
return View(new CreateResellerProviderModel());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("providers/create/multi-organization-enterprise")]
|
[HttpGet("providers/create/business-unit")]
|
||||||
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
|
public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null)
|
||||||
{
|
{
|
||||||
return View(new CreateMultiOrganizationEnterpriseProviderModel
|
return View(new CreateBusinessUnitProviderModel
|
||||||
{
|
{
|
||||||
OwnerEmail = ownerEmail,
|
OwnerEmail = ownerEmail,
|
||||||
EnterpriseSeatMinimum = enterpriseMinimumSeats
|
EnterpriseSeatMinimum = enterpriseMinimumSeats
|
||||||
@ -157,7 +163,7 @@ public class ProvidersController : Controller
|
|||||||
{
|
{
|
||||||
ProviderType.Msp => RedirectToAction("CreateMsp"),
|
ProviderType.Msp => RedirectToAction("CreateMsp"),
|
||||||
ProviderType.Reseller => RedirectToAction("CreateReseller"),
|
ProviderType.Reseller => RedirectToAction("CreateReseller"),
|
||||||
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
|
ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"),
|
||||||
_ => View(model)
|
_ => View(model)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -198,10 +204,10 @@ public class ProvidersController : Controller
|
|||||||
return RedirectToAction("Edit", new { id = provider.Id });
|
return RedirectToAction("Edit", new { id = provider.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("providers/create/multi-organization-enterprise")]
|
[HttpPost("providers/create/business-unit")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
[RequirePermission(Permission.Provider_Create)]
|
[RequirePermission(Permission.Provider_Create)]
|
||||||
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
|
public async Task<IActionResult> CreateBusinessUnit(CreateBusinessUnitProviderModel model)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
@ -209,7 +215,7 @@ public class ProvidersController : Controller
|
|||||||
}
|
}
|
||||||
var provider = model.ToProvider();
|
var provider = model.ToProvider();
|
||||||
|
|
||||||
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
|
await _createProviderCommand.CreateBusinessUnitAsync(
|
||||||
provider,
|
provider,
|
||||||
model.OwnerEmail,
|
model.OwnerEmail,
|
||||||
model.Plan.Value,
|
model.Plan.Value,
|
||||||
@ -300,29 +306,44 @@ 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)
|
||||||
]);
|
]);
|
||||||
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
|
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically))
|
||||||
|
{
|
||||||
|
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
|
||||||
|
|
||||||
|
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
|
||||||
|
{
|
||||||
|
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
|
||||||
|
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case ProviderType.MultiOrganizationEnterprise:
|
case ProviderType.BusinessUnit:
|
||||||
{
|
{
|
||||||
var existingMoePlan = providerPlans.Single();
|
var existingMoePlan = providerPlans.Single();
|
||||||
|
|
||||||
// 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)
|
||||||
]);
|
]);
|
||||||
@ -347,14 +368,18 @@ public class ProvidersController : Controller
|
|||||||
|
|
||||||
if (!provider.IsBillable())
|
if (!provider.IsBillable())
|
||||||
{
|
{
|
||||||
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
|
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||||
|
|
||||||
|
var payByInvoice =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
|
||||||
|
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
|
||||||
|
|
||||||
return new ProviderEditModel(
|
return new ProviderEditModel(
|
||||||
provider, users, providerOrganizations,
|
provider, users, providerOrganizations,
|
||||||
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
|
providerPlans.ToList(), payByInvoice, GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequirePermission(Permission.Provider_ResendEmailInvite)]
|
[RequirePermission(Permission.Provider_ResendEmailInvite)]
|
||||||
|
@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities;
|
|||||||
|
|
||||||
namespace Bit.Admin.AdminConsole.Models;
|
namespace Bit.Admin.AdminConsole.Models;
|
||||||
|
|
||||||
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
public class CreateBusinessUnitProviderModel : IValidatableObject
|
||||||
{
|
{
|
||||||
[Display(Name = "Owner Email")]
|
[Display(Name = "Owner Email")]
|
||||||
public string OwnerEmail { get; set; }
|
public string OwnerEmail { get; set; }
|
||||||
@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
|||||||
{
|
{
|
||||||
return new Provider
|
return new Provider
|
||||||
{
|
{
|
||||||
Type = ProviderType.MultiOrganizationEnterprise
|
Type = ProviderType.BusinessUnit
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
||||||
{
|
{
|
||||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||||
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||||
}
|
}
|
||||||
if (EnterpriseSeatMinimum < 0)
|
if (EnterpriseSeatMinimum < 0)
|
||||||
{
|
{
|
||||||
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
|
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
|
||||||
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
|
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
|
||||||
}
|
}
|
||||||
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
|
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
|
||||||
{
|
{
|
||||||
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan);
|
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(Plan);
|
||||||
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
|
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -86,6 +86,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
UseApi = org.UseApi;
|
UseApi = org.UseApi;
|
||||||
UseSecretsManager = org.UseSecretsManager;
|
UseSecretsManager = org.UseSecretsManager;
|
||||||
UseRiskInsights = org.UseRiskInsights;
|
UseRiskInsights = org.UseRiskInsights;
|
||||||
|
UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies;
|
||||||
UseResetPassword = org.UseResetPassword;
|
UseResetPassword = org.UseResetPassword;
|
||||||
SelfHost = org.SelfHost;
|
SelfHost = org.SelfHost;
|
||||||
UsersGetPremium = org.UsersGetPremium;
|
UsersGetPremium = org.UsersGetPremium;
|
||||||
@ -154,6 +155,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
public new bool UseSecretsManager { get; set; }
|
public new bool UseSecretsManager { get; set; }
|
||||||
[Display(Name = "Risk Insights")]
|
[Display(Name = "Risk Insights")]
|
||||||
public new bool UseRiskInsights { get; set; }
|
public new bool UseRiskInsights { get; set; }
|
||||||
|
[Display(Name = "Admin Sponsored Families")]
|
||||||
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
[Display(Name = "Self Host")]
|
[Display(Name = "Self Host")]
|
||||||
public bool SelfHost { get; set; }
|
public bool SelfHost { get; set; }
|
||||||
[Display(Name = "Users Get Premium")]
|
[Display(Name = "Users Get Premium")]
|
||||||
@ -295,6 +298,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
existingOrganization.UseApi = UseApi;
|
existingOrganization.UseApi = UseApi;
|
||||||
existingOrganization.UseSecretsManager = UseSecretsManager;
|
existingOrganization.UseSecretsManager = UseSecretsManager;
|
||||||
existingOrganization.UseRiskInsights = UseRiskInsights;
|
existingOrganization.UseRiskInsights = UseRiskInsights;
|
||||||
|
existingOrganization.UseAdminSponsoredFamilies = UseAdminSponsoredFamilies;
|
||||||
existingOrganization.UseResetPassword = UseResetPassword;
|
existingOrganization.UseResetPassword = UseResetPassword;
|
||||||
existingOrganization.SelfHost = SelfHost;
|
existingOrganization.SelfHost = SelfHost;
|
||||||
existingOrganization.UsersGetPremium = UsersGetPremium;
|
existingOrganization.UsersGetPremium = UsersGetPremium;
|
||||||
|
@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
IEnumerable<ProviderUserUserDetails> providerUsers,
|
IEnumerable<ProviderUserUserDetails> providerUsers,
|
||||||
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
|
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
|
||||||
IReadOnlyCollection<ProviderPlan> providerPlans,
|
IReadOnlyCollection<ProviderPlan> providerPlans,
|
||||||
|
bool payByInvoice,
|
||||||
string gatewayCustomerUrl = null,
|
string gatewayCustomerUrl = null,
|
||||||
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
|
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
|
||||||
{
|
{
|
||||||
@ -33,8 +34,9 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
GatewayCustomerUrl = gatewayCustomerUrl;
|
GatewayCustomerUrl = gatewayCustomerUrl;
|
||||||
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
||||||
Type = provider.Type;
|
Type = provider.Type;
|
||||||
|
PayByInvoice = payByInvoice;
|
||||||
|
|
||||||
if (Type == ProviderType.MultiOrganizationEnterprise)
|
if (Type == ProviderType.BusinessUnit)
|
||||||
{
|
{
|
||||||
var plan = providerPlans.SingleOrDefault();
|
var plan = providerPlans.SingleOrDefault();
|
||||||
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
|
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
|
||||||
@ -62,6 +64,8 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
public string GatewaySubscriptionId { get; set; }
|
public string GatewaySubscriptionId { get; set; }
|
||||||
public string GatewayCustomerUrl { get; }
|
public string GatewayCustomerUrl { get; }
|
||||||
public string GatewaySubscriptionUrl { get; }
|
public string GatewaySubscriptionUrl { get; }
|
||||||
|
[Display(Name = "Pay By Invoice")]
|
||||||
|
public bool PayByInvoice { get; set; }
|
||||||
[Display(Name = "Provider Type")]
|
[Display(Name = "Provider Type")]
|
||||||
public ProviderType Type { get; set; }
|
public ProviderType Type { get; set; }
|
||||||
|
|
||||||
@ -100,7 +104,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ProviderType.MultiOrganizationEnterprise:
|
case ProviderType.BusinessUnit:
|
||||||
if (Plan == null)
|
if (Plan == null)
|
||||||
{
|
{
|
||||||
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
|
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
|
||||||
|
@ -40,7 +40,7 @@ public class ProviderViewModel
|
|||||||
ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats));
|
ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (Provider.Type == ProviderType.MultiOrganizationEnterprise)
|
else if (Provider.Type == ProviderType.BusinessUnit)
|
||||||
{
|
{
|
||||||
var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly)
|
var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly)
|
||||||
.Sum(po => po.OccupiedSeats).GetValueOrDefault(0);
|
.Sum(po => po.OccupiedSeats).GetValueOrDefault(0);
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
@using Bit.Admin.Enums;
|
@using Bit.Admin.Enums;
|
||||||
@using Bit.Admin.Models
|
@using Bit.Admin.Models
|
||||||
|
@using Bit.Core
|
||||||
|
@using Bit.Core.AdminConsole.Enums.Provider
|
||||||
@using Bit.Core.Billing.Enums
|
@using Bit.Core.Billing.Enums
|
||||||
@using Bit.Core.Enums
|
@using Bit.Core.Billing.Extensions
|
||||||
|
@using Bit.Core.Services
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||||
|
@inject IFeatureService FeatureService
|
||||||
@model OrganizationEditModel
|
@model OrganizationEditModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name;
|
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name;
|
||||||
@ -13,6 +18,13 @@
|
|||||||
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
|
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
|
||||||
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
|
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
|
||||||
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
|
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
|
||||||
|
|
||||||
|
var canConvertToBusinessUnit =
|
||||||
|
FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) &&
|
||||||
|
AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) &&
|
||||||
|
Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise &&
|
||||||
|
!string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) &&
|
||||||
|
Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending };
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
@ -114,6 +126,15 @@
|
|||||||
Enterprise Trial
|
Enterprise Trial
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (canConvertToBusinessUnit)
|
||||||
|
{
|
||||||
|
<a asp-controller="BusinessUnitConversion"
|
||||||
|
asp-action="Index"
|
||||||
|
asp-route-organizationId="@Model.Organization.Id"
|
||||||
|
class="btn btn-secondary me-2">
|
||||||
|
Convert to Business Unit
|
||||||
|
</a>
|
||||||
|
}
|
||||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-danger me-2"
|
<button class="btn btn-outline-danger me-2"
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
@using Bit.Core.Billing.Enums
|
@using Bit.Core.Billing.Enums
|
||||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
|
||||||
@model CreateMultiOrganizationEnterpriseProviderModel
|
@model CreateBusinessUnitProviderModel
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
|
ViewData["Title"] = "Create Business Unit Provider";
|
||||||
}
|
}
|
||||||
|
|
||||||
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
|
<h1 class="mb-4">Create Business Unit Provider</h1>
|
||||||
<div>
|
<div>
|
||||||
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
|
<form method="post" asp-action="CreateBusinessUnit">
|
||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label asp-for="OwnerEmail" class="form-label"></label>
|
<label asp-for="OwnerEmail" class="form-label"></label>
|
||||||
@ -19,14 +19,14 @@
|
|||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@{
|
@{
|
||||||
var multiOrgPlans = new List<PlanType>
|
var businessUnitPlanTypes = new List<PlanType>
|
||||||
{
|
{
|
||||||
PlanType.EnterpriseAnnually,
|
PlanType.EnterpriseAnnually,
|
||||||
PlanType.EnterpriseMonthly
|
PlanType.EnterpriseMonthly
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
<label asp-for="Plan" class="form-label"></label>
|
<label asp-for="Plan" class="form-label"></label>
|
||||||
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
|
||||||
<option value="">--</option>
|
<option value="">--</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
@ -74,20 +74,20 @@
|
|||||||
</div>
|
</div>
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ProviderType.MultiOrganizationEnterprise:
|
case ProviderType.BusinessUnit:
|
||||||
{
|
{
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@{
|
@{
|
||||||
var multiOrgPlans = new List<PlanType>
|
var businessUnitPlanTypes = new List<PlanType>
|
||||||
{
|
{
|
||||||
PlanType.EnterpriseAnnually,
|
PlanType.EnterpriseAnnually,
|
||||||
PlanType.EnterpriseMonthly
|
PlanType.EnterpriseMonthly
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
<label asp-for="Plan" class="form-label"></label>
|
<label asp-for="Plan" class="form-label"></label>
|
||||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
|
||||||
<option value="">--</option>
|
<option value="">--</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -136,6 +136,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable())
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input type="checkbox" class="form-check-input" asp-for="PayByInvoice">
|
||||||
|
<label class="form-check-label" asp-for="PayByInvoice"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
@await Html.PartialAsync("Organizations", Model)
|
@await Html.PartialAsync("Organizations", Model)
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
@using Bit.Admin.Enums;
|
@using Bit.Admin.Enums;
|
||||||
|
@using Bit.Core
|
||||||
@using Bit.Core.Enums
|
@using Bit.Core.Enums
|
||||||
@using Bit.Core.AdminConsole.Enums.Provider
|
@using Bit.Core.AdminConsole.Enums.Provider
|
||||||
@using Bit.Core.Billing.Enums
|
@using Bit.Core.Billing.Enums
|
||||||
@using Bit.SharedWeb.Utilities
|
@using Bit.SharedWeb.Utilities
|
||||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService;
|
@inject Bit.Admin.Services.IAccessControlService AccessControlService;
|
||||||
|
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||||
|
|
||||||
@model OrganizationEditModel
|
@model OrganizationEditModel
|
||||||
|
|
||||||
@ -146,6 +148,13 @@
|
|||||||
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'>
|
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'>
|
||||||
<label class="form-check-label" asp-for="UseCustomPermissions"></label>
|
<label class="form-check-label" asp-for="UseCustomPermissions"></label>
|
||||||
</div>
|
</div>
|
||||||
|
@if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
|
||||||
|
{
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
|
||||||
|
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<h3>Password Manager</h3>
|
<h3>Password Manager</h3>
|
||||||
|
@ -0,0 +1,185 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Admin.Billing.Models;
|
||||||
|
using Bit.Admin.Enums;
|
||||||
|
using Bit.Admin.Utilities;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Bit.Admin.Billing.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[Route("organizations/billing/{organizationId:guid}/business-unit")]
|
||||||
|
[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)]
|
||||||
|
public class BusinessUnitConversionController(
|
||||||
|
IBusinessUnitConverter businessUnitConverter,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IProviderRepository providerRepository,
|
||||||
|
IProviderUserRepository providerUserRepository) : Controller
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public async Task<IActionResult> IndexAsync([FromRoute] Guid organizationId)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = new BusinessUnitConversionModel { Organization = organization };
|
||||||
|
|
||||||
|
var invitedProviderAdmin = await GetInvitedProviderAdminAsync(organization);
|
||||||
|
|
||||||
|
if (invitedProviderAdmin != null)
|
||||||
|
{
|
||||||
|
model.ProviderAdminEmail = invitedProviderAdmin.Email;
|
||||||
|
model.ProviderId = invitedProviderAdmin.ProviderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var success = ReadSuccessMessage();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(success))
|
||||||
|
{
|
||||||
|
model.Success = success;
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors = ReadErrorMessages();
|
||||||
|
|
||||||
|
if (errors is { Count: > 0 })
|
||||||
|
{
|
||||||
|
model.Errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public async Task<IActionResult> InitiateAsync(
|
||||||
|
[FromRoute] Guid organizationId,
|
||||||
|
BusinessUnitConversionModel model)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await businessUnitConverter.InitiateConversion(
|
||||||
|
organization,
|
||||||
|
model.ProviderAdminEmail!);
|
||||||
|
|
||||||
|
return result.Match(
|
||||||
|
providerId => RedirectToAction("Edit", "Providers", new { id = providerId }),
|
||||||
|
errors =>
|
||||||
|
{
|
||||||
|
PersistErrorMessages(errors);
|
||||||
|
return RedirectToAction("Index", new { organizationId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("reset")]
|
||||||
|
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public async Task<IActionResult> ResetAsync(
|
||||||
|
[FromRoute] Guid organizationId,
|
||||||
|
BusinessUnitConversionModel model)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await businessUnitConverter.ResetConversion(organization, model.ProviderAdminEmail!);
|
||||||
|
|
||||||
|
PersistSuccessMessage("Business unit conversion was successfully reset.");
|
||||||
|
|
||||||
|
return RedirectToAction("Index", new { organizationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("resend-invite")]
|
||||||
|
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public async Task<IActionResult> ResendInviteAsync(
|
||||||
|
[FromRoute] Guid organizationId,
|
||||||
|
BusinessUnitConversionModel model)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await businessUnitConverter.ResendConversionInvite(organization, model.ProviderAdminEmail!);
|
||||||
|
|
||||||
|
PersistSuccessMessage($"Invite was successfully resent to {model.ProviderAdminEmail}.");
|
||||||
|
|
||||||
|
return RedirectToAction("Index", new { organizationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ProviderUser?> GetInvitedProviderAdminAsync(
|
||||||
|
Organization organization)
|
||||||
|
{
|
||||||
|
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
|
if (provider is not
|
||||||
|
{
|
||||||
|
Type: ProviderType.BusinessUnit,
|
||||||
|
Status: ProviderStatusType.Pending
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerUsers =
|
||||||
|
await providerUserRepository.GetManyByProviderAsync(provider.Id, ProviderUserType.ProviderAdmin);
|
||||||
|
|
||||||
|
if (providerUsers.Count != 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerUser = providerUsers.First();
|
||||||
|
|
||||||
|
return providerUser is
|
||||||
|
{
|
||||||
|
Type: ProviderUserType.ProviderAdmin,
|
||||||
|
Status: ProviderUserStatusType.Invited,
|
||||||
|
UserId: not null
|
||||||
|
} ? providerUser : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string _errors = "errors";
|
||||||
|
private const string _success = "Success";
|
||||||
|
|
||||||
|
private void PersistSuccessMessage(string message) => TempData[_success] = message;
|
||||||
|
private void PersistErrorMessages(List<string> errors)
|
||||||
|
{
|
||||||
|
var input = string.Join("|", errors);
|
||||||
|
TempData[_errors] = input;
|
||||||
|
}
|
||||||
|
private string? ReadSuccessMessage() => ReadTempData<string>(_success);
|
||||||
|
private List<string>? ReadErrorMessages()
|
||||||
|
{
|
||||||
|
var output = ReadTempData<string>(_errors);
|
||||||
|
return string.IsNullOrEmpty(output) ? null : output.Split('|').ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private T? ReadTempData<T>(string key) => TempData.TryGetValue(key, out var obj) && obj is T value ? value : default;
|
||||||
|
}
|
25
src/Admin/Billing/Models/BusinessUnitConversionModel.cs
Normal file
25
src/Admin/Billing/Models/BusinessUnitConversionModel.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
|
||||||
|
namespace Bit.Admin.Billing.Models;
|
||||||
|
|
||||||
|
public class BusinessUnitConversionModel
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[EmailAddress]
|
||||||
|
[Display(Name = "Provider Admin Email")]
|
||||||
|
public string? ProviderAdminEmail { get; set; }
|
||||||
|
|
||||||
|
[BindNever]
|
||||||
|
public required Organization Organization { get; set; }
|
||||||
|
|
||||||
|
[BindNever]
|
||||||
|
public Guid? ProviderId { get; set; }
|
||||||
|
|
||||||
|
[BindNever]
|
||||||
|
public string? Success { get; set; }
|
||||||
|
|
||||||
|
[BindNever] public List<string>? Errors { get; set; } = [];
|
||||||
|
}
|
75
src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml
Normal file
75
src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
@model Bit.Admin.Billing.Models.BusinessUnitConversionModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Convert Organization to Business Unit";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ProviderAdminEmail))
|
||||||
|
{
|
||||||
|
<h1>Convert @Model.Organization.Name to Business Unit</h1>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Success))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-3" role="alert">
|
||||||
|
@Model.Success
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (Model.Errors?.Any() ?? false)
|
||||||
|
{
|
||||||
|
@foreach (var error in Model.Errors)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
|
||||||
|
@error
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<p>This organization has a business unit conversion in progress.</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="ProviderAdminEmail" class="form-label"></label>
|
||||||
|
<input type="email" class="form-control" asp-for="ProviderAdminEmail" disabled></input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<form method="post" asp-controller="BusinessUnitConversion" asp-action="ResendInvite" asp-route-organizationId="@Model.Organization.Id">
|
||||||
|
<input type="hidden" asp-for="ProviderAdminEmail" />
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Resend Invite</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Reset" asp-route-organizationId="@Model.Organization.Id">
|
||||||
|
<input type="hidden" asp-for="ProviderAdminEmail" />
|
||||||
|
<button type="submit" class="btn btn-danger mb-2">Reset Conversion</button>
|
||||||
|
</form>
|
||||||
|
@if (Model.ProviderId.HasValue)
|
||||||
|
{
|
||||||
|
<a asp-controller="Providers"
|
||||||
|
asp-action="Edit"
|
||||||
|
asp-route-id="@Model.ProviderId"
|
||||||
|
class="btn btn-secondary mb-2">
|
||||||
|
Go to Provider
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<h1>Convert @Model.Organization.Name to Business Unit</h1>
|
||||||
|
@if (Model.Errors?.Any() ?? false)
|
||||||
|
{
|
||||||
|
@foreach (var error in Model.Errors)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
|
||||||
|
@error
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Initiate" asp-route-organizationId="@Model.Organization.Id">
|
||||||
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="ProviderAdminEmail" class="form-label"></label>
|
||||||
|
<input type="email" class="form-control" asp-for="ProviderAdminEmail" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Convert</button>
|
||||||
|
</form>
|
||||||
|
}
|
@ -184,7 +184,7 @@ public class UsersController : Controller
|
|||||||
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
|
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
|
||||||
{
|
{
|
||||||
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
? await _userService.IsManagedByAnyOrganizationAsync(userId)
|
? await _userService.IsClaimedByAnyOrganizationAsync(userId)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ public enum Permission
|
|||||||
Org_Billing_View,
|
Org_Billing_View,
|
||||||
Org_Billing_Edit,
|
Org_Billing_Edit,
|
||||||
Org_Billing_LaunchGateway,
|
Org_Billing_LaunchGateway,
|
||||||
|
Org_Billing_ConvertToBusinessUnit,
|
||||||
|
|
||||||
Provider_List_View,
|
Provider_List_View,
|
||||||
Provider_Create,
|
Provider_Create,
|
||||||
|
@ -42,6 +42,7 @@ public static class RolePermissionMapping
|
|||||||
Permission.Org_Billing_View,
|
Permission.Org_Billing_View,
|
||||||
Permission.Org_Billing_Edit,
|
Permission.Org_Billing_Edit,
|
||||||
Permission.Org_Billing_LaunchGateway,
|
Permission.Org_Billing_LaunchGateway,
|
||||||
|
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||||
Permission.Provider_List_View,
|
Permission.Provider_List_View,
|
||||||
Permission.Provider_Create,
|
Permission.Provider_Create,
|
||||||
Permission.Provider_View,
|
Permission.Provider_View,
|
||||||
@ -90,6 +91,7 @@ public static class RolePermissionMapping
|
|||||||
Permission.Org_Billing_View,
|
Permission.Org_Billing_View,
|
||||||
Permission.Org_Billing_Edit,
|
Permission.Org_Billing_Edit,
|
||||||
Permission.Org_Billing_LaunchGateway,
|
Permission.Org_Billing_LaunchGateway,
|
||||||
|
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||||
Permission.Org_InitiateTrial,
|
Permission.Org_InitiateTrial,
|
||||||
Permission.Provider_List_View,
|
Permission.Provider_List_View,
|
||||||
Permission.Provider_Create,
|
Permission.Provider_Create,
|
||||||
@ -166,6 +168,7 @@ public static class RolePermissionMapping
|
|||||||
Permission.Org_Billing_View,
|
Permission.Org_Billing_View,
|
||||||
Permission.Org_Billing_Edit,
|
Permission.Org_Billing_Edit,
|
||||||
Permission.Org_Billing_LaunchGateway,
|
Permission.Org_Billing_LaunchGateway,
|
||||||
|
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||||
Permission.Org_RequestDelete,
|
Permission.Org_RequestDelete,
|
||||||
Permission.Provider_Edit,
|
Permission.Provider_Edit,
|
||||||
Permission.Provider_View,
|
Permission.Provider_View,
|
||||||
|
21
src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs
Normal file
21
src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An attribute which requires authorization using the specified requirement.
|
||||||
|
/// This uses the standard ASP.NET authorization middleware.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The IAuthorizationRequirement that will be used to authorize the user.</typeparam>
|
||||||
|
public class AuthorizeAttribute<T>
|
||||||
|
: AuthorizeAttribute, IAuthorizationRequirementData
|
||||||
|
where T : IAuthorizationRequirement, new()
|
||||||
|
{
|
||||||
|
public IEnumerable<IAuthorizationRequirement> GetRequirements()
|
||||||
|
{
|
||||||
|
var requirement = new T();
|
||||||
|
return [requirement];
|
||||||
|
}
|
||||||
|
}
|
79
src/Api/AdminConsole/Authorization/HttpContextExtensions.cs
Normal file
79
src/Api/AdminConsole/Authorization/HttpContextExtensions.cs
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Authorization;
|
||||||
|
|
||||||
|
public static class HttpContextExtensions
|
||||||
|
{
|
||||||
|
public const string NoOrgIdError =
|
||||||
|
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
|
||||||
|
/// Subsequent calls will retrieve the cached value.
|
||||||
|
/// Results are stored by type and therefore must be of a unique type.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<T> WithFeaturesCacheAsync<T>(this HttpContext httpContext, Func<Task<T>> callback)
|
||||||
|
{
|
||||||
|
var cachedResult = httpContext.Features.Get<T>();
|
||||||
|
if (cachedResult != null)
|
||||||
|
{
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await callback();
|
||||||
|
httpContext.Features.Set(result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the user is a ProviderUser for a Provider which manages the specified organization, otherwise false.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
|
||||||
|
/// </remarks>
|
||||||
|
public static async Task<bool> IsProviderUserForOrgAsync(
|
||||||
|
this HttpContext httpContext,
|
||||||
|
IProviderUserRepository providerUserRepository,
|
||||||
|
Guid userId,
|
||||||
|
Guid organizationId)
|
||||||
|
{
|
||||||
|
var organizations = await httpContext.GetProviderUserOrganizationsAsync(providerUserRepository, userId);
|
||||||
|
return organizations.Any(o => o.OrganizationId == organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the ProviderUserOrganizations for a user. These are the organizations the ProviderUser manages via their Provider, if any.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<IEnumerable<ProviderUserOrganizationDetails>> GetProviderUserOrganizationsAsync(
|
||||||
|
this HttpContext httpContext,
|
||||||
|
IProviderUserRepository providerUserRepository,
|
||||||
|
Guid userId)
|
||||||
|
=> await httpContext.WithFeaturesCacheAsync(() =>
|
||||||
|
providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed));
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <exception cref="InvalidOperationException"></exception>
|
||||||
|
public static Guid GetOrganizationId(this HttpContext httpContext)
|
||||||
|
{
|
||||||
|
httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam);
|
||||||
|
if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(NoOrgIdError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return orgId;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A requirement that implements this interface will be handled by <see cref="OrganizationRequirementHandler"/>,
|
||||||
|
/// which calls AuthorizeAsync with the organization details from the route.
|
||||||
|
/// This is used for simple role-based checks.
|
||||||
|
/// This may only be used on endpoints with {orgId} in their path.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOrganizationRequirement : IAuthorizationRequirement
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to authorize a request that has this requirement.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="organizationClaims">
|
||||||
|
/// The CurrentContextOrganization for the user if they are a member of the organization.
|
||||||
|
/// This is null if they are not a member.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="isProviderUserForOrg">
|
||||||
|
/// A callback that returns true if the user is a ProviderUser that manages the organization, otherwise false.
|
||||||
|
/// This requires a database query, call it last.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>True if the requirement has been satisfied, otherwise false.</returns>
|
||||||
|
public Task<bool> AuthorizeAsync(
|
||||||
|
CurrentContextOrganization? organizationClaims,
|
||||||
|
Func<Task<bool>> isProviderUserForOrg);
|
||||||
|
}
|
@ -0,0 +1,119 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Identity;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Authorization;
|
||||||
|
|
||||||
|
public static class OrganizationClaimsExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a user's claims and returns an object representing their claims for the specified organization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user who has the claims.</param>
|
||||||
|
/// <param name="organizationId">The organizationId to look for in the claims.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="CurrentContextOrganization"/> representing the user's claims for that organization, or null
|
||||||
|
/// if the user does not have any claims for that organization.
|
||||||
|
/// </returns>
|
||||||
|
public static CurrentContextOrganization? GetCurrentContextOrganization(this ClaimsPrincipal user, Guid organizationId)
|
||||||
|
{
|
||||||
|
var hasClaim = GetClaimsParser(user, organizationId);
|
||||||
|
|
||||||
|
var role = GetRoleFromClaims(hasClaim);
|
||||||
|
if (!role.HasValue)
|
||||||
|
{
|
||||||
|
// Not an organization member
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CurrentContextOrganization
|
||||||
|
{
|
||||||
|
Id = organizationId,
|
||||||
|
Type = role.Value,
|
||||||
|
AccessSecretsManager = hasClaim(Claims.SecretsManagerAccess),
|
||||||
|
Permissions = role == OrganizationUserType.Custom
|
||||||
|
? GetPermissionsFromClaims(hasClaim)
|
||||||
|
: new Permissions()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a function for evaluating claims for the specified user and organizationId.
|
||||||
|
/// The function returns true if the claim type exists and false otherwise.
|
||||||
|
/// </summary>
|
||||||
|
private static Func<string, bool> GetClaimsParser(ClaimsPrincipal user, Guid organizationId)
|
||||||
|
{
|
||||||
|
// Group claims by ClaimType
|
||||||
|
var claimsDict = user.Claims
|
||||||
|
.GroupBy(c => c.Type)
|
||||||
|
.ToDictionary(
|
||||||
|
c => c.Key,
|
||||||
|
c => c.ToList());
|
||||||
|
|
||||||
|
return claimType
|
||||||
|
=> claimsDict.TryGetValue(claimType, out var claims) &&
|
||||||
|
claims
|
||||||
|
.ParseGuids()
|
||||||
|
.Any(v => v == organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the provided claims into proper Guids, or ignore them if they are not valid guids.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<Guid> ParseGuids(this IEnumerable<Claim> claims)
|
||||||
|
{
|
||||||
|
foreach (var claim in claims)
|
||||||
|
{
|
||||||
|
if (Guid.TryParse(claim.Value, out var guid))
|
||||||
|
{
|
||||||
|
yield return guid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrganizationUserType? GetRoleFromClaims(Func<string, bool> hasClaim)
|
||||||
|
{
|
||||||
|
if (hasClaim(Claims.OrganizationOwner))
|
||||||
|
{
|
||||||
|
return OrganizationUserType.Owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasClaim(Claims.OrganizationAdmin))
|
||||||
|
{
|
||||||
|
return OrganizationUserType.Admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasClaim(Claims.OrganizationCustom))
|
||||||
|
{
|
||||||
|
return OrganizationUserType.Custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasClaim(Claims.OrganizationUser))
|
||||||
|
{
|
||||||
|
return OrganizationUserType.User;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Permissions GetPermissionsFromClaims(Func<string, bool> hasClaim)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
AccessEventLogs = hasClaim(Claims.CustomPermissions.AccessEventLogs),
|
||||||
|
AccessImportExport = hasClaim(Claims.CustomPermissions.AccessImportExport),
|
||||||
|
AccessReports = hasClaim(Claims.CustomPermissions.AccessReports),
|
||||||
|
CreateNewCollections = hasClaim(Claims.CustomPermissions.CreateNewCollections),
|
||||||
|
EditAnyCollection = hasClaim(Claims.CustomPermissions.EditAnyCollection),
|
||||||
|
DeleteAnyCollection = hasClaim(Claims.CustomPermissions.DeleteAnyCollection),
|
||||||
|
ManageGroups = hasClaim(Claims.CustomPermissions.ManageGroups),
|
||||||
|
ManagePolicies = hasClaim(Claims.CustomPermissions.ManagePolicies),
|
||||||
|
ManageSso = hasClaim(Claims.CustomPermissions.ManageSso),
|
||||||
|
ManageUsers = hasClaim(Claims.CustomPermissions.ManageUsers),
|
||||||
|
ManageResetPassword = hasClaim(Claims.CustomPermissions.ManageResetPassword),
|
||||||
|
ManageScim = hasClaim(Claims.CustomPermissions.ManageScim),
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles any requirement that implements <see cref="IOrganizationRequirement"/>.
|
||||||
|
/// Retrieves the Organization ID from the route and then passes it to the requirement's AuthorizeAsync callback to
|
||||||
|
/// determine whether the action is authorized.
|
||||||
|
/// </summary>
|
||||||
|
public class OrganizationRequirementHandler(
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IProviderUserRepository providerUserRepository,
|
||||||
|
IUserService userService)
|
||||||
|
: AuthorizationHandler<IOrganizationRequirement>
|
||||||
|
{
|
||||||
|
public const string NoHttpContextError = "This method should only be called in the context of an HTTP Request.";
|
||||||
|
public const string NoUserIdError = "This method should only be called on the private api with a logged in user.";
|
||||||
|
|
||||||
|
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, IOrganizationRequirement requirement)
|
||||||
|
{
|
||||||
|
var httpContext = httpContextAccessor.HttpContext;
|
||||||
|
if (httpContext == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(NoHttpContextError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationId = httpContext.GetOrganizationId();
|
||||||
|
var organizationClaims = httpContext.User.GetCurrentContextOrganization(organizationId);
|
||||||
|
|
||||||
|
var userId = userService.GetProperUserId(httpContext.User);
|
||||||
|
if (userId == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(NoUserIdError);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<bool> IsProviderUserForOrg() => httpContext.IsProviderUserForOrgAsync(providerUserRepository, userId.Value, organizationId);
|
||||||
|
|
||||||
|
var authorized = await requirement.AuthorizeAsync(organizationClaims, IsProviderUserForOrg);
|
||||||
|
|
||||||
|
if (authorized)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Authorization.Requirements;
|
||||||
|
|
||||||
|
public class ManageUsersRequirement : IOrganizationRequirement
|
||||||
|
{
|
||||||
|
public async Task<bool> AuthorizeAsync(
|
||||||
|
CurrentContextOrganization? organizationClaims,
|
||||||
|
Func<Task<bool>> isProviderUserForOrg)
|
||||||
|
=> organizationClaims switch
|
||||||
|
{
|
||||||
|
{ Type: OrganizationUserType.Owner } => true,
|
||||||
|
{ Type: OrganizationUserType.Admin } => true,
|
||||||
|
{ Permissions.ManageUsers: true } => true,
|
||||||
|
_ => await isProviderUserForOrg()
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.Context;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Authorization.Requirements;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requires that the user is a member of the organization or a provider for the organization.
|
||||||
|
/// </summary>
|
||||||
|
public class MemberOrProviderRequirement : IOrganizationRequirement
|
||||||
|
{
|
||||||
|
public async Task<bool> AuthorizeAsync(
|
||||||
|
CurrentContextOrganization? organizationClaims,
|
||||||
|
Func<Task<bool>> isProviderUserForOrg)
|
||||||
|
=> organizationClaims is not null || await isProviderUserForOrg();
|
||||||
|
}
|
@ -56,8 +56,8 @@ public class OrganizationUsersController : Controller
|
|||||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||||
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
|
private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand;
|
||||||
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
|
private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
@ -83,8 +83,8 @@ public class OrganizationUsersController : Controller
|
|||||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||||
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
|
IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand,
|
||||||
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
|
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
@ -109,8 +109,8 @@ public class OrganizationUsersController : Controller
|
|||||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||||
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
|
_deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand;
|
||||||
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
|
_getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
@ -127,11 +127,11 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedByOrganization = await GetManagedByOrganizationStatusAsync(
|
var claimedByOrganizationStatus = await GetClaimedByOrganizationStatusAsync(
|
||||||
organizationUser.OrganizationId,
|
organizationUser.OrganizationId,
|
||||||
[organizationUser.Id]);
|
[organizationUser.Id]);
|
||||||
|
|
||||||
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections);
|
var response = new OrganizationUserDetailsResponseModel(organizationUser, claimedByOrganizationStatus[organizationUser.Id], collections);
|
||||||
|
|
||||||
if (includeGroups)
|
if (includeGroups)
|
||||||
{
|
{
|
||||||
@ -175,13 +175,13 @@ public class OrganizationUsersController : Controller
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
||||||
var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
|
var organizationUsersClaimedStatus = await GetClaimedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
|
||||||
var responses = organizationUsers
|
var responses = organizationUsers
|
||||||
.Select(o =>
|
.Select(o =>
|
||||||
{
|
{
|
||||||
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
|
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
|
||||||
var managedByOrganization = organizationUsersManagementStatus[o.Id];
|
var claimedByOrganization = organizationUsersClaimedStatus[o.Id];
|
||||||
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization);
|
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization);
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
});
|
});
|
||||||
@ -313,7 +313,7 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName);
|
await _organizationService.InitPendingOrganization(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
|
||||||
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
|
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
|
||||||
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
|
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
|
||||||
}
|
}
|
||||||
@ -494,7 +494,7 @@ public class OrganizationUsersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
|
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
|
||||||
var isTdeEnrollment = ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption;
|
var isTdeEnrollment = ssoConfig != null && ssoConfig.Enabled && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption;
|
||||||
if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash))
|
if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Incorrect password");
|
throw new BadRequestException("Incorrect password");
|
||||||
@ -591,7 +591,7 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
|
await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
||||||
@ -610,7 +610,7 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
|
var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
|
||||||
|
|
||||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
|
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
|
||||||
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
|
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
|
||||||
@ -717,14 +717,14 @@ public class OrganizationUsersController : Controller
|
|||||||
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
|
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IDictionary<Guid, bool>> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
|
private async Task<IDictionary<Guid, bool>> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
|
||||||
{
|
{
|
||||||
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
||||||
{
|
{
|
||||||
return userIds.ToDictionary(kvp => kvp, kvp => false);
|
return userIds.ToDictionary(kvp => kvp, kvp => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds);
|
var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds);
|
||||||
return usersOrganizationManagementStatus;
|
return usersOrganizationClaimedStatus;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -65,6 +65,7 @@ public class OrganizationsController : Controller
|
|||||||
private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
|
private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
|
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
|
||||||
|
|
||||||
public OrganizationsController(
|
public OrganizationsController(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -88,7 +89,8 @@ public class OrganizationsController : Controller
|
|||||||
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
|
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
|
||||||
IOrganizationDeleteCommand organizationDeleteCommand,
|
IOrganizationDeleteCommand organizationDeleteCommand,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IPricingClient pricingClient)
|
IPricingClient pricingClient,
|
||||||
|
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -112,6 +114,7 @@ public class OrganizationsController : Controller
|
|||||||
_organizationDeleteCommand = organizationDeleteCommand;
|
_organizationDeleteCommand = organizationDeleteCommand;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
|
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@ -140,10 +143,10 @@ public class OrganizationsController : Controller
|
|||||||
var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId,
|
var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId,
|
||||||
OrganizationUserStatusType.Confirmed);
|
OrganizationUserStatusType.Confirmed);
|
||||||
|
|
||||||
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
|
||||||
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id);
|
var organizationIdsClaimingActiveUser = organizationsClaimingActiveUser.Select(o => o.Id);
|
||||||
|
|
||||||
var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser));
|
var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingActiveUser));
|
||||||
return new ListResponseModel<ProfileOrganizationResponseModel>(responses);
|
return new ListResponseModel<ProfileOrganizationResponseModel>(responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,9 +280,9 @@ public class OrganizationsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id))
|
&& (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.");
|
throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id);
|
await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id);
|
||||||
@ -490,7 +493,7 @@ public class OrganizationsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id}/keys")]
|
[HttpPost("{id}/keys")]
|
||||||
public async Task<OrganizationKeysResponseModel> PostKeys(string id, [FromBody] OrganizationKeysRequestModel model)
|
public async Task<OrganizationKeysResponseModel> PostKeys(Guid id, [FromBody] OrganizationKeysRequestModel model)
|
||||||
{
|
{
|
||||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
@ -498,7 +501,7 @@ public class OrganizationsController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var org = await _organizationService.UpdateOrganizationKeysAsync(new Guid(id), model.PublicKey,
|
var org = await _organizationUpdateKeysCommand.UpdateOrganizationKeysAsync(id, model.PublicKey,
|
||||||
model.EncryptedPrivateKey);
|
model.EncryptedPrivateKey);
|
||||||
return new OrganizationKeysResponseModel(org);
|
return new OrganizationKeysResponseModel(org);
|
||||||
}
|
}
|
||||||
|
@ -64,6 +64,7 @@ public class OrganizationResponseModel : ResponseModel
|
|||||||
LimitItemDeletion = organization.LimitItemDeletion;
|
LimitItemDeletion = organization.LimitItemDeletion;
|
||||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||||
UseRiskInsights = organization.UseRiskInsights;
|
UseRiskInsights = organization.UseRiskInsights;
|
||||||
|
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@ -110,6 +111,7 @@ public class OrganizationResponseModel : ResponseModel
|
|||||||
public bool LimitItemDeletion { get; set; }
|
public bool LimitItemDeletion { get; set; }
|
||||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||||
public bool UseRiskInsights { get; set; }
|
public bool UseRiskInsights { get; set; }
|
||||||
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||||
|
@ -66,24 +66,34 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
|
|||||||
{
|
{
|
||||||
public OrganizationUserDetailsResponseModel(
|
public OrganizationUserDetailsResponseModel(
|
||||||
OrganizationUser organizationUser,
|
OrganizationUser organizationUser,
|
||||||
bool managedByOrganization,
|
bool claimedByOrganization,
|
||||||
|
string ssoExternalId,
|
||||||
IEnumerable<CollectionAccessSelection> collections)
|
IEnumerable<CollectionAccessSelection> collections)
|
||||||
: base(organizationUser, "organizationUserDetails")
|
: base(organizationUser, "organizationUserDetails")
|
||||||
{
|
{
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
|
SsoExternalId = ssoExternalId;
|
||||||
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
bool managedByOrganization,
|
bool claimedByOrganization,
|
||||||
IEnumerable<CollectionAccessSelection> collections)
|
IEnumerable<CollectionAccessSelection> collections)
|
||||||
: base(organizationUser, "organizationUserDetails")
|
: base(organizationUser, "organizationUserDetails")
|
||||||
{
|
{
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
|
SsoExternalId = organizationUser.SsoExternalId;
|
||||||
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ManagedByOrganization { get; set; }
|
[Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
|
||||||
|
public bool ManagedByOrganization
|
||||||
|
{
|
||||||
|
get => ClaimedByOrganization;
|
||||||
|
set => ClaimedByOrganization = value;
|
||||||
|
}
|
||||||
|
public bool ClaimedByOrganization { get; set; }
|
||||||
|
public string SsoExternalId { get; set; }
|
||||||
|
|
||||||
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
||||||
|
|
||||||
@ -117,7 +127,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
|
|||||||
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
||||||
{
|
{
|
||||||
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
|
bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails")
|
||||||
: base(organizationUser, obj)
|
: base(organizationUser, obj)
|
||||||
{
|
{
|
||||||
if (organizationUser == null)
|
if (organizationUser == null)
|
||||||
@ -134,7 +144,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
|
|||||||
Groups = organizationUser.Groups;
|
Groups = organizationUser.Groups;
|
||||||
// Prevent reset password when using key connector.
|
// Prevent reset password when using key connector.
|
||||||
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
|
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
@ -142,11 +152,17 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
|
|||||||
public string AvatarColor { get; set; }
|
public string AvatarColor { get; set; }
|
||||||
public bool TwoFactorEnabled { get; set; }
|
public bool TwoFactorEnabled { get; set; }
|
||||||
public bool SsoBound { get; set; }
|
public bool SsoBound { get; set; }
|
||||||
|
[Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
|
||||||
|
public bool ManagedByOrganization
|
||||||
|
{
|
||||||
|
get => ClaimedByOrganization;
|
||||||
|
set => ClaimedByOrganization = value;
|
||||||
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indicates if the organization manages the user. If a user is "managed" by an organization,
|
/// Indicates if the organization claimed the user. If a user is "claimed" by an organization,
|
||||||
/// the organization has greater control over their account, and some user actions are restricted.
|
/// the organization has greater control over their account, and some user actions are restricted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ManagedByOrganization { get; set; }
|
public bool ClaimedByOrganization { get; set; }
|
||||||
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
||||||
public IEnumerable<Guid> Groups { get; set; }
|
public IEnumerable<Guid> Groups { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
|
|
||||||
public ProfileOrganizationResponseModel(
|
public ProfileOrganizationResponseModel(
|
||||||
OrganizationUserOrganizationDetails organization,
|
OrganizationUserOrganizationDetails organization,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser)
|
IEnumerable<Guid> organizationIdsClaimingUser)
|
||||||
: this("profileOrganization")
|
: this("profileOrganization")
|
||||||
{
|
{
|
||||||
Id = organization.OrganizationId;
|
Id = organization.OrganizationId;
|
||||||
@ -51,7 +51,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
|
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
|
||||||
Identifier = organization.Identifier;
|
Identifier = organization.Identifier;
|
||||||
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
|
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
|
||||||
ResetPasswordEnrolled = organization.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey);
|
||||||
UserId = organization.UserId;
|
UserId = organization.UserId;
|
||||||
OrganizationUserId = organization.OrganizationUserId;
|
OrganizationUserId = organization.OrganizationUserId;
|
||||||
ProviderId = organization.ProviderId;
|
ProviderId = organization.ProviderId;
|
||||||
@ -70,8 +70,9 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
LimitCollectionDeletion = organization.LimitCollectionDeletion;
|
LimitCollectionDeletion = organization.LimitCollectionDeletion;
|
||||||
LimitItemDeletion = organization.LimitItemDeletion;
|
LimitItemDeletion = organization.LimitItemDeletion;
|
||||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||||
UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId);
|
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
|
||||||
UseRiskInsights = organization.UseRiskInsights;
|
UseRiskInsights = organization.UseRiskInsights;
|
||||||
|
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||||
|
|
||||||
if (organization.SsoConfig != null)
|
if (organization.SsoConfig != null)
|
||||||
{
|
{
|
||||||
@ -133,15 +134,27 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
public bool LimitItemDeletion { get; set; }
|
public bool LimitItemDeletion { get; set; }
|
||||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indicates if the organization manages the user.
|
/// Obsolete.
|
||||||
|
///
|
||||||
|
/// See <see cref="UserIsClaimedByOrganization"/>
|
||||||
|
/// </summary>
|
||||||
|
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
|
||||||
|
public bool UserIsManagedByOrganization
|
||||||
|
{
|
||||||
|
get => UserIsClaimedByOrganization;
|
||||||
|
set => UserIsClaimedByOrganization = value;
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if the organization claims the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An organization manages a user if the user's email domain is verified by the organization and the user is a member of it.
|
/// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it.
|
||||||
/// The organization must be enabled and able to have verified domains.
|
/// The organization must be enabled and able to have verified domains.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// False if the Account Deprovisioning feature flag is disabled.
|
/// False if the Account Deprovisioning feature flag is disabled.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
public bool UserIsManagedByOrganization { get; set; }
|
public bool UserIsClaimedByOrganization { get; set; }
|
||||||
public bool UseRiskInsights { get; set; }
|
public bool UseRiskInsights { get; set; }
|
||||||
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -50,5 +50,6 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
|
|||||||
LimitItemDeletion = organization.LimitItemDeletion;
|
LimitItemDeletion = organization.LimitItemDeletion;
|
||||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||||
UseRiskInsights = organization.UseRiskInsights;
|
UseRiskInsights = organization.UseRiskInsights;
|
||||||
|
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ public class ProfileProviderResponseModel : ResponseModel
|
|||||||
UserId = provider.UserId;
|
UserId = provider.UserId;
|
||||||
UseEvents = provider.UseEvents;
|
UseEvents = provider.UseEvents;
|
||||||
ProviderStatus = provider.ProviderStatus;
|
ProviderStatus = provider.ProviderStatus;
|
||||||
|
ProviderType = provider.ProviderType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@ -35,4 +36,5 @@ public class ProfileProviderResponseModel : ResponseModel
|
|||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
public bool UseEvents { get; set; }
|
public bool UseEvents { get; set; }
|
||||||
public ProviderStatusType ProviderStatus { get; set; }
|
public ProviderStatusType ProviderStatus { get; set; }
|
||||||
|
public ProviderType ProviderType { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
Email = user.Email;
|
Email = user.Email;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SetsRequiredMembers]
|
[SetsRequiredMembers]
|
||||||
@ -49,7 +49,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
TwoFactorEnabled = twoFactorEnabled;
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
|
||||||
SsoExternalId = user.SsoExternalId;
|
SsoExternalId = user.SsoExternalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,11 +124,11 @@ public class AccountsController : Controller
|
|||||||
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail);
|
var claimedUserValidationResult = await _userService.ValidateClaimedUserDomainAsync(user, model.NewEmail);
|
||||||
|
|
||||||
if (!managedUserValidationResult.Succeeded)
|
if (!claimedUserValidationResult.Succeeded)
|
||||||
{
|
{
|
||||||
throw new BadRequestException(managedUserValidationResult.Errors);
|
throw new BadRequestException(claimedUserValidationResult.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
||||||
@ -284,52 +284,6 @@ public class AccountsController : Controller
|
|||||||
throw new BadRequestException(ModelState);
|
throw new BadRequestException(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("set-key-connector-key")]
|
|
||||||
public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model)
|
|
||||||
{
|
|
||||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new UnauthorizedAccessException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var error in result.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException(ModelState);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("convert-to-key-connector")]
|
|
||||||
public async Task PostConvertToKeyConnector()
|
|
||||||
{
|
|
||||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new UnauthorizedAccessException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _userService.ConvertToKeyConnectorAsync(user);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var error in result.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException(ModelState);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("kdf")]
|
[HttpPost("kdf")]
|
||||||
public async Task PostKdf([FromBody] KdfRequestModel model)
|
public async Task PostKdf([FromBody] KdfRequestModel model)
|
||||||
{
|
{
|
||||||
@ -437,11 +391,11 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, twoFactorEnabled,
|
providerUserOrganizationDetails, twoFactorEnabled,
|
||||||
hasPremiumFromOrg, organizationIdsManagingActiveUser);
|
hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,9 +405,9 @@ public class AccountsController : Controller
|
|||||||
var userId = _userService.GetProperUserId(User);
|
var userId = _userService.GetProperUserId(User);
|
||||||
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
|
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
|
||||||
OrganizationUserStatusType.Confirmed);
|
OrganizationUserStatusType.Confirmed);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value);
|
var organizationIdsClaimingUser = await GetOrganizationIdsClaimingUserAsync(userId.Value);
|
||||||
|
|
||||||
var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser));
|
var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
|
||||||
return new ListResponseModel<ProfileOrganizationResponseModel>(responseData);
|
return new ListResponseModel<ProfileOrganizationResponseModel>(responseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,9 +425,9 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser);
|
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,9 +444,9 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser);
|
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,9 +514,9 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
// If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
&& await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
|
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
}
|
}
|
||||||
@ -763,9 +717,9 @@ public class AccountsController : Controller
|
|||||||
await _userService.SaveUserAsync(user);
|
await _userService.SaveUserAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId)
|
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
|
||||||
return organizationManagingUser.Select(o => o.Id);
|
return organizationsClaimingUser.Select(o => o.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
src/Api/Auth/Models/Request/UntrustDevicesModel.cs
Normal file
11
src/Api/Auth/Models/Request/UntrustDevicesModel.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Bit.Api.Auth.Models.Request;
|
||||||
|
|
||||||
|
public class UntrustDevicesRequestModel
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public IEnumerable<Guid> Devices { get; set; } = null!;
|
||||||
|
}
|
@ -58,10 +58,10 @@ public class AccountsController(
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
|
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
|
||||||
userHasPremiumFromOrganization, organizationIdsManagingActiveUser);
|
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||||
return new PaymentResponseModel
|
return new PaymentResponseModel
|
||||||
{
|
{
|
||||||
UserProfile = profile,
|
UserProfile = profile,
|
||||||
@ -229,9 +229,9 @@ public class AccountsController(
|
|||||||
await paymentService.SaveTaxInfoAsync(user, taxInfo);
|
await paymentService.SaveTaxInfoAsync(user, taxInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId)
|
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var organizationManagingUser = await userService.GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
|
||||||
return organizationManagingUser.Select(o => o.Id);
|
return organizationsClaimingUser.Select(o => o.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.Billing.Models.Requests;
|
using Bit.Api.Billing.Models.Requests;
|
||||||
using Bit.Api.Billing.Models.Responses;
|
using Bit.Api.Billing.Models.Responses;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Sales;
|
using Bit.Core.Billing.Models.Sales;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
@ -18,7 +19,9 @@ namespace Bit.Api.Billing.Controllers;
|
|||||||
[Route("organizations/{organizationId:guid}/billing")]
|
[Route("organizations/{organizationId:guid}/billing")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class OrganizationBillingController(
|
public class OrganizationBillingController(
|
||||||
|
IBusinessUnitConverter businessUnitConverter,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
|
IFeatureService featureService,
|
||||||
IOrganizationBillingService organizationBillingService,
|
IOrganizationBillingService organizationBillingService,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
@ -296,4 +299,40 @@ public class OrganizationBillingController(
|
|||||||
|
|
||||||
return TypedResults.Ok();
|
return TypedResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("setup-business-unit")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public async Task<IResult> SetupBusinessUnitAsync(
|
||||||
|
[FromRoute] Guid organizationId,
|
||||||
|
[FromBody] SetupBusinessUnitRequestBody requestBody)
|
||||||
|
{
|
||||||
|
var enableOrganizationBusinessUnitConversion =
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion);
|
||||||
|
|
||||||
|
if (!enableOrganizationBusinessUnitConversion)
|
||||||
|
{
|
||||||
|
return Error.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
return Error.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await currentContext.OrganizationUser(organizationId))
|
||||||
|
{
|
||||||
|
return Error.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerId = await businessUnitConverter.FinalizeConversion(
|
||||||
|
organization,
|
||||||
|
requestBody.UserId,
|
||||||
|
requestBody.Token,
|
||||||
|
requestBody.ProviderKey,
|
||||||
|
requestBody.OrganizationKey);
|
||||||
|
|
||||||
|
return TypedResults.Ok(providerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,10 +84,27 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
|
||||||
|
{
|
||||||
|
if (model.SponsoringUserId.HasValue)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(model.Notes))
|
||||||
|
{
|
||||||
|
model.Notes = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetUser = model.SponsoringUserId ?? _currentContext.UserId!.Value;
|
||||||
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
|
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
|
||||||
sponsoringOrg,
|
sponsoringOrg,
|
||||||
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
|
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, targetUser),
|
||||||
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName);
|
model.PlanSponsorshipType,
|
||||||
|
model.SponsoredEmail,
|
||||||
|
model.FriendlyName,
|
||||||
|
model.Notes);
|
||||||
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
|
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -409,9 +409,9 @@ public class OrganizationsController(
|
|||||||
organizationId,
|
organizationId,
|
||||||
OrganizationUserStatusType.Confirmed);
|
OrganizationUserStatusType.Confirmed);
|
||||||
|
|
||||||
var organizationIdsManagingActiveUser = (await userService.GetOrganizationsManagingUserAsync(userId))
|
var organizationIdsClaimingActiveUser = (await userService.GetOrganizationsClaimingUserAsync(userId))
|
||||||
.Select(o => o.Id);
|
.Select(o => o.Id);
|
||||||
|
|
||||||
return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsManagingActiveUser);
|
return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsClaimingActiveUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Models.Requests;
|
||||||
|
|
||||||
|
public class SetupBusinessUnitRequestBody
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Token { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string ProviderKey { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string OrganizationKey { get; set; }
|
||||||
|
}
|
@ -1,10 +1,10 @@
|
|||||||
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;
|
||||||
using Bit.Core.Auth.Models.Api.Response;
|
using Bit.Core.Auth.Models.Api.Response;
|
||||||
|
using Bit.Core.Auth.UserFeatures.DeviceTrust;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@ -22,6 +22,7 @@ public class DevicesController : Controller
|
|||||||
private readonly IDeviceRepository _deviceRepository;
|
private readonly IDeviceRepository _deviceRepository;
|
||||||
private readonly IDeviceService _deviceService;
|
private readonly IDeviceService _deviceService;
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
|
private readonly IUntrustDevicesCommand _untrustDevicesCommand;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly ILogger<DevicesController> _logger;
|
private readonly ILogger<DevicesController> _logger;
|
||||||
@ -30,6 +31,7 @@ public class DevicesController : Controller
|
|||||||
IDeviceRepository deviceRepository,
|
IDeviceRepository deviceRepository,
|
||||||
IDeviceService deviceService,
|
IDeviceService deviceService,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
|
IUntrustDevicesCommand untrustDevicesCommand,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
ILogger<DevicesController> logger)
|
ILogger<DevicesController> logger)
|
||||||
@ -37,6 +39,7 @@ public class DevicesController : Controller
|
|||||||
_deviceRepository = deviceRepository;
|
_deviceRepository = deviceRepository;
|
||||||
_deviceService = deviceService;
|
_deviceService = deviceService;
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
|
_untrustDevicesCommand = untrustDevicesCommand;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@ -125,7 +128,8 @@ public class DevicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{identifier}/retrieve-keys")]
|
[HttpPost("{identifier}/retrieve-keys")]
|
||||||
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier, [FromBody] SecretVerificationRequestModel model)
|
[Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")]
|
||||||
|
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
|
||||||
{
|
{
|
||||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
|
||||||
@ -134,14 +138,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();
|
||||||
@ -173,6 +170,19 @@ public class DevicesController : Controller
|
|||||||
model.OtherDevices ?? Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>());
|
model.OtherDevices ?? Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("untrust")]
|
||||||
|
public async Task PostUntrust([FromBody] UntrustDevicesRequestModel model)
|
||||||
|
{
|
||||||
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _untrustDevicesCommand.UntrustDevices(user, model.Devices);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("identifier/{identifier}/token")]
|
[HttpPut("identifier/{identifier}/token")]
|
||||||
[HttpPost("identifier/{identifier}/token")]
|
[HttpPost("identifier/{identifier}/token")]
|
||||||
public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model)
|
public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model)
|
||||||
|
@ -3,6 +3,7 @@ using Bit.Core.Context;
|
|||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -20,6 +21,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
|
|||||||
private readonly ICreateSponsorshipCommand _offerSponsorshipCommand;
|
private readonly ICreateSponsorshipCommand _offerSponsorshipCommand;
|
||||||
private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand;
|
private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public SelfHostedOrganizationSponsorshipsController(
|
public SelfHostedOrganizationSponsorshipsController(
|
||||||
ICreateSponsorshipCommand offerSponsorshipCommand,
|
ICreateSponsorshipCommand offerSponsorshipCommand,
|
||||||
@ -27,7 +29,8 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
|
|||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
ICurrentContext currentContext
|
ICurrentContext currentContext,
|
||||||
|
IFeatureService featureService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_offerSponsorshipCommand = offerSponsorshipCommand;
|
_offerSponsorshipCommand = offerSponsorshipCommand;
|
||||||
@ -36,15 +39,29 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
|
|||||||
_organizationSponsorshipRepository = organizationSponsorshipRepository;
|
_organizationSponsorshipRepository = organizationSponsorshipRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{sponsoringOrgId}/families-for-enterprise")]
|
[HttpPost("{sponsoringOrgId}/families-for-enterprise")]
|
||||||
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
|
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
|
||||||
{
|
{
|
||||||
|
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
|
||||||
|
{
|
||||||
|
if (model.SponsoringUserId.HasValue)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(model.Notes))
|
||||||
|
{
|
||||||
|
model.Notes = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _offerSponsorshipCommand.CreateSponsorshipAsync(
|
await _offerSponsorshipCommand.CreateSponsorshipAsync(
|
||||||
await _organizationRepository.GetByIdAsync(sponsoringOrgId),
|
await _organizationRepository.GetByIdAsync(sponsoringOrgId),
|
||||||
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
|
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, model.SponsoringUserId ?? _currentContext.UserId ?? default),
|
||||||
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName);
|
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName, model.Notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{sponsoringOrgId}")]
|
[HttpDelete("{sponsoringOrgId}")]
|
||||||
|
@ -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;
|
||||||
@ -23,7 +24,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace Bit.Api.KeyManagement.Controllers;
|
namespace Bit.Api.KeyManagement.Controllers;
|
||||||
|
|
||||||
[Route("accounts/key-management")]
|
[Route("accounts")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class AccountsKeyManagementController : Controller
|
public class AccountsKeyManagementController : Controller
|
||||||
{
|
{
|
||||||
@ -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,9 +74,10 @@ public class AccountsKeyManagementController : Controller
|
|||||||
_emergencyAccessValidator = emergencyAccessValidator;
|
_emergencyAccessValidator = emergencyAccessValidator;
|
||||||
_organizationUserValidator = organizationUserValidator;
|
_organizationUserValidator = organizationUserValidator;
|
||||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||||
|
_deviceValidator = deviceValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("regenerate-keys")]
|
[HttpPost("key-management/regenerate-keys")]
|
||||||
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
|
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
|
||||||
{
|
{
|
||||||
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
|
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
|
||||||
@ -89,7 +93,7 @@ public class AccountsKeyManagementController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpPost("rotate-user-account-keys")]
|
[HttpPost("key-management/rotate-user-account-keys")]
|
||||||
public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model)
|
public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model)
|
||||||
{
|
{
|
||||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
@ -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),
|
||||||
@ -128,4 +133,50 @@ public class AccountsKeyManagementController : Controller
|
|||||||
|
|
||||||
throw new BadRequestException(ModelState);
|
throw new BadRequestException(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("set-key-connector-key")]
|
||||||
|
public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model)
|
||||||
|
{
|
||||||
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var error in result.Errors)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, error.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("convert-to-key-connector")]
|
||||||
|
public async Task PostConvertToKeyConnectorAsync()
|
||||||
|
{
|
||||||
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _userService.ConvertToKeyConnectorAsync(user);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var error in result.Errors)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, error.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(ModelState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
namespace Bit.Api.Auth.Models.Request.Accounts;
|
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||||
|
|
||||||
public class SetKeyConnectorKeyRequestModel
|
public class SetKeyConnectorKeyRequestModel
|
||||||
{
|
{
|
@ -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)
|
||||||
|
@ -16,4 +16,14 @@ public class OrganizationSponsorshipCreateRequestModel
|
|||||||
|
|
||||||
[StringLength(256)]
|
[StringLength(256)]
|
||||||
public string FriendlyName { get; set; }
|
public string FriendlyName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (optional) The user to target for the sponsorship.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Left empty when creating a sponsorship for the authenticated user.</remarks>
|
||||||
|
public Guid? SponsoringUserId { get; set; }
|
||||||
|
|
||||||
|
[EncryptedString]
|
||||||
|
[EncryptedStringLength(512)]
|
||||||
|
public string Notes { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
namespace Bit.Api.Models.Response;
|
namespace Bit.Api.Models.Response;
|
||||||
|
|
||||||
@ -21,6 +22,8 @@ public class DeviceResponseModel : ResponseModel
|
|||||||
Identifier = device.Identifier;
|
Identifier = device.Identifier;
|
||||||
CreationDate = device.CreationDate;
|
CreationDate = device.CreationDate;
|
||||||
IsTrusted = device.IsTrusted();
|
IsTrusted = device.IsTrusted();
|
||||||
|
EncryptedUserKey = device.EncryptedUserKey;
|
||||||
|
EncryptedPublicKey = device.EncryptedPublicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@ -29,4 +32,10 @@ public class DeviceResponseModel : ResponseModel
|
|||||||
public string Identifier { get; set; }
|
public string Identifier { get; set; }
|
||||||
public DateTime CreationDate { get; set; }
|
public DateTime CreationDate { get; set; }
|
||||||
public bool IsTrusted { get; set; }
|
public bool IsTrusted { get; set; }
|
||||||
|
[EncryptedString]
|
||||||
|
[EncryptedStringLength(2000)]
|
||||||
|
public string EncryptedUserKey { get; set; }
|
||||||
|
[EncryptedString]
|
||||||
|
[EncryptedStringLength(2000)]
|
||||||
|
public string EncryptedPublicKey { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
bool twoFactorEnabled,
|
bool twoFactorEnabled,
|
||||||
bool premiumFromOrganization,
|
bool premiumFromOrganization,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser) : base("profile")
|
IEnumerable<Guid> organizationIdsClaimingUser) : base("profile")
|
||||||
{
|
{
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
AvatarColor = user.AvatarColor;
|
AvatarColor = user.AvatarColor;
|
||||||
CreationDate = user.CreationDate;
|
CreationDate = user.CreationDate;
|
||||||
VerifyDevices = user.VerifyDevices;
|
VerifyDevices = user.VerifyDevices;
|
||||||
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser));
|
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
|
||||||
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
||||||
ProviderOrganizations =
|
ProviderOrganizations =
|
||||||
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
||||||
|
@ -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; }
|
||||||
|
@ -5,7 +5,7 @@ using Bit.Core.Settings;
|
|||||||
using AspNetCoreRateLimit;
|
using AspNetCoreRateLimit;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Duende.IdentityModel;
|
using IdentityModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.Auth.Models.Request;
|
using Bit.Api.Auth.Models.Request;
|
||||||
@ -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);
|
||||||
|
@ -77,10 +77,9 @@ public class ImportCiphersController : Controller
|
|||||||
|
|
||||||
//An User is allowed to import if CanCreate Collections or has AccessToImportExport
|
//An User is allowed to import if CanCreate Collections or has AccessToImportExport
|
||||||
var authorized = await CheckOrgImportPermission(collections, orgId);
|
var authorized = await CheckOrgImportPermission(collections, orgId);
|
||||||
|
|
||||||
if (!authorized)
|
if (!authorized)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new BadRequestException("Not enough privileges to import into this organization.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
@ -103,21 +102,59 @@ public class ImportCiphersController : Controller
|
|||||||
.Select(c => c.Id)
|
.Select(c => c.Id)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
//We need to verify if the user is trying to import into existing collections
|
// when there are no collections, then we can import
|
||||||
var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id));
|
if (collections.Count == 0)
|
||||||
|
|
||||||
//When importing into existing collection, we need to verify if the user has permissions
|
|
||||||
if (existingCollections.Any() && !(await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
|
|
||||||
{
|
{
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
//Users allowed to import if they CanCreate Collections
|
|
||||||
if (!(await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// are we trying to import into existing collections?
|
||||||
|
var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id));
|
||||||
|
|
||||||
|
// are we trying to create new collections?
|
||||||
|
var hasNewCollections = collections.Any(tc => !orgCollectionIds.Contains(tc.Id));
|
||||||
|
|
||||||
|
// suppose we have both new and existing collections
|
||||||
|
if (hasNewCollections && existingCollections.Any())
|
||||||
|
{
|
||||||
|
// since we are creating new collection, user must have import/manage and create collection permission
|
||||||
|
if ((await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded
|
||||||
|
&& (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
|
||||||
|
{
|
||||||
|
// can import collections and create new ones
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// user does not have permission to import
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// suppose we have new collections and none of our collections exist
|
||||||
|
if (hasNewCollections && !existingCollections.Any())
|
||||||
|
{
|
||||||
|
// user is trying to create new collections
|
||||||
|
// we need to check if the user has permission to create collections
|
||||||
|
if ((await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// user does not have permission to create new collections
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// in many import formats, we don't create collections, we just import ciphers into an existing collection
|
||||||
|
|
||||||
|
// When importing, we need to verify if the user has ImportCiphers permission
|
||||||
|
if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Api.Tools.Authorization;
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
|
using Bit.Api.Tools.Authorization;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
@ -105,5 +106,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IAuthorizationHandler, VaultExportAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, VaultExportAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, SecurityTaskAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, SecurityTaskAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, SecurityTaskOrganizationAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, SecurityTaskOrganizationAuthorizationHandler>();
|
||||||
|
|
||||||
|
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -177,12 +177,7 @@ public class CiphersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _cipherService.SaveDetailsAsync(cipher, user.Id, model.Cipher.LastKnownRevisionDate, model.CollectionIds, cipher.OrganizationId.HasValue);
|
await _cipherService.SaveDetailsAsync(cipher, user.Id, model.Cipher.LastKnownRevisionDate, model.CollectionIds, cipher.OrganizationId.HasValue);
|
||||||
var response = new CipherResponseModel(
|
return await Get(cipher.Id);
|
||||||
cipher,
|
|
||||||
user,
|
|
||||||
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
|
|
||||||
_globalSettings);
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("admin")]
|
[HttpPost("admin")]
|
||||||
@ -1091,9 +1086,9 @@ public class CiphersController : Controller
|
|||||||
throw new BadRequestException(ModelState);
|
throw new BadRequestException(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
// If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
&& await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details.");
|
throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
}
|
}
|
||||||
|
@ -104,13 +104,13 @@ public class SyncController : Controller
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id);
|
var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id);
|
||||||
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id);
|
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
|
||||||
|
|
||||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||||
|
|
||||||
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
||||||
organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
||||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
bool userTwoFactorEnabled,
|
bool userTwoFactorEnabled,
|
||||||
bool userHasPremiumFromOrganization,
|
bool userHasPremiumFromOrganization,
|
||||||
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser,
|
IEnumerable<Guid> organizationIdsClaimingingUser,
|
||||||
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
||||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
@ -37,7 +37,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
: base("sync")
|
: base("sync")
|
||||||
{
|
{
|
||||||
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser);
|
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser);
|
||||||
Folders = folders.Select(f => new FolderResponseModel(f));
|
Folders = folders.Select(f => new FolderResponseModel(f));
|
||||||
Ciphers = ciphers.Select(cipher =>
|
Ciphers = ciphers.Select(cipher =>
|
||||||
new CipherDetailsResponseModel(
|
new CipherDetailsResponseModel(
|
||||||
|
@ -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' " />
|
||||||
|
@ -16,6 +16,12 @@ public interface IStripeFacade
|
|||||||
RequestOptions requestOptions = null,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<Customer> UpdateCustomer(
|
||||||
|
string customerId,
|
||||||
|
CustomerUpdateOptions customerUpdateOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<Event> GetEvent(
|
Task<Event> GetEvent(
|
||||||
string eventId,
|
string eventId,
|
||||||
EventGetOptions eventGetOptions = null,
|
EventGetOptions eventGetOptions = null,
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
using Bit.Billing.Constants;
|
using Bit.Billing.Constants;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Services;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Event = Stripe.Event;
|
using Event = Stripe.Event;
|
||||||
|
|
||||||
@ -10,20 +16,124 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
|
|||||||
private readonly IStripeEventService _stripeEventService;
|
private readonly IStripeEventService _stripeEventService;
|
||||||
private readonly IStripeFacade _stripeFacade;
|
private readonly IStripeFacade _stripeFacade;
|
||||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
private readonly IProviderRepository _providerRepository;
|
||||||
|
|
||||||
public PaymentMethodAttachedHandler(
|
public PaymentMethodAttachedHandler(
|
||||||
ILogger<PaymentMethodAttachedHandler> logger,
|
ILogger<PaymentMethodAttachedHandler> logger,
|
||||||
IStripeEventService stripeEventService,
|
IStripeEventService stripeEventService,
|
||||||
IStripeFacade stripeFacade,
|
IStripeFacade stripeFacade,
|
||||||
IStripeEventUtilityService stripeEventUtilityService)
|
IStripeEventUtilityService stripeEventUtilityService,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IProviderRepository providerRepository)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_stripeEventService = stripeEventService;
|
_stripeEventService = stripeEventService;
|
||||||
_stripeFacade = stripeFacade;
|
_stripeFacade = stripeFacade;
|
||||||
_stripeEventUtilityService = stripeEventUtilityService;
|
_stripeEventUtilityService = stripeEventUtilityService;
|
||||||
|
_featureService = featureService;
|
||||||
|
_providerRepository = providerRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task HandleAsync(Event parsedEvent)
|
public async Task HandleAsync(Event parsedEvent)
|
||||||
|
{
|
||||||
|
var updateMSPToChargeAutomatically =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically);
|
||||||
|
|
||||||
|
if (updateMSPToChargeAutomatically)
|
||||||
|
{
|
||||||
|
await HandleVNextAsync(parsedEvent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await HandleVCurrentAsync(parsedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleVNextAsync(Event parsedEvent)
|
||||||
|
{
|
||||||
|
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent, true, ["customer.subscriptions.data.latest_invoice"]);
|
||||||
|
|
||||||
|
if (paymentMethod == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = paymentMethod.Customer;
|
||||||
|
var subscriptions = customer?.Subscriptions;
|
||||||
|
|
||||||
|
// This represents a provider subscription set to "send_invoice" that was paid using a Stripe hosted invoice payment page.
|
||||||
|
var invoicedProviderSubscription = subscriptions?.Data.FirstOrDefault(subscription =>
|
||||||
|
subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.ProviderId) &&
|
||||||
|
subscription.Status != StripeConstants.SubscriptionStatus.Canceled &&
|
||||||
|
subscription.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If we have an invoiced provider subscription where the customer hasn't been marked as invoice-approved,
|
||||||
|
* we need to try and set the default payment method and update the collection method to be "charge_automatically".
|
||||||
|
*/
|
||||||
|
if (invoicedProviderSubscription != null &&
|
||||||
|
!customer.ApprovedToPayByInvoice() &&
|
||||||
|
Guid.TryParse(invoicedProviderSubscription.Metadata[StripeConstants.MetadataKeys.ProviderId], out var providerId))
|
||||||
|
{
|
||||||
|
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||||
|
|
||||||
|
if (provider is { Type: ProviderType.Msp })
|
||||||
|
{
|
||||||
|
if (customer.InvoiceSettings.DefaultPaymentMethodId != paymentMethod.Id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _stripeFacade.UpdateCustomer(customer.Id,
|
||||||
|
new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||||
|
{
|
||||||
|
DefaultPaymentMethod = paymentMethod.Id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception,
|
||||||
|
"Failed to set customer's ({CustomerID}) default payment method during 'payment_method.attached' webhook",
|
||||||
|
customer.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _stripeFacade.UpdateSubscription(invoicedProviderSubscription.Id,
|
||||||
|
new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception,
|
||||||
|
"Failed to set subscription's ({SubscriptionID}) collection method to 'charge_automatically' during 'payment_method.attached' webhook",
|
||||||
|
customer.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unpaidSubscriptions = subscriptions?.Data.Where(subscription =>
|
||||||
|
subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList();
|
||||||
|
|
||||||
|
if (unpaidSubscriptions == null || unpaidSubscriptions.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var unpaidSubscription in unpaidSubscriptions)
|
||||||
|
{
|
||||||
|
await AttemptToPayOpenSubscriptionAsync(unpaidSubscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleVCurrentAsync(Event parsedEvent)
|
||||||
{
|
{
|
||||||
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
|
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
|
||||||
if (paymentMethod is null)
|
if (paymentMethod is null)
|
||||||
|
@ -33,6 +33,13 @@ public class StripeFacade : IStripeFacade
|
|||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
|
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<Customer> UpdateCustomer(
|
||||||
|
string customerId,
|
||||||
|
CustomerUpdateOptions customerUpdateOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
await _customerService.UpdateAsync(customerId, customerUpdateOptions, requestOptions, cancellationToken);
|
||||||
|
|
||||||
public async Task<Invoice> GetInvoice(
|
public async Task<Invoice> GetInvoice(
|
||||||
string invoiceId,
|
string invoiceId,
|
||||||
InvoiceGetOptions invoiceGetOptions = null,
|
InvoiceGetOptions invoiceGetOptions = null,
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using Bit.Billing.Constants;
|
using Bit.Billing.Constants;
|
||||||
using Bit.Billing.Jobs;
|
using Bit.Billing.Jobs;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
@ -24,7 +23,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
private readonly IPushNotificationService _pushNotificationService;
|
private readonly IPushNotificationService _pushNotificationService;
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
private readonly ISchedulerFactory _schedulerFactory;
|
private readonly ISchedulerFactory _schedulerFactory;
|
||||||
private readonly IFeatureService _featureService;
|
|
||||||
private readonly IOrganizationEnableCommand _organizationEnableCommand;
|
private readonly IOrganizationEnableCommand _organizationEnableCommand;
|
||||||
private readonly IOrganizationDisableCommand _organizationDisableCommand;
|
private readonly IOrganizationDisableCommand _organizationDisableCommand;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
@ -39,7 +37,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
IPushNotificationService pushNotificationService,
|
IPushNotificationService pushNotificationService,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
ISchedulerFactory schedulerFactory,
|
ISchedulerFactory schedulerFactory,
|
||||||
IFeatureService featureService,
|
|
||||||
IOrganizationEnableCommand organizationEnableCommand,
|
IOrganizationEnableCommand organizationEnableCommand,
|
||||||
IOrganizationDisableCommand organizationDisableCommand,
|
IOrganizationDisableCommand organizationDisableCommand,
|
||||||
IPricingClient pricingClient)
|
IPricingClient pricingClient)
|
||||||
@ -53,7 +50,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
_pushNotificationService = pushNotificationService;
|
_pushNotificationService = pushNotificationService;
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_schedulerFactory = schedulerFactory;
|
_schedulerFactory = schedulerFactory;
|
||||||
_featureService = featureService;
|
|
||||||
_organizationEnableCommand = organizationEnableCommand;
|
_organizationEnableCommand = organizationEnableCommand;
|
||||||
_organizationDisableCommand = organizationDisableCommand;
|
_organizationDisableCommand = organizationDisableCommand;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
@ -227,12 +223,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
|
|
||||||
private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId)
|
private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId)
|
||||||
{
|
{
|
||||||
var isResellerManagedOrgAlertEnabled = _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert);
|
|
||||||
if (!isResellerManagedOrgAlertEnabled)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var scheduler = await _schedulerFactory.GetScheduler();
|
var scheduler = await _schedulerFactory.GetScheduler();
|
||||||
|
|
||||||
var job = JobBuilder.Create<SubscriptionCancellationJob>()
|
var job = JobBuilder.Create<SubscriptionCancellationJob>()
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -114,6 +114,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool UseRiskInsights { get; set; }
|
public bool UseRiskInsights { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If set to true, admins can initiate organization-issued sponsorships.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
|
|
||||||
public void SetNewId()
|
public void SetNewId()
|
||||||
{
|
{
|
||||||
if (Id == default(Guid))
|
if (Id == default(Guid))
|
||||||
@ -313,5 +318,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();
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,6 @@ public enum ProviderType : byte
|
|||||||
Msp = 0,
|
Msp = 0,
|
||||||
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Creates Bitwarden Portal page for client organization billing management", Order = 1000)]
|
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Creates Bitwarden Portal page for client organization billing management", Order = 1000)]
|
||||||
Reseller = 1,
|
Reseller = 1,
|
||||||
[Display(ShortName = "MOE", Name = "Multi-organization Enterprises", Description = "Creates provider portal for multi-organization management", Order = 1)]
|
[Display(ShortName = "Business Unit", Name = "Business Unit", Description = "Creates provider portal for business unit management", Order = 1)]
|
||||||
MultiOrganizationEnterprise = 2,
|
BusinessUnit = 2,
|
||||||
}
|
}
|
||||||
|
@ -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.";
|
||||||
|
};
|
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