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

Merge branch 'main' into auth/pm-19691/remove-duo-redirect-flag

This commit is contained in:
Todd Martin 2025-04-07 21:43:47 -04:00
commit d38ebbe9ad
No known key found for this signature in database
GPG Key ID: 663E7AF5C839BC8F
232 changed files with 12099 additions and 2083 deletions

10
.github/CODEOWNERS vendored
View File

@ -20,12 +20,19 @@
# Database Operations for database changes # Database Operations for database changes
src/Sql/** @bitwarden/dept-dbops src/Sql/** @bitwarden/dept-dbops
util/EfShared/** @bitwarden/dept-dbops util/EfShared/** @bitwarden/dept-dbops
util/Migrator/** @bitwarden/dept-dbops util/Migrator/** @bitwarden/team-platform-dev # The Platform team owns the Migrator project code
util/Migrator/DbScripts/** @bitwarden/dept-dbops
util/Migrator/DbScripts_finalization/** @bitwarden/dept-dbops
util/Migrator/DbScripts_transition/** @bitwarden/dept-dbops
util/Migrator/MySql/** @bitwarden/dept-dbops
util/MySqlMigrations/** @bitwarden/dept-dbops util/MySqlMigrations/** @bitwarden/dept-dbops
util/PostgresMigrations/** @bitwarden/dept-dbops util/PostgresMigrations/** @bitwarden/dept-dbops
util/SqlServerEFScaffold/** @bitwarden/dept-dbops util/SqlServerEFScaffold/** @bitwarden/dept-dbops
util/SqliteMigrations/** @bitwarden/dept-dbops util/SqliteMigrations/** @bitwarden/dept-dbops
# Shared util projects
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
# Auth team # Auth team
**/Auth @bitwarden/team-auth-dev **/Auth @bitwarden/team-auth-dev
bitwarden_license/src/Sso @bitwarden/team-auth-dev bitwarden_license/src/Sso @bitwarden/team-auth-dev
@ -66,6 +73,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
# Platform team # Platform team
.github/workflows/build.yml @bitwarden/team-platform-dev .github/workflows/build.yml @bitwarden/team-platform-dev
.github/workflows/build_target.yml @bitwarden/team-platform-dev
.github/workflows/cleanup-after-pr.yml @bitwarden/team-platform-dev .github/workflows/cleanup-after-pr.yml @bitwarden/team-platform-dev
.github/workflows/cleanup-rc-branch.yml @bitwarden/team-platform-dev .github/workflows/cleanup-rc-branch.yml @bitwarden/team-platform-dev
.github/workflows/repository-management.yml @bitwarden/team-platform-dev .github/workflows/repository-management.yml @bitwarden/team-platform-dev

View File

@ -7,22 +7,18 @@ on:
- "main" - "main"
- "rc" - "rc"
- "hotfix-rc" - "hotfix-rc"
pull_request_target: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
workflow_call:
inputs: {}
env: env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs: jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs:
- check-run
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -40,6 +36,8 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs:
- lint - lint
outputs:
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -75,6 +73,14 @@ jobs:
base_path: ./bitwarden_license/src base_path: ./bitwarden_license/src
node: true node: true
steps: steps:
- name: Check secrets
id: check-secrets
env:
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
run: |
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
@ -134,6 +140,7 @@ jobs:
id-token: write id-token: write
needs: needs:
- build-artifacts - build-artifacts
if: ${{ needs.build-artifacts.outputs.has_secrets == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -227,7 +234,7 @@ jobs:
- name: Generate Docker image tag - name: Generate Docker image tag
id: tag id: tag
run: | run: |
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
else else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
@ -289,11 +296,11 @@ jobs:
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}" "GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign - name: Install Cosign
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Sign image with Cosign - name: Sign image with Cosign
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env: env:
DIGEST: ${{ steps.build-docker.outputs.digest }} DIGEST: ${{ steps.build-docker.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }} TAGS: ${{ steps.image-tags.outputs.tags }}
@ -343,7 +350,7 @@ jobs:
- name: Make Docker stubs - name: Make Docker stubs
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
# Set proper setup image based on branch # Set proper setup image based on branch
@ -385,7 +392,7 @@ jobs:
- name: Make Docker stub checksums - name: Make Docker stub checksums
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
@ -393,7 +400,7 @@ jobs:
- name: Upload Docker stub US artifact - name: Upload Docker stub US artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -403,7 +410,7 @@ jobs:
- name: Upload Docker stub EU artifact - name: Upload Docker stub EU artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -413,7 +420,7 @@ jobs:
- name: Upload Docker stub US checksum artifact - name: Upload Docker stub US checksum artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -423,7 +430,7 @@ jobs:
- name: Upload Docker stub EU checksum artifact - name: Upload Docker stub EU checksum artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -552,7 +559,7 @@ jobs:
self-host-build: self-host-build:
name: Trigger self-host build name: Trigger self-host build
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs:
@ -587,7 +594,7 @@ jobs:
trigger-k8s-deploy: trigger-k8s-deploy:
name: Trigger k8s deploy name: Trigger k8s deploy
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs:
- build-docker - build-docker
@ -623,7 +630,8 @@ jobs:
trigger-ee-updates: trigger-ee-updates:
name: Trigger Ephemeral Environment updates name: Trigger Ephemeral Environment updates
if: | if: |
github.event_name == 'pull_request_target' needs.build-artifacts.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
@ -660,7 +668,8 @@ jobs:
name: Trigger Ephemeral Environment Sync name: Trigger Ephemeral Environment Sync
needs: trigger-ee-updates needs: trigger-ee-updates
if: | if: |
github.event_name == 'pull_request_target' needs.build-artifacts.outputs.has_secrets == 'true'
&& 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:
@ -670,7 +679,6 @@ jobs:
pull_request_number: ${{ github.event.number }} pull_request_number: ${{ github.event.number }}
secrets: inherit secrets: inherit
check-failures: check-failures:
name: Check for failures name: Check for failures
if: always() if: always()
@ -686,7 +694,7 @@ jobs:
steps: steps:
- name: Check if any job failed - name: Check if any job failed
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure') && contains(needs.*.result, 'failure')
run: exit 1 run: exit 1

21
.github/workflows/build_target.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Build on PR Target
on:
pull_request_target:
types: [opened, synchronize]
defaults:
run:
shell: bash
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
run-workflow:
name: Run Build on PR Target
needs: check-run
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
uses: ./.github/workflows/build.yml
secrets: inherit

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2025.3.3</Version> <Version>2025.4.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -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}

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
@ -7,10 +8,12 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -28,6 +31,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly ISubscriberService _subscriberService; private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxStrategy _automaticTaxStrategy;
public RemoveOrganizationFromProviderCommand( public RemoveOrganizationFromProviderCommand(
IEventService eventService, IEventService eventService,
@ -40,7 +44,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient) IPricingClient pricingClient,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
{ {
_eventService = eventService; _eventService = eventService;
_mailService = mailService; _mailService = mailService;
@ -53,6 +58,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_subscriberService = subscriberService; _subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_automaticTaxStrategy = automaticTaxStrategy;
} }
public async Task RemoveOrganizationFromProvider( public async Task RemoveOrganizationFromProvider(
@ -107,10 +113,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
organization.IsValidClient() && organization.IsValidClient() &&
!string.IsNullOrEmpty(organization.GatewayCustomerId)) !string.IsNullOrEmpty(organization.GatewayCustomerId))
{ {
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{ {
Description = string.Empty, Description = string.Empty,
Email = organization.BillingEmail Email = organization.BillingEmail,
Expand = ["tax", "tax_ids"]
}); });
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
@ -120,7 +127,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Customer = organization.GatewayCustomerId, Customer = organization.GatewayCustomerId,
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30, DaysUntilDue = 30,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
{ "organizationId", organization.Id.ToString() } { "organizationId", organization.Id.ToString() }
@ -130,6 +136,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }] Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
}; };
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
_automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
}
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id; organization.GatewaySubscriptionId = subscription.Id;

View File

@ -14,6 +14,7 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
@ -22,6 +23,7 @@ using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using CsvHelper; using CsvHelper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
@ -29,10 +31,10 @@ namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService( public class ProviderBillingService(
IEventService eventService, IEventService eventService,
IFeatureService featureService,
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,
@ -40,7 +42,9 @@ public class ProviderBillingService(
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
ITaxService taxService) : IProviderBillingService ITaxService taxService,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
: IProviderBillingService
{ {
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)] [RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization( public async Task AddExistingOrganization(
@ -143,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
{ {
@ -180,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
@ -191,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)
{ {
@ -205,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);
} }
} }
@ -400,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);
@ -423,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:
@ -434,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:
@ -445,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);
} }
} }
@ -557,7 +550,8 @@ public class ProviderBillingService(
{ {
ArgumentNullException.ThrowIfNull(provider); ArgumentNullException.ThrowIfNull(provider);
var customer = await subscriberService.GetCustomerOrThrow(provider); var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
var customer = await subscriberService.GetCustomerOrThrow(provider, customerGetOptions);
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
@ -580,19 +574,17 @@ 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
}); });
} }
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id, Customer = customer.Id,
DaysUntilDue = 30, DaysUntilDue = 30,
@ -605,6 +597,15 @@ public class ProviderBillingService(
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
}; };
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
try try
{ {
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
@ -643,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
{ {
@ -694,7 +689,7 @@ public class ProviderBillingService(
{ {
Id = subscriptionItem.Id, Id = subscriptionItem.Id,
Price = priceId, Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum Quantity = updatedSeatMinimum
}); });
} }
} }
@ -702,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
{ {
@ -713,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);
} }
@ -726,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

View File

@ -0,0 +1,133 @@
// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
#nullable enable
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing;
using Bit.Core.Billing.Enums;
using Stripe;
namespace Bit.Commercial.Core.Billing;
public static class ProviderPriceAdapter
{
public static class MSP
{
public static class Active
{
public const string Enterprise = "provider-portal-enterprise-monthly-2025";
public const string Teams = "provider-portal-teams-monthly-2025";
}
public static class Legacy
{
public const string Enterprise = "password-manager-provider-portal-enterprise-monthly-2024";
public const string Teams = "password-manager-provider-portal-teams-monthly-2024";
public static readonly List<string> List = [Enterprise, Teams];
}
}
public static class BusinessUnit
{
public static class Active
{
public const string Annually = "business-unit-portal-enterprise-annually-2025";
public const string Monthly = "business-unit-portal-enterprise-monthly-2025";
}
public static class Legacy
{
public const string Annually = "password-manager-provider-portal-enterprise-annually-2024";
public const string Monthly = "password-manager-provider-portal-enterprise-monthly-2024";
public static readonly List<string> List = [Annually, Monthly];
}
}
/// <summary>
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> and <paramref name="subscription"/> to determine
/// whether the <paramref name="provider"/> is on active or legacy pricing and then returns a Stripe price ID for the provided
/// <paramref name="planType"/> based on that determination.
/// </summary>
/// <param name="provider">The provider to get the Stripe price ID for.</param>
/// <param name="subscription">The provider's subscription.</param>
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
public static string GetPriceId(
Provider provider,
Subscription subscription,
PlanType planType)
{
var priceIds = subscription.Items.Select(item => item.Price.Id);
var invalidPlanType =
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
return provider.Type switch
{
ProviderType.Msp => MSP.Legacy.List.Intersect(priceIds).Any()
? planType switch
{
PlanType.TeamsMonthly => MSP.Legacy.Teams,
PlanType.EnterpriseMonthly => MSP.Legacy.Enterprise,
_ => throw invalidPlanType
}
: planType switch
{
PlanType.TeamsMonthly => MSP.Active.Teams,
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
_ => throw invalidPlanType
},
ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
? planType switch
{
PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually,
PlanType.EnterpriseMonthly => BusinessUnit.Legacy.Monthly,
_ => throw invalidPlanType
}
: planType switch
{
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
_ => throw invalidPlanType
},
_ => throw new BillingException(
$"ProviderType {provider.Type} does not have any associated provider price IDs")
};
}
/// <summary>
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> to return the active Stripe price ID for the provided
/// <paramref name="planType"/>.
/// </summary>
/// <param name="provider">The provider to get the Stripe price ID for.</param>
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
public static string GetActivePriceId(
Provider provider,
PlanType planType)
{
var invalidPlanType =
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
return provider.Type switch
{
ProviderType.Msp => planType switch
{
PlanType.TeamsMonthly => MSP.Active.Teams,
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
_ => throw invalidPlanType
},
ProviderType.MultiOrganizationEnterprise => planType switch
{
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
_ => throw invalidPlanType
},
_ => throw new BillingException(
$"ProviderType {provider.Type} does not have any associated provider price IDs")
};
}
}

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -23,7 +24,7 @@ public class UsersController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IPatchUserCommand _patchUserCommand; private readonly IPatchUserCommand _patchUserCommand;
private readonly IPostUserCommand _postUserCommand; private readonly IPostUserCommand _postUserCommand;
private readonly ILogger<UsersController> _logger; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public UsersController( public UsersController(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@ -32,7 +33,7 @@ public class UsersController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPatchUserCommand patchUserCommand, IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand, IPostUserCommand postUserCommand,
ILogger<UsersController> logger) IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService; _organizationService = organizationService;
@ -40,7 +41,7 @@ public class UsersController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_patchUserCommand = patchUserCommand; _patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand; _postUserCommand = postUserCommand;
_logger = logger; _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -93,7 +94,7 @@ public class UsersController : Controller
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked) if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
{ {
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM); await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
} }
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked) else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
{ {

View File

@ -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();

View File

@ -1,4 +1,5 @@
using Bit.Core.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -11,15 +12,18 @@ public class PatchUserCommand : IPatchUserCommand
{ {
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly ILogger<PatchUserCommand> _logger; private readonly ILogger<PatchUserCommand> _logger;
public PatchUserCommand( public PatchUserCommand(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
ILogger<PatchUserCommand> logger) ILogger<PatchUserCommand> logger)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService; _organizationService = organizationService;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_logger = logger; _logger = logger;
} }
@ -71,7 +75,7 @@ public class PatchUserCommand : IPatchUserCommand
{ {
if (active && orgUser.Status == OrganizationUserStatusType.Revoked) if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
{ {
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM); await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
return true; return true;
} }
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked) else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)

View File

@ -1,39 +1,99 @@
using Bit.Core.Enums; #nullable enable
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Commands;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Scim.Context; using Bit.Scim.Context;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces; using Bit.Scim.Users.Interfaces;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
namespace Bit.Scim.Users; namespace Bit.Scim.Users;
public class PostUserCommand : IPostUserCommand public class PostUserCommand(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IPaymentService paymentService,
IScimContext scimContext,
IFeatureService featureService,
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
TimeProvider timeProvider,
IPricingClient pricingClient)
: IPostUserCommand
{ {
private readonly IOrganizationRepository _organizationRepository; public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IPaymentService _paymentService;
private readonly IScimContext _scimContext;
public PostUserCommand(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IPaymentService paymentService,
IScimContext scimContext)
{ {
_organizationRepository = organizationRepository; if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
_organizationUserRepository = organizationUserRepository; {
_organizationService = organizationService; return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
_paymentService = paymentService; }
_scimContext = scimContext;
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
} }
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model) private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
ScimUserRequestModel model,
Guid organizationId,
ScimProviderType scimProvider)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization is null)
{
throw new NotFoundException();
}
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var request = model.ToRequest(
scimProvider: scimProvider,
inviteOrganization: new InviteOrganization(organization, plan),
performedAt: timeProvider.GetUtcNow());
var orgUsers = await organizationUserRepository
.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
if (orgUsers.Any(existingUser =>
request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) ||
request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase)))
{
throw new ConflictException("User already exists.");
}
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
var invitedOrganizationUserId = result switch
{
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors
.Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null,
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
_ => throw new InvalidOperationException()
};
var organizationUser = invitedOrganizationUserId.HasValue
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
: null;
return organizationUser;
}
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
ScimUserRequestModel model,
Guid organizationId,
ScimProviderType scimProvider)
{ {
var scimProvider = _scimContext.RequestScimProvider;
var invite = model.ToOrganizationUserInvite(scimProvider); var invite = model.ToOrganizationUserInvite(scimProvider);
var email = invite.Emails.Single(); var email = invite.Emails.Single();
@ -44,7 +104,7 @@ public class PostUserCommand : IPostUserCommand
throw new BadRequestException(); throw new BadRequestException();
} }
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email); var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
if (orgUserByEmail != null) if (orgUserByEmail != null)
{ {
@ -57,13 +117,21 @@ public class PostUserCommand : IPostUserCommand
throw new ConflictException(); throw new ConflictException();
} }
var organization = await _organizationRepository.GetByIdAsync(organizationId); var organization = await organizationRepository.GetByIdAsync(organizationId);
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
if (organization == null)
{
throw new NotFoundException();
}
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
invite.AccessSecretsManager = hasStandaloneSecretsManager; invite.AccessSecretsManager = hasStandaloneSecretsManager;
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
invite, externalId); EventSystemUser.SCIM,
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); invite,
externalId);
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
return orgUser; return orgUser;
} }

View File

@ -228,6 +228,26 @@ public class RemoveOrganizationFromProviderCommandTests
Id = "subscription_id" Id = "subscription_id"
}); });
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
options.Metadata["organizationId"] == organization.Id.ToString() &&
options.OffSession == true &&
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
options.Items.First().Quantity == organization.Seats)
, Arg.Any<Customer>()))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>

View File

@ -4,6 +4,7 @@ using Bit.Commercial.Core.Billing;
using Bit.Commercial.Core.Billing.Models; using Bit.Commercial.Core.Billing.Models;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
@ -115,6 +116,8 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange // Arrange
provider.Type = ProviderType.MultiOrganizationEnterprise;
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var existingPlan = new ProviderPlan var existingPlan = new ProviderPlan
{ {
@ -132,10 +135,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
.Returns(StaticStore.GetPlan(existingPlan.PlanType)); .Returns(StaticStore.GetPlan(existingPlan.PlanType));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
stripeAdapter.ProviderSubscriptionGetAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is(provider.Id))
.Returns(new Subscription .Returns(new Subscription
{ {
Id = provider.GatewaySubscriptionId, Id = provider.GatewaySubscriptionId,
@ -158,7 +158,7 @@ public class ProviderBillingServiceTests
}); });
var command = var command =
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId); new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
.Returns(StaticStore.GetPlan(command.NewPlan)); .Returns(StaticStore.GetPlan(command.NewPlan));
@ -170,6 +170,8 @@ public class ProviderBillingServiceTests
await providerPlanRepository.Received(1) await providerPlanRepository.Received(1)
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly)); .ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1) await stripeAdapter.Received(1)
.SubscriptionUpdateAsync( .SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId), Arg.Is(provider.GatewaySubscriptionId),
@ -405,6 +407,23 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
new SubscriptionItem
{
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
}
]
}
};
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 50 seats currently assigned with a seat minimum of 100 // 50 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
@ -427,11 +446,9 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum // 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
await sutProvider.GetDependency<IPaymentService>().DidNotReceiveWithAnyArgs().AdjustSeats( await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(
Arg.Any<Provider>(), Arg.Any<string>(),
Arg.Any<Bit.Core.Models.StaticStore.Plan>(), Arg.Any<SubscriptionUpdateOptions>());
Arg.Any<int>(),
Arg.Any<int>());
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
pPlan => pPlan.AllocatedSeats == 60)); pPlan => pPlan.AllocatedSeats == 60));
@ -474,6 +491,23 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
new SubscriptionItem
{
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
}
]
}
};
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 95 seats currently assigned with a seat minimum of 100 // 95 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
@ -496,11 +530,12 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 95 current + 10 seat scale = 105 seats, 5 above the minimum // 95 current + 10 seat scale = 105 seats, 5 above the minimum
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats( await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
provider, provider.GatewaySubscriptionId,
StaticStore.GetPlan(providerPlan.PlanType), Arg.Is<SubscriptionUpdateOptions>(
providerPlan.SeatMinimum!.Value, options =>
105); options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
options.Items.First().Quantity == 105));
// 105 total seats - 100 minimum = 5 purchased seats // 105 total seats - 100 minimum = 5 purchased seats
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
@ -544,6 +579,23 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
new SubscriptionItem
{
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
}
]
}
};
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100 // 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
@ -566,11 +618,12 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 110 current + 10 seat scale up = 120 seats // 110 current + 10 seat scale up = 120 seats
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats( await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
provider, provider.GatewaySubscriptionId,
StaticStore.GetPlan(providerPlan.PlanType), Arg.Is<SubscriptionUpdateOptions>(
110, options =>
120); options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
options.Items.First().Quantity == 120));
// 120 total seats - 100 seat minimum = 20 purchased seats // 120 total seats - 100 seat minimum = 20 purchased seats
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
@ -614,6 +667,23 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
new SubscriptionItem
{
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
}
]
}
};
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100 // 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
@ -636,11 +706,12 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30); await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum. // 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats( await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
provider, provider.GatewaySubscriptionId,
StaticStore.GetPlan(providerPlan.PlanType), Arg.Is<SubscriptionUpdateOptions>(
110, options =>
providerPlan.SeatMinimum!.Value); options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
options.Items.First().Quantity == providerPlan.SeatMinimum!.Value));
// Being below the seat minimum means no purchased seats. // Being below the seat minimum means no purchased seats.
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
@ -924,11 +995,15 @@ public class ProviderBillingServiceTests
{ {
provider.GatewaySubscriptionId = null; provider.GatewaySubscriptionId = null;
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer sutProvider.GetDependency<ISubscriberService>()
{ .GetCustomerOrThrow(
Id = "customer_id", provider,
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
}); .Returns(new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
});
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -973,13 +1048,18 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider) Provider provider)
{ {
provider.Type = ProviderType.Msp;
provider.GatewaySubscriptionId = null; provider.GatewaySubscriptionId = null;
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}); };
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
provider,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1012,11 +1092,21 @@ 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>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>( sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub => sub =>
sub.AutomaticTax.Enabled == true && sub.AutomaticTax.Enabled == true &&
@ -1024,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 &&
@ -1048,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)
@ -1068,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>();
@ -1097,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>
{ {
@ -1116,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)
@ -1149,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>();
@ -1178,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>
{ {
@ -1195,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)
@ -1228,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>();
@ -1257,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>
{ {
@ -1274,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)
@ -1301,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>();
@ -1330,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>
{ {
@ -1347,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)
@ -1380,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>();
@ -1409,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>
{ {
@ -1426,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)

View File

@ -0,0 +1,151 @@
using Bit.Commercial.Core.Billing;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Stripe;
using Xunit;
namespace Bit.Commercial.Core.Test.Billing;
public class ProviderPriceAdapterTests
{
[Theory]
[InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)]
[InlineData("password-manager-provider-portal-teams-monthly-2024", PlanType.TeamsMonthly)]
public void GetPriceId_MSP_Legacy_Succeeds(string priceId, PlanType planType)
{
var provider = new Provider
{
Id = Guid.NewGuid(),
Type = ProviderType.Msp
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = priceId } }
]
}
};
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
Assert.Equal(result, priceId);
}
[Theory]
[InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
[InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)]
public void GetPriceId_MSP_Active_Succeeds(string priceId, PlanType planType)
{
var provider = new Provider
{
Id = Guid.NewGuid(),
Type = ProviderType.Msp
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = priceId } }
]
}
};
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
Assert.Equal(result, priceId);
}
[Theory]
[InlineData("password-manager-provider-portal-enterprise-annually-2024", PlanType.EnterpriseAnnually)]
[InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)]
public void GetPriceId_BusinessUnit_Legacy_Succeeds(string priceId, PlanType planType)
{
var provider = new Provider
{
Id = Guid.NewGuid(),
Type = ProviderType.MultiOrganizationEnterprise
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = priceId } }
]
}
};
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
Assert.Equal(result, priceId);
}
[Theory]
[InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)]
[InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
public void GetPriceId_BusinessUnit_Active_Succeeds(string priceId, PlanType planType)
{
var provider = new Provider
{
Id = Guid.NewGuid(),
Type = ProviderType.MultiOrganizationEnterprise
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = priceId } }
]
}
};
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
Assert.Equal(result, priceId);
}
[Theory]
[InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
[InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)]
public void GetActivePriceId_MSP_Succeeds(string priceId, PlanType planType)
{
var provider = new Provider
{
Id = Guid.NewGuid(),
Type = ProviderType.Msp
};
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
Assert.Equal(result, priceId);
}
[Theory]
[InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)]
[InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
public void GetActivePriceId_BusinessUnit_Succeeds(string priceId, PlanType planType)
{
var provider = new Provider
{
Id = Guid.NewGuid(),
Type = ProviderType.MultiOrganizationEnterprise
};
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
Assert.Equal(result, priceId);
}
}

View File

@ -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());
} }

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -43,7 +44,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
} }
[Theory] [Theory]
@ -71,7 +72,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
} }
[Theory] [Theory]
@ -147,7 +148,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM); await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
} }

View File

@ -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);

90
perf/load/sync.js Normal file
View File

@ -0,0 +1,90 @@
import http from "k6/http";
import { check, fail } from "k6";
import { authenticate } from "./helpers/auth.js";
const IDENTITY_URL = __ENV.IDENTITY_URL;
const API_URL = __ENV.API_URL;
const CLIENT_ID = __ENV.CLIENT_ID;
const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Sync",
},
},
scenarios: {
constant_load: {
executor: "constant-arrival-rate",
rate: 30,
timeUnit: "1m", // 0.5 requests / second
duration: "10m",
preAllocatedVUs: 5,
},
ramping_load: {
executor: "ramping-arrival-rate",
startRate: 30,
timeUnit: "1m", // 0.5 requests / second to start
stages: [
{ duration: "30s", target: 30 },
{ duration: "2m", target: 75 },
{ duration: "1m", target: 60 },
{ duration: "2m", target: 100 },
{ duration: "2m", target: 90 },
{ duration: "1m", target: 120 },
{ duration: "30s", target: 150 },
{ duration: "30s", target: 60 },
{ duration: "30s", target: 0 },
],
preAllocatedVUs: 20,
},
},
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<1200"],
},
};
export function setup() {
return authenticate(IDENTITY_URL, CLIENT_ID, AUTH_USERNAME, AUTH_PASSWORD);
}
export default function (data) {
const params = {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${data.access_token}`,
"X-ClientId": CLIENT_ID,
},
tags: { name: "Sync" },
};
const excludeDomains = Math.random() > 0.5;
const syncRes = http.get(`${API_URL}/sync?excludeDomains=${excludeDomains}`, params);
if (
!check(syncRes, {
"sync status is 200": (r) => r.status === 200,
})
) {
console.error(`Sync failed with status ${syncRes.status}: ${syncRes.body}`);
fail("sync status code was *not* 200");
}
if (syncRes.status === 200) {
const syncJson = syncRes.json();
check(syncJson, {
"sync response has profile": (j) => j.profile !== undefined,
"sync response has folders": (j) => Array.isArray(j.folders),
"sync response has collections": (j) => Array.isArray(j.collections),
"sync response has ciphers": (j) => Array.isArray(j.ciphers),
"sync response has policies": (j) => Array.isArray(j.policies),
"sync response has sends": (j) => Array.isArray(j.sends),
"sync response has correct object type": (j) => j.object === "sync"
});
}
}

View File

@ -300,8 +300,7 @@ public class ProvidersController : Controller
{ {
case ProviderType.Msp: case ProviderType.Msp:
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider.Id, provider,
provider.GatewaySubscriptionId,
[ [
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum), (Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum) (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
@ -314,15 +313,14 @@ public class ProvidersController : Controller
// 1. Change the plan and take over any old values. // 1. Change the plan and take over any old values.
var changeMoePlanCommand = new ChangeProviderPlanCommand( var changeMoePlanCommand = new ChangeProviderPlanCommand(
provider,
existingMoePlan.Id, existingMoePlan.Id,
model.Plan!.Value, model.Plan!.Value);
provider.GatewaySubscriptionId);
await _providerBillingService.ChangePlan(changeMoePlanCommand); await _providerBillingService.ChangePlan(changeMoePlanCommand);
// 2. Update the seat minimums. // 2. Update the seat minimums.
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider.Id, provider,
provider.GatewaySubscriptionId,
[ [
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value) (Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
]); ]);

View File

@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
@ -61,6 +62,7 @@ public class OrganizationUsersController : Controller
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public OrganizationUsersController( public OrganizationUsersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -86,7 +88,8 @@ public class OrganizationUsersController : Controller
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService, IFeatureService featureService,
IPricingClient pricingClient, IPricingClient pricingClient,
IConfirmOrganizationUserCommand confirmOrganizationUserCommand) IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -112,6 +115,7 @@ public class OrganizationUsersController : Controller
_featureService = featureService; _featureService = featureService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand; _confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -630,14 +634,14 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/restore")] [HttpPut("{id}/restore")]
public async Task RestoreAsync(Guid orgId, Guid id) public async Task RestoreAsync(Guid orgId, Guid id)
{ {
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _organizationService.RestoreUserAsync(orgUser, userId)); await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId));
} }
[HttpPatch("restore")] [HttpPatch("restore")]
[HttpPut("restore")] [HttpPut("restore")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _organizationService.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
} }
[HttpPatch("enable-secrets-manager")] [HttpPatch("enable-secrets-manager")]

View File

@ -13,7 +13,17 @@ public static class PolicyDetailResponses
{ {
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy)); throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
} }
return new PolicyDetailResponseModel(policy, await CanToggleState());
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId)); async Task<bool> CanToggleState()
{
if (!await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId))
{
return true;
}
return !policy.Enabled;
}
} }
} }

View File

@ -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;

View File

@ -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;
} }

View File

@ -76,6 +76,13 @@ public class OrganizationSponsorshipsController : Controller
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{ {
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId); var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync( var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
sponsoringOrg, sponsoringOrg,
@ -89,6 +96,14 @@ public class OrganizationSponsorshipsController : Controller
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId) public async Task ResendSponsorshipOffer(Guid sponsoringOrgId)
{ {
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
var sponsoringOrgUser = await _organizationUserRepository var sponsoringOrgUser = await _organizationUserRepository
.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default); .GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default);
@ -135,6 +150,14 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Can only redeem sponsorship for an organization you own."); throw new BadRequestException("Can only redeem sponsorship for an organization you own.");
} }
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(
model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
await _setUpSponsorshipCommand.SetUpSponsorshipAsync( await _setUpSponsorshipCommand.SetUpSponsorshipAsync(
sponsorship, sponsorship,
await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId)); await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId));

View File

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request; using Bit.Api.Models.Request;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Request;
@ -125,7 +124,7 @@ public class DevicesController : Controller
} }
[HttpPost("{identifier}/retrieve-keys")] [HttpPost("{identifier}/retrieve-keys")]
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier, [FromBody] SecretVerificationRequestModel model) public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
{ {
var user = await _userService.GetUserByPrincipalAsync(User); var user = await _userService.GetUserByPrincipalAsync(User);
@ -134,14 +133,7 @@ public class DevicesController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!await _userService.VerifySecretAsync(user, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id); var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
if (device == null) if (device == null)
{ {
throw new NotFoundException(); throw new NotFoundException();

View File

@ -8,6 +8,7 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Request;
using Bit.Core; using Bit.Core;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -43,6 +44,7 @@ public class AccountsKeyManagementController : Controller
_organizationUserValidator; _organizationUserValidator;
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
_webauthnKeyValidator; _webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
public AccountsKeyManagementController(IUserService userService, public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService, IFeatureService featureService,
@ -57,7 +59,8 @@ public class AccountsKeyManagementController : Controller
emergencyAccessValidator, emergencyAccessValidator,
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>> IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
organizationUserValidator, organizationUserValidator,
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator) IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
{ {
_userService = userService; _userService = userService;
_featureService = featureService; _featureService = featureService;
@ -71,6 +74,7 @@ public class AccountsKeyManagementController : Controller
_emergencyAccessValidator = emergencyAccessValidator; _emergencyAccessValidator = emergencyAccessValidator;
_organizationUserValidator = organizationUserValidator; _organizationUserValidator = organizationUserValidator;
_webauthnKeyValidator = webAuthnKeyValidator; _webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
} }
[HttpPost("regenerate-keys")] [HttpPost("regenerate-keys")]
@ -109,6 +113,7 @@ public class AccountsKeyManagementController : Controller
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData), EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData), OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData), WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
DeviceKeys = await _deviceValidator.ValidateAsync(user, model.AccountUnlockData.DeviceKeyUnlockData),
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers), Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders), Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),

View File

@ -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; }
} }

View 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;
}
}

View File

@ -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)

View File

@ -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; }

View File

@ -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);

View File

@ -16,6 +16,7 @@ using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.Permissions;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Queries; using Bit.Core.Vault.Queries;
@ -345,6 +346,77 @@ public class CiphersController : Controller
return await CanEditCiphersAsync(organizationId, cipherIds); return await CanEditCiphersAsync(organizationId, cipherIds);
} }
private async Task<bool> CanDeleteOrRestoreCipherAsAdminAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
{
return await CanEditCipherAsAdminAsync(organizationId, cipherIds);
}
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin", we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }))
{
// Are we a provider user? If so, we need to be sure we're not restricted
// Once the feature flag is removed, this check can be combined with the above
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
// Provider is restricted from editing ciphers, so we're not an "admin"
if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess))
{
return false;
}
// Provider is unrestricted, so we're an "admin", don't return early
}
else
{
// Not a provider or admin
return false;
}
}
// If the user can edit all ciphers for the organization, just check they all belong to the org
if (await CanEditAllCiphersAsync(organizationId))
{
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
// Ensure all requested ciphers are in orgCiphers
return cipherIds.All(c => orgCiphers.ContainsKey(c));
}
// The user cannot access any ciphers for the organization, we're done
if (!await CanAccessOrganizationCiphersAsync(organizationId))
{
return false;
}
var user = await _userService.GetUserByPrincipalAsync(User);
// Select all deletable ciphers for this user belonging to the organization
var deletableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(user.Id, true))
.Where(c => c.OrganizationId == organizationId && c.UserId == null).ToList();
// Special case for unassigned ciphers
if (await CanAccessUnassignedCiphersAsync(organizationId))
{
var unassignedCiphers =
(await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(
organizationId));
// Users that can access unassigned ciphers can also delete them
deletableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Manage = true }));
}
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
var deletableOrgCiphers = deletableOrgCipherList
.Where(c => NormalCipherPermissions.CanDelete(user, c, organizationAbility))
.ToDictionary(c => c.Id);
return cipherIds.All(c => deletableOrgCiphers.ContainsKey(c));
}
/// <summary> /// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062 /// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary> /// </summary>
@ -763,12 +835,12 @@ public class CiphersController : Controller
[HttpDelete("{id}/admin")] [HttpDelete("{id}/admin")]
[HttpPost("{id}/delete-admin")] [HttpPost("{id}/delete-admin")]
public async Task DeleteAdmin(string id) public async Task DeleteAdmin(Guid id)
{ {
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id)); var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue || if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -808,7 +880,7 @@ public class CiphersController : Controller
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList(); var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
if (string.IsNullOrWhiteSpace(model.OrganizationId) || if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds)) !await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -830,12 +902,12 @@ public class CiphersController : Controller
} }
[HttpPut("{id}/delete-admin")] [HttpPut("{id}/delete-admin")]
public async Task PutDeleteAdmin(string id) public async Task PutDeleteAdmin(Guid id)
{ {
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id)); var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue || if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -871,7 +943,7 @@ public class CiphersController : Controller
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList(); var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
if (string.IsNullOrWhiteSpace(model.OrganizationId) || if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds)) !await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -899,12 +971,12 @@ public class CiphersController : Controller
} }
[HttpPut("{id}/restore-admin")] [HttpPut("{id}/restore-admin")]
public async Task<CipherMiniResponseModel> PutRestoreAdmin(string id) public async Task<CipherMiniResponseModel> PutRestoreAdmin(Guid id)
{ {
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id)); var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue || if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -944,7 +1016,7 @@ public class CiphersController : Controller
var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i))); var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i)));
if (model.OrganizationId == default || !await CanEditCipherAsAdminAsync(model.OrganizationId, cipherIdsToRestore)) if (model.OrganizationId == default || !await CanDeleteOrRestoreCipherAsAdminAsync(model.OrganizationId, cipherIdsToRestore))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }

View File

@ -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' " />

View File

@ -1,8 +1,11 @@
using Bit.Core.AdminConsole.Repositories; using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
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.Services;
@ -12,6 +15,7 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
public class UpcomingInvoiceHandler( public class UpcomingInvoiceHandler(
IFeatureService featureService,
ILogger<StripeEventProcessor> logger, ILogger<StripeEventProcessor> logger,
IMailService mailService, IMailService mailService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -21,7 +25,8 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository, IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand) IValidateSponsorshipCommand validateSponsorshipCommand,
IAutomaticTaxFactory automaticTaxFactory)
: IUpcomingInvoiceHandler : IUpcomingInvoiceHandler
{ {
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
@ -136,6 +141,21 @@ public class UpcomingInvoiceHandler(
private async Task TryEnableAutomaticTaxAsync(Subscription subscription) private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
{ {
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id));
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
if (updateOptions == null)
{
return;
}
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
return;
}
if (subscription.AutomaticTax.Enabled || if (subscription.AutomaticTax.Enabled ||
!subscription.Customer.HasBillingLocation() || !subscription.Customer.HasBillingLocation() ||
await IsNonTaxableNonUSBusinessUseSubscription(subscription)) await IsNonTaxableNonUSBusinessUseSubscription(subscription))

View File

@ -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());
}); });

View File

@ -1,6 +0,0 @@
@{
ViewData["Title"] = "Index";
}
<h2>Index</h2>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,3 +0,0 @@
@using Bit.Billing
@using Bit.Billing.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}

View File

@ -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;
}

View File

@ -313,5 +313,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
UseSecretsManager = license.UseSecretsManager; UseSecretsManager = license.UseSecretsManager;
SmSeats = license.SmSeats; SmSeats = license.SmSeats;
SmServiceAccounts = license.SmServiceAccounts; SmServiceAccounts = license.SmServiceAccounts;
UseRiskInsights = license.UseRiskInsights;
} }
} }

View File

@ -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();
} }

View File

@ -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();
} }

View File

@ -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);
}

View 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.";
};

View File

@ -0,0 +1,35 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.AdminConsole.Models.Business;
public record InviteOrganization
{
public Guid OrganizationId { get; init; }
public int? Seats { get; init; }
public int? MaxAutoScaleSeats { get; init; }
public int? SmSeats { get; init; }
public int? SmMaxAutoScaleSeats { get; init; }
public Plan Plan { get; init; }
public string GatewayCustomerId { get; init; }
public string GatewaySubscriptionId { get; init; }
public bool UseSecretsManager { get; init; }
public InviteOrganization()
{
}
public InviteOrganization(Organization organization, Plan plan)
{
OrganizationId = organization.Id;
Seats = organization.Seats;
MaxAutoScaleSeats = organization.MaxAutoscaleSeats;
SmSeats = organization.SmSeats;
SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats;
Plan = plan;
GatewayCustomerId = organization.GatewayCustomerId;
GatewaySubscriptionId = organization.GatewaySubscriptionId;
UseSecretsManager = organization.UseSecretsManager;
}
}

View File

@ -0,0 +1,64 @@
using System.Text.Json.Nodes;
using Bit.Core.Enums;
#nullable enable
namespace Bit.Core.Models.Data.Organizations;
public class OrganizationIntegrationConfigurationDetails
{
public Guid Id { get; set; }
public Guid OrganizationIntegrationId { get; set; }
public IntegrationType IntegrationType { get; set; }
public EventType EventType { get; set; }
public string? Configuration { get; set; }
public string? IntegrationConfiguration { get; set; }
public string? Template { get; set; }
public JsonObject MergedConfiguration
{
get
{
var integrationJson = IntegrationConfigurationJson;
foreach (var kvp in ConfigurationJson)
{
integrationJson[kvp.Key] = kvp.Value?.DeepClone();
}
return integrationJson;
}
}
private JsonObject ConfigurationJson
{
get
{
try
{
var configuration = Configuration ?? string.Empty;
return JsonNode.Parse(configuration) as JsonObject ?? new JsonObject();
}
catch
{
return new JsonObject();
}
}
}
private JsonObject IntegrationConfigurationJson
{
get
{
try
{
var integration = IntegrationConfiguration ?? string.Empty;
return JsonNode.Parse(integration) as JsonObject ?? new JsonObject();
}
catch
{
return new JsonObject();
}
}
}
}

View File

@ -148,7 +148,8 @@ public class SelfHostedOrganizationDetails : Organization
LimitCollectionDeletion = LimitCollectionDeletion, LimitCollectionDeletion = LimitCollectionDeletion,
LimitItemDeletion = LimitItemDeletion, LimitItemDeletion = LimitItemDeletion,
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems, AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
Status = Status Status = Status,
UseRiskInsights = UseRiskInsights,
}; };
} }
} }

View File

@ -154,6 +154,12 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
} }
} }
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId))
{
throw new BadRequestException("Custom users can not delete admins.");
}
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged) if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged)
{ {
throw new BadRequestException("Member is not managed by the organization."); throw new BadRequestException("Member is not managed by the organization.");

View File

@ -0,0 +1,37 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.Exceptions;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
public static class ErrorMapper
{
/// <summary>
/// Maps the ErrorT to a Bit.Exception class.
/// </summary>
/// <param name="error"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public static Exception MapToBitException<T>(Error<T> error) =>
error switch
{
UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message),
_ => new BadRequestException(error.Message)
};
/// <summary>
/// This maps the ErrorT object to the Bit.Exception class.
///
/// This should be replaced by an IActionResult mapper when possible.
/// </summary>
/// <param name="errors"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public static Exception MapToBitException<T>(ICollection<Error<T>> errors) =>
errors switch
{
not null when errors.Count == 1 => MapToBitException(errors.First()),
not null when errors.Count > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))),
_ => new BadRequestException()
};
}

View File

@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
public record FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
{
public const string Code = "Failed to invite users";
}

View File

@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
public record NoUsersToInviteError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
{
public const string Code = "No users to invite";
}

View File

@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
public record UserAlreadyExistsError(ScimInviteOrganizationUsersResponse Response) : Error<ScimInviteOrganizationUsersResponse>(Code, Response)
{
public const string Code = "User already exists";
}

View File

@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Models.Commands;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
/// <summary>
/// Defines the contract for inviting organization users via SCIM (System for Cross-domain Identity Management).
/// Provides functionality for handling single email invitation requests within an organization context.
/// </summary>
public interface IInviteOrganizationUsersCommand
{
/// <summary>
/// Sends an invitation to add an organization user via SCIM (System for Cross-domain Identity Management) system.
/// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value.
/// Success will be the successful return object.
/// </summary>
/// <param name="request">
/// Contains the details for inviting a single organization user via email.
/// </param>
/// <returns>Response from InviteScimOrganiation<see cref="ScimInviteOrganizationUsersResponse"/></returns>
Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request);
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
/// <summary>
/// This is for sending the invite to an organization user.
/// </summary>
public interface ISendOrganizationInvitesCommand
{
/// <summary>
/// This sends emails out to organization users for a given organization.
/// </summary>
/// <param name="request"><see cref="SendInvitesRequest"/></param>
/// <returns></returns>
Task SendInvitesAsync(SendInvitesRequest request);
}

View File

@ -0,0 +1,282 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.Interfaces;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Commands;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Microsoft.Extensions.Logging;
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
public class InviteOrganizationUsersCommand(IEventService eventService,
IOrganizationUserRepository organizationUserRepository,
IInviteUsersValidator inviteUsersValidator,
IPaymentService paymentService,
IOrganizationRepository organizationRepository,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger<InviteOrganizationUsersCommand> logger,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderUserRepository providerUserRepository
) : IInviteOrganizationUsersCommand
{
public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached.";
public async Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request)
{
var result = await InviteOrganizationUsersAsync(request);
switch (result)
{
case Failure<InviteOrganizationUsersResponse> failure:
return new Failure<ScimInviteOrganizationUsersResponse>(
failure.Errors.Select(error => new Error<ScimInviteOrganizationUsersResponse>(error.Message,
new ScimInviteOrganizationUsersResponse
{
InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault()
})));
case Success<InviteOrganizationUsersResponse> success when success.Value.InvitedUsers.Any():
var user = success.Value.InvitedUsers.First();
await eventService.LogOrganizationUserEventAsync<IOrganizationUser>(
organizationUser: user,
type: EventType.OrganizationUser_Invited,
systemUser: EventSystemUser.SCIM,
date: request.PerformedAt.UtcDateTime);
return new Success<ScimInviteOrganizationUsersResponse>(new ScimInviteOrganizationUsersResponse
{
InvitedUser = user
});
default:
return new Failure<ScimInviteOrganizationUsersResponse>(
new InvalidResultTypeError<ScimInviteOrganizationUsersResponse>(
new ScimInviteOrganizationUsersResponse()));
}
}
private async Task<CommandResult<InviteOrganizationUsersResponse>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request)
{
var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray();
if (invitesToSend.Length == 0)
{
return new Failure<InviteOrganizationUsersResponse>(new NoUsersToInviteError(
new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId)));
}
var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest
{
Invites = invitesToSend.ToArray(),
InviteOrganization = request.InviteOrganization,
PerformedBy = request.PerformedBy,
PerformedAt = request.PerformedAt,
OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId),
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
});
if (validationResult is Invalid<InviteOrganizationUsersValidationRequest> invalid)
{
return invalid.MapToFailure(r => new InviteOrganizationUsersResponse(r));
}
var validatedRequest = validationResult as Valid<InviteOrganizationUsersValidationRequest>;
var organizationUserToInviteEntities = invitesToSend
.Select(x => x.MapToDataModel(request.PerformedAt, validatedRequest!.Value.InviteOrganization))
.ToArray();
var organization = await organizationRepository.GetByIdAsync(validatedRequest!.Value.InviteOrganization.OrganizationId);
try
{
await organizationUserRepository.CreateManyAsync(organizationUserToInviteEntities);
await AdjustPasswordManagerSeatsAsync(validatedRequest, organization);
await AdjustSecretsManagerSeatsAsync(validatedRequest);
await SendAdditionalEmailsAsync(validatedRequest, organization);
await SendInvitesAsync(organizationUserToInviteEntities, organization);
await PublishReferenceEventAsync(validatedRequest, organization);
}
catch (Exception ex)
{
logger.LogError(ex, FailedToInviteUsersError.Code);
await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats);
await RevertPasswordManagerChangesAsync(validatedRequest, organization);
return new Failure<InviteOrganizationUsersResponse>(
new FailedToInviteUsersError(
new InviteOrganizationUsersResponse(validatedRequest.Value)));
}
return new Success<InviteOrganizationUsersResponse>(
new InviteOrganizationUsersResponse(
invitedOrganizationUsers: organizationUserToInviteEntities.Select(x => x.OrganizationUser).ToArray(),
organizationId: organization!.Id));
}
private async Task<IEnumerable<OrganizationUserInvite>> FilterExistingUsersAsync(InviteOrganizationUsersRequest request)
{
var existingEmails = new HashSet<string>(await organizationUserRepository.SelectKnownEmailsAsync(
request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false),
StringComparer.OrdinalIgnoreCase);
return request.Invites
.Where(invite => !existingEmails.Contains(invite.Email))
.ToArray();
}
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0)
{
// When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add.
// However, this might lead to a problem if we don't actually update stripe but throw any ways.
// stripe could not be updated, and then we would decrement the number of seats in stripe accidentally.
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd;
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
await organizationRepository.ReplaceAsync(organization);
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
}
private async Task RevertSecretsManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization, int? initialSmSeats)
{
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
{
var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(
organization: organization,
plan: validatedResult.Value.InviteOrganization.Plan,
autoscaling: false)
{
SmSeats = initialSmSeats
};
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
}
}
private async Task PublishReferenceEventAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult,
Organization organization) =>
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext)
{
Users = validatedResult.Value.Invites.Length
});
private async Task SendInvitesAsync(IEnumerable<CreateOrganizationUser> users, Organization organization) =>
await sendOrganizationInvitesCommand.SendInvitesAsync(
new SendInvitesRequest(
users.Select(x => x.OrganizationUser),
organization));
private async Task SendAdditionalEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization);
}
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
{
return;
}
try
{
var ownerEmails = await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization);
await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats!.Value, ownerEmails);
}
catch (Exception ex)
{
logger.LogError(ex, IssueNotifyingOwnersOfSeatLimitReached);
}
}
private async Task<IEnumerable<string>> GetOwnerEmailAddressesAsync(InviteOrganization organization)
{
var providerOrganization = await providerOrganizationRepository
.GetByOrganizationId(organization.OrganizationId);
if (providerOrganization == null)
{
return (await organizationUserRepository
.GetManyByMinimumRoleAsync(organization.OrganizationId, OrganizationUserType.Owner))
.Select(x => x.Email)
.Distinct();
}
return (await providerUserRepository
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed))
.Select(u => u.Email).Distinct();
}
private async Task AdjustSecretsManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult)
{
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
{
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(validatedResult.Value.SecretsManagerSubscriptionUpdate);
}
}
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0)
{
return;
}
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
{
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
});
}
}

View File

@ -0,0 +1,15 @@
using Bit.Core.Entities;
using Bit.Core.Models.Data;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
/// <summary>
/// Object for associating the <see cref="OrganizationUser"/> with their assigned collections
/// <see cref="CollectionAccessSelection"/> and Group Ids.
/// </summary>
public class CreateOrganizationUser
{
public OrganizationUser OrganizationUser { get; set; }
public CollectionAccessSelection[] Collections { get; set; } = [];
public Guid[] Groups { get; set; } = [];
}

View File

@ -0,0 +1,30 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public static class CreateOrganizationUserExtensions
{
public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite,
DateTimeOffset performedAt,
InviteOrganization organization) =>
new()
{
OrganizationUser = new OrganizationUser
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organization.OrganizationId,
Email = organizationUserInvite.Email.ToLowerInvariant(),
Type = organizationUserInvite.Type,
Status = OrganizationUserStatusType.Invited,
AccessSecretsManager = organizationUserInvite.AccessSecretsManager,
ExternalId = string.IsNullOrWhiteSpace(organizationUserInvite.ExternalId) ? null : organizationUserInvite.ExternalId,
CreationDate = performedAt.UtcDateTime,
RevisionDate = performedAt.UtcDateTime
},
Collections = organizationUserInvite.AssignedCollections,
Groups = organizationUserInvite.Groups
};
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public static class InviteOrganizationUserErrorMessages
{
public const string InvalidEmailErrorMessage = "The email address is not valid.";
public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.";
}

View File

@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.Models.Business;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public class InviteOrganizationUsersRequest
{
public OrganizationUserInvite[] Invites { get; } = [];
public InviteOrganization InviteOrganization { get; }
public Guid PerformedBy { get; }
public DateTimeOffset PerformedAt { get; }
public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites,
InviteOrganization inviteOrganization,
Guid performedBy,
DateTimeOffset performedAt)
{
Invites = invites;
InviteOrganization = inviteOrganization;
PerformedBy = performedBy;
PerformedAt = performedAt;
}
}

View File

@ -0,0 +1,42 @@
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public class InviteOrganizationUsersResponse(Guid organizationId)
{
public IEnumerable<OrganizationUser> InvitedUsers { get; } = [];
public Guid OrganizationId { get; } = organizationId;
public InviteOrganizationUsersResponse(InviteOrganizationUsersValidationRequest usersValidationRequest)
: this(usersValidationRequest.InviteOrganization.OrganizationId)
{
InvitedUsers = usersValidationRequest.Invites.Select(x => new OrganizationUser { Email = x.Email });
}
public InviteOrganizationUsersResponse(IEnumerable<OrganizationUser> invitedOrganizationUsers, Guid organizationId)
: this(organizationId)
{
InvitedUsers = invitedOrganizationUsers;
}
}
public class ScimInviteOrganizationUsersResponse
{
public OrganizationUser InvitedUser { get; init; }
public ScimInviteOrganizationUsersResponse()
{
}
public ScimInviteOrganizationUsersResponse(InviteOrganizationUsersRequest request)
{
var userToInvite = request.Invites.First();
InvitedUser = new OrganizationUser
{
Email = userToInvite.Email,
ExternalId = userToInvite.ExternalId
};
}
}

View File

@ -0,0 +1,40 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.Models.Business;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public class InviteOrganizationUsersValidationRequest
{
public InviteOrganizationUsersValidationRequest()
{
}
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request)
{
Invites = request.Invites;
InviteOrganization = request.InviteOrganization;
PerformedBy = request.PerformedBy;
PerformedAt = request.PerformedAt;
OccupiedPmSeats = request.OccupiedPmSeats;
OccupiedSmSeats = request.OccupiedSmSeats;
}
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request,
PasswordManagerSubscriptionUpdate subscriptionUpdate,
SecretsManagerSubscriptionUpdate smSubscriptionUpdate)
: this(request)
{
PasswordManagerSubscriptionUpdate = subscriptionUpdate;
SecretsManagerSubscriptionUpdate = smSubscriptionUpdate;
}
public OrganizationUserInvite[] Invites { get; init; } = [];
public InviteOrganization InviteOrganization { get; init; }
public Guid PerformedBy { get; init; }
public DateTimeOffset PerformedAt { get; init; }
public int OccupiedPmSeats { get; init; }
public int OccupiedSmSeats { get; init; }
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; }
public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; }
}

View File

@ -0,0 +1,77 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public class OrganizationUserInvite
{
public string Email { get; private init; }
public CollectionAccessSelection[] AssignedCollections { get; private init; }
public OrganizationUserType Type { get; private init; }
public Permissions Permissions { get; private init; }
public string ExternalId { get; private init; }
public bool AccessSecretsManager { get; private init; }
public Guid[] Groups { get; private init; }
public OrganizationUserInvite(string email, string externalId) :
this(
email: email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
false)
{
}
public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) :
this(invite.Email,
invite.AssignedCollections,
invite.Groups,
invite.Type,
invite.Permissions,
invite.ExternalId,
accessSecretsManager)
{
}
public OrganizationUserInvite(string email,
IEnumerable<CollectionAccessSelection> assignedCollections,
IEnumerable<Guid> groups,
OrganizationUserType type,
Permissions permissions,
string externalId,
bool accessSecretsManager)
{
ValidateEmailAddress(email);
var collections = assignedCollections?.ToArray() ?? [];
if (collections.Any(x => x.IsValidCollectionAccessConfiguration()))
{
throw new BadRequestException(InvalidCollectionConfigurationErrorMessage);
}
Email = email;
AssignedCollections = collections;
Groups = groups.ToArray();
Type = type;
Permissions = permissions ?? new Permissions();
ExternalId = externalId;
AccessSecretsManager = accessSecretsManager;
}
private static void ValidateEmailAddress(string email)
{
if (!email.IsValidEmail())
{
throw new BadRequestException($"{email} {InvalidEmailErrorMessage}");
}
}
}

View File

@ -0,0 +1,33 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
/// <summary>
/// Represents a request to send invitations to a group of organization users.
/// </summary>
public class SendInvitesRequest
{
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization) =>
(Users, Organization) = (users.ToArray(), organization);
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization, bool initOrganization) =>
(Users, Organization, InitOrganization) = (users.ToArray(), organization, initOrganization);
/// <summary>
/// Organization Users to send emails to.
/// </summary>
public OrganizationUser[] Users { get; set; } = [];
/// <summary>
/// The organization to invite the users to.
/// </summary>
public Organization Organization { get; init; }
/// <summary>
/// This is for when the organization is being created and this is the owners initial invite
/// </summary>
public bool InitOrganization { get; init; }
}

View File

@ -0,0 +1,80 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tokens;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
public class SendOrganizationInvitesCommand(
IUserRepository userRepository,
ISsoConfigRepository ssoConfigurationRepository,
IPolicyRepository policyRepository,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
IMailService mailService) : ISendOrganizationInvitesCommand
{
public async Task SendInvitesAsync(SendInvitesRequest request)
{
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(request.Users, request.Organization, request.InitOrganization);
await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(IEnumerable<OrganizationUser> orgUsers,
Organization organization, bool initOrganization = false)
{
// Materialize the sequence into a list to avoid multiple enumeration warnings
var orgUsersList = orgUsers.ToList();
// Email links must include information about the org and user for us to make routing decisions client side
// Given an org user, determine if existing BW user exists
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
var existingUsers = await userRepository.GetManyByEmailsAsync(orgUserEmails);
// hash existing users emails list for O(1) lookups
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
ou => ou.Id,
ou => existingUserEmailsHashSet.Contains(ou.Email)
);
// Determine if org has SSO enabled and if user is required to login with SSO
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
var orgSsoEnabled = organization.UseSso && (await ssoConfigurationRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{
var orgUserInviteTokenable = orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = dataProtectorTokenFactory.Protect(orgUserInviteTokenable);
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
}
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
return new OrganizationInvitesInfo(
organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens,
orgUserHasExistingUserDict,
initOrganization
);
}
}

View File

@ -0,0 +1,12 @@
using Bit.Core.Models.Data;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public static class CollectionAccessSelectionExtensions
{
/// <summary>
/// This validates the permissions on the given assigned collection
/// </summary>
public static bool IsValidCollectionAccessConfiguration(this CollectionAccessSelection collectionAccessSelection) =>
collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords);
}

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
public record CannotAutoScaleOnSelfHostError(EnvironmentRequest Invalid) : Error<EnvironmentRequest>(Code, Invalid)
{
public const string Code = "Cannot auto scale self-host.";
}

View File

@ -0,0 +1,18 @@
#nullable enable
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.Settings;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
public class EnvironmentRequest
{
public bool IsSelfHosted { get; init; }
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; init; }
public EnvironmentRequest(IGlobalSettings globalSettings, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate)
{
IsSelfHosted = globalSettings.SelfHosted;
PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate;
}
}

View File

@ -0,0 +1,14 @@
using Bit.Core.AdminConsole.Shared.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
public interface IInviteUsersEnvironmentValidator : IValidator<EnvironmentRequest>;
public class InviteUsersEnvironmentValidator : IInviteUsersEnvironmentValidator
{
public Task<ValidationResult<EnvironmentRequest>> ValidateAsync(EnvironmentRequest value) =>
Task.FromResult<ValidationResult<EnvironmentRequest>>(
value.IsSelfHosted && value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 ?
new Invalid<EnvironmentRequest>(new CannotAutoScaleOnSelfHostError(value)) :
new Valid<EnvironmentRequest>(value));
}

View File

@ -0,0 +1,108 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public interface IInviteUsersValidator : IValidator<InviteOrganizationUsersValidationRequest>;
public class InviteOrganizationUsersValidator(
IOrganizationRepository organizationRepository,
IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator,
IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand,
IPaymentService paymentService) : IInviteUsersValidator
{
public async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateAsync(
InviteOrganizationUsersValidationRequest request)
{
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(request);
var passwordManagerValidationResult =
await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate);
if (passwordManagerValidationResult is Invalid<PasswordManagerSubscriptionUpdate> invalidSubscriptionUpdate)
{
return invalidSubscriptionUpdate.Map(request);
}
// If the organization has the Secrets Manager Standalone Discount, all users are added to secrets manager.
// This is an expensive call, so we're doing it now to delay the check as long as possible.
if (await paymentService.HasSecretsManagerStandalone(request.InviteOrganization))
{
request = new InviteOrganizationUsersValidationRequest(request)
{
Invites = request.Invites
.Select(x => new OrganizationUserInvite(x, accessSecretsManager: true))
.ToArray()
};
}
if (request.InviteOrganization.UseSecretsManager && request.Invites.Any(x => x.AccessSecretsManager))
{
return await ValidateSecretsManagerSubscriptionUpdateAsync(request, subscriptionUpdate);
}
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
request,
subscriptionUpdate,
null));
}
private async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateSecretsManagerSubscriptionUpdateAsync(
InviteOrganizationUsersValidationRequest request,
PasswordManagerSubscriptionUpdate subscriptionUpdate)
{
try
{
var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(
organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId),
plan: request.InviteOrganization.Plan,
autoscaling: true);
var seatsToAdd = GetSecretManagerSeatAdjustment(request);
if (seatsToAdd > 0)
{
smSubscriptionUpdate.AdjustSeats(seatsToAdd);
await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate);
}
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
request,
subscriptionUpdate,
smSubscriptionUpdate));
}
catch (Exception ex)
{
return new Invalid<InviteOrganizationUsersValidationRequest>(
new Error<InviteOrganizationUsersValidationRequest>(ex.Message, request));
}
}
/// <summary>
/// This calculates the number of SM seats to add to the organization seat total.
///
/// If they have a current seat limit (it can be null), we want to figure out how many are available (seats -
/// occupied seats). Then, we'll subtract the available seats from the number of users we're trying to invite.
///
/// If it's negative, we have available seats and do not need to increase, so we go with 0.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private static int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) =>
request.InviteOrganization.SmSeats.HasValue
? Math.Max(
request.Invites.Count(x => x.AccessSecretsManager) -
(request.InviteOrganization.SmSeats.Value -
request.OccupiedSmSeats),
0)
: 0;
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.Models.Business;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
public record OrganizationNoPaymentMethodFoundError(InviteOrganization InvalidRequest)
: Error<InviteOrganization>(Code, InvalidRequest)
{
public const string Code = "No payment method found.";
}
public record OrganizationNoSubscriptionFoundError(InviteOrganization InvalidRequest)
: Error<InviteOrganization>(Code, InvalidRequest)
{
public const string Code = "No subscription found.";
}

View File

@ -0,0 +1,32 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Shared.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
public interface IInviteUsersOrganizationValidator : IValidator<InviteOrganization>;
public class InviteUsersOrganizationValidator : IInviteUsersOrganizationValidator
{
public Task<ValidationResult<InviteOrganization>> ValidateAsync(InviteOrganization inviteOrganization)
{
if (inviteOrganization.Seats is null)
{
return Task.FromResult<ValidationResult<InviteOrganization>>(
new Valid<InviteOrganization>(inviteOrganization));
}
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewayCustomerId))
{
return Task.FromResult<ValidationResult<InviteOrganization>>(
new Invalid<InviteOrganization>(new OrganizationNoPaymentMethodFoundError(inviteOrganization)));
}
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewaySubscriptionId))
{
return Task.FromResult<ValidationResult<InviteOrganization>>(
new Invalid<InviteOrganization>(new OrganizationNoSubscriptionFoundError(inviteOrganization)));
}
return Task.FromResult<ValidationResult<InviteOrganization>>(new Valid<InviteOrganization>(inviteOrganization));
}
}

View File

@ -0,0 +1,30 @@
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
public record PasswordManagerSeatLimitHasBeenReachedError(PasswordManagerSubscriptionUpdate InvalidRequest)
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
{
public const string Code = "Seat limit has been reached.";
}
public record PasswordManagerPlanDoesNotAllowAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
{
public const string Code = "Plan does not allow additional seats.";
}
public record PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
: Error<PasswordManagerSubscriptionUpdate>(GetErrorMessage(InvalidRequest), InvalidRequest)
{
private static string GetErrorMessage(PasswordManagerSubscriptionUpdate invalidRequest) =>
string.Format(Code, invalidRequest.PasswordManagerPlan.MaxAdditionalSeats);
public const string Code = "Organization plan allows a maximum of {0} additional seats.";
}
public record PasswordManagerMustHaveSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
{
public const string Code = "You do not have any Password Manager seats!";
}

View File

@ -0,0 +1,117 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
public interface IInviteUsersPasswordManagerValidator : IValidator<PasswordManagerSubscriptionUpdate>;
public class InviteUsersPasswordManagerValidator(
IGlobalSettings globalSettings,
IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator,
IInviteUsersOrganizationValidator inviteUsersOrganizationValidator,
IProviderRepository providerRepository,
IPaymentService paymentService,
IOrganizationRepository organizationRepository
) : IInviteUsersPasswordManagerValidator
{
/// <summary>
/// This is for validating if the organization can add additional users.
/// </summary>
/// <param name="subscriptionUpdate"></param>
/// <returns></returns>
public static ValidationResult<PasswordManagerSubscriptionUpdate> ValidatePasswordManager(PasswordManagerSubscriptionUpdate subscriptionUpdate)
{
if (subscriptionUpdate.Seats is null)
{
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
if (subscriptionUpdate.SeatsRequiredToAdd == 0)
{
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
if (subscriptionUpdate.PasswordManagerPlan.BaseSeats + subscriptionUpdate.SeatsRequiredToAdd <= 0)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate));
}
if (subscriptionUpdate.MaxSeatsReached)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(
new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate));
}
if (subscriptionUpdate.PasswordManagerPlan.HasAdditionalSeatsOption is false)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(
new PasswordManagerPlanDoesNotAllowAdditionalSeatsError(subscriptionUpdate));
}
// Apparently MaxAdditionalSeats is never set. Can probably be removed.
if (subscriptionUpdate.UpdatedSeatTotal - subscriptionUpdate.PasswordManagerPlan.BaseSeats > subscriptionUpdate.PasswordManagerPlan.MaxAdditionalSeats)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(
new PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(subscriptionUpdate));
}
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
public async Task<ValidationResult<PasswordManagerSubscriptionUpdate>> ValidateAsync(PasswordManagerSubscriptionUpdate request)
{
switch (ValidatePasswordManager(request))
{
case Valid<PasswordManagerSubscriptionUpdate> valid
when valid.Value.SeatsRequiredToAdd is 0:
return new Valid<PasswordManagerSubscriptionUpdate>(request);
case Invalid<PasswordManagerSubscriptionUpdate> invalid:
return invalid;
}
if (await inviteUsersEnvironmentValidator.ValidateAsync(new EnvironmentRequest(globalSettings, request)) is Invalid<EnvironmentRequest> invalidEnvironment)
{
return invalidEnvironment.Map(request);
}
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
{
return organizationValidation.Map(request);
}
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
if (provider is not null)
{
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
if (providerValidationResult is Invalid<InviteOrganizationProvider> invalidProviderValidation)
{
return invalidProviderValidation.Map(request);
}
}
var paymentSubscription = await paymentService.GetSubscriptionAsync(
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
var paymentValidationResult = InviteUserPaymentValidation.Validate(
new PaymentsSubscription(paymentSubscription, request.InviteOrganization));
if (paymentValidationResult is Invalid<PaymentsSubscription> invalidPaymentValidation)
{
return invalidPaymentValidation.Map(request);
}
return new Valid<PasswordManagerSubscriptionUpdate>(request);
}
}

View File

@ -0,0 +1,89 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
public class PasswordManagerSubscriptionUpdate
{
/// <summary>
/// Seats the organization has
/// </summary>
public int? Seats { get; }
/// <summary>
/// Max number of seats that the organization can have
/// </summary>
public int? MaxAutoScaleSeats { get; }
/// <summary>
/// Seats currently occupied by current users
/// </summary>
public int OccupiedSeats { get; }
/// <summary>
/// Users to add to the organization seats
/// </summary>
public int NewUsersToAdd { get; }
/// <summary>
/// Number of seats available for users
/// </summary>
public int? AvailableSeats => Seats - OccupiedSeats;
/// <summary>
/// Number of seats to scale the organization by.
///
/// If Organization has no seat limit (Seats is null), then there are no new seats to add.
/// </summary>
public int SeatsRequiredToAdd => AvailableSeats.HasValue ? Math.Max(NewUsersToAdd - AvailableSeats.Value, 0) : 0;
/// <summary>
/// New total of seats for the organization
/// </summary>
public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd;
/// <summary>
/// If the new seat total is equal to the organization's auto-scale seat count
/// </summary>
public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value;
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
public InviteOrganization InviteOrganization { get; }
private PasswordManagerSubscriptionUpdate(int? organizationSeats,
int? organizationAutoScaleSeatLimit,
int currentSeats,
int newUsersToAdd,
Plan.PasswordManagerPlanFeatures plan,
InviteOrganization inviteOrganization)
{
Seats = organizationSeats;
MaxAutoScaleSeats = organizationAutoScaleSeatLimit;
OccupiedSeats = currentSeats;
NewUsersToAdd = newUsersToAdd;
PasswordManagerPlan = plan;
InviteOrganization = inviteOrganization;
}
public PasswordManagerSubscriptionUpdate(InviteOrganization inviteOrganization, int occupiedSeats, int newUsersToAdd) :
this(
organizationSeats: inviteOrganization.Seats,
organizationAutoScaleSeatLimit: inviteOrganization.MaxAutoScaleSeats,
currentSeats: occupiedSeats,
newUsersToAdd: newUsersToAdd,
plan: inviteOrganization.Plan.PasswordManager,
inviteOrganization: inviteOrganization)
{ }
public PasswordManagerSubscriptionUpdate(InviteOrganizationUsersValidationRequest usersValidationRequest) :
this(
organizationSeats: usersValidationRequest.InviteOrganization.Seats,
organizationAutoScaleSeatLimit: usersValidationRequest.InviteOrganization.MaxAutoScaleSeats,
currentSeats: usersValidationRequest.OccupiedPmSeats,
newUsersToAdd: usersValidationRequest.Invites.Length,
plan: usersValidationRequest.InviteOrganization.Plan.PasswordManager,
inviteOrganization: usersValidationRequest.InviteOrganization)
{ }
}

View File

@ -0,0 +1,10 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
public record PaymentCancelledSubscriptionError(PaymentsSubscription InvalidRequest)
: Error<PaymentsSubscription>(Code, InvalidRequest)
{
public const string Code = "You do not have an active subscription. Reinstate your subscription to make changes.";
}

View File

@ -0,0 +1,25 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public static class InviteUserPaymentValidation
{
public static ValidationResult<PaymentsSubscription> Validate(PaymentsSubscription subscription)
{
if (subscription.ProductTierType is ProductTierType.Free)
{
return new Valid<PaymentsSubscription>(subscription);
}
if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled)
{
return new Invalid<PaymentsSubscription>(new PaymentCancelledSubscriptionError(subscription));
}
return new Valid<PaymentsSubscription>(subscription);
}
}

View File

@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Billing.Enums;
using Bit.Core.Models.Business;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
public class PaymentsSubscription
{
public ProductTierType ProductTierType { get; init; }
public string SubscriptionStatus { get; init; }
public PaymentsSubscription() { }
public PaymentsSubscription(SubscriptionInfo subscriptionInfo, InviteOrganization inviteOrganization)
{
SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty;
ProductTierType = inviteOrganization.Plan.ProductTier;
}
}

View File

@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
public record ProviderBillableSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
{
public const string Code = "Seat limit has been reached. Please contact your provider to add more seats.";
}
public record ProviderResellerSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
{
public const string Code = "Seat limit has been reached. Contact your provider to purchase additional seats.";
}

View File

@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Enums.Provider;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
public class InviteOrganizationProvider
{
public Guid ProviderId { get; init; }
public ProviderType Type { get; init; }
public ProviderStatusType Status { get; init; }
public bool Enabled { get; init; }
public InviteOrganizationProvider(Entities.Provider.Provider provider)
{
ProviderId = provider.Id;
Type = provider.Type;
Status = provider.Status;
Enabled = provider.Enabled;
}
}

View File

@ -0,0 +1,28 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Billing.Extensions;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
public static class InvitingUserOrganizationProviderValidator
{
public static ValidationResult<InviteOrganizationProvider> Validate(InviteOrganizationProvider inviteOrganizationProvider)
{
if (inviteOrganizationProvider is not { Enabled: true })
{
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
}
if (inviteOrganizationProvider.IsBillable())
{
return new Invalid<InviteOrganizationProvider>(new ProviderBillableSeatLimitError(inviteOrganizationProvider));
}
if (inviteOrganizationProvider.Type == ProviderType.Reseller)
{
return new Invalid<InviteOrganizationProvider>(new ProviderResellerSeatLimitError(inviteOrganizationProvider));
}
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
}
}

View File

@ -25,7 +25,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
public const string UserNotFoundErrorMessage = "User not found."; public const string UserNotFoundErrorMessage = "User not found.";
public const string UsersInvalidErrorMessage = "Users invalid."; public const string UsersInvalidErrorMessage = "Users invalid.";
public const string RemoveYourselfErrorMessage = "You cannot remove yourself."; public const string RemoveYourselfErrorMessage = "You cannot remove yourself.";
public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can delete other owners."; public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can remove other owners.";
public const string RemoveAdminByCustomUserErrorMessage = "Custom users can not remove admins.";
public const string RemoveLastConfirmedOwnerErrorMessage = "Organization must have at least one confirmed owner."; public const string RemoveLastConfirmedOwnerErrorMessage = "Organization must have at least one confirmed owner.";
public const string RemoveClaimedAccountErrorMessage = "Cannot remove member accounts claimed by the organization. To offboard a member, revoke or delete the account."; public const string RemoveClaimedAccountErrorMessage = "Cannot remove member accounts claimed by the organization. To offboard a member, revoke or delete the account.";
@ -153,6 +154,11 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
} }
} }
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(orgUser.OrganizationId))
{
throw new BadRequestException(RemoveAdminByCustomUserErrorMessage);
}
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
{ {
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });

View File

@ -0,0 +1,54 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
/// <summary>
/// Restores a user back to their previous status.
/// </summary>
public interface IRestoreOrganizationUserCommand
{
/// <summary>
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
/// can re-add this user based on their current occupied seats.
///
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
/// other organizations the user may belong to.
///
/// Reference Events and Push Notifications are fired off for this as well.
/// </summary>
/// <param name="organizationUser">Revoked user to be restored.</param>
/// <param name="restoringUserId">UserId of the user performing the action.</param>
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
/// <summary>
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
/// can re-add this user based on their current occupied seats.
///
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
/// other organizations the user may belong to.
///
/// Reference Events and Push Notifications are fired off for this as well.
/// </summary>
/// <param name="organizationUser">Revoked user to be restored.</param>
/// <param name="systemUser">System that is performing the action on behalf of the organization (Public API, SCIM, etc.)</param>
Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
/// <summary>
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
/// can re-add this user based on their current occupied seats.
///
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
/// other organizations the user may belong to.
///
/// Reference Events and Push Notifications are fired off for this as well.
/// </summary>
/// <param name="organizationId">Organization the users should be restored to.</param>
/// <param name="organizationUserIds">List of organization user ids to restore to previous status.</param>
/// <param name="restoringUserId">UserId of the user performing the action.</param>
/// <param name="userService">Passed in from caller to avoid circular dependency</param>
/// <returns>List of organization user Ids and strings. A successful restoration will have an empty string.
/// If an error occurs, the error message will be provided.</returns>
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
}

View File

@ -0,0 +1,302 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
public class RestoreOrganizationUserCommand(
ICurrentContext currentContext,
IEventService eventService,
IPushNotificationService pushNotificationService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IPolicyService policyService,
IUserRepository userRepository,
IOrganizationService organizationService) : IRestoreOrganizationUserCommand
{
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
{
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
{
throw new BadRequestException("You cannot restore yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
!await currentContext.OrganizationOwner(organizationUser.OrganizationId))
{
throw new BadRequestException("Only owners can restore other owners.");
}
await RepositoryRestoreUserAsync(organizationUser);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
{
await RepositoryRestoreUserAsync(organizationUser);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
systemUser);
if (organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already active.");
}
var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
if (availableSeats < 1)
{
await organizationService.AutoAddSeatsAsync(organization, 1); // Hooray
}
var userTwoFactorIsEnabled = false;
// Only check 2FA status if the user is linked to a user account
if (organizationUser.UserId.HasValue)
{
userTwoFactorIsEnabled =
(await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync([organizationUser.UserId.Value]))
.FirstOrDefault()
.twoFactorIsEnabled;
}
if (organization.PlanType == PlanType.Free)
{
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
}
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
}
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
{
var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId!.Value);
var otherOrgs = await organizationRepository.GetManyByUserIdAsync(organizationUser.UserId.Value);
var orgOrgUserDict = relatedOrgUsersFromOtherOrgs
.Where(x => x.Id != organizationUser.Id)
.ToDictionary(x => x, x => otherOrgs.FirstOrDefault(y => y.Id == x.OrganizationId));
CheckForOtherFreeOrganizationOwnership(organizationUser, orgOrgUserDict);
}
private async Task<Dictionary<OrganizationUser, Organization>> GetRelatedOrganizationUsersAndOrganizationsAsync(
List<OrganizationUser> organizationUsers)
{
var allUserIds = organizationUsers
.Where(x => x.UserId.HasValue)
.Select(x => x.UserId.Value);
var otherOrganizationUsers = (await organizationUserRepository.GetManyByManyUsersAsync(allUserIds))
.Where(x => organizationUsers.Any(y => y.Id == x.Id) == false)
.ToArray();
var otherOrgs = await organizationRepository.GetManyByIdsAsync(otherOrganizationUsers
.Select(x => x.OrganizationId)
.Distinct());
return otherOrganizationUsers
.ToDictionary(x => x, x => otherOrgs.FirstOrDefault(y => y.Id == x.OrganizationId));
}
private static void CheckForOtherFreeOrganizationOwnership(OrganizationUser organizationUser,
Dictionary<OrganizationUser, Organization> otherOrgUsersAndOrgs)
{
var ownerOrAdminList = new[] { OrganizationUserType.Owner, OrganizationUserType.Admin };
if (ownerOrAdminList.Any(x => organizationUser.Type == x) &&
otherOrgUsersAndOrgs.Any(x =>
x.Key.UserId == organizationUser.UserId &&
ownerOrAdminList.Any(userType => userType == x.Key.Type) &&
x.Key.Status == OrganizationUserStatusType.Confirmed &&
x.Value.PlanType == PlanType.Free))
{
throw new BadRequestException(
"User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account.");
}
}
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
{
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
.ToList();
if (filteredUsers.Count == 0)
{
throw new BadRequestException("Users invalid.");
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired);
var deletingUserIsOwner = false;
if (restoringUserId.HasValue)
{
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
}
// Query Two Factor Authentication status for all users in the organization
// This is an optimization to avoid querying the Two Factor Authentication status for each user individually
var organizationUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers);
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var organizationUser in filteredUsers)
{
try
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already active.");
}
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId)
{
throw new BadRequestException("You cannot restore yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
!deletingUserIsOwner)
{
throw new BadRequestException("Only owners can restore other owners.");
}
var twoFactorIsEnabled = organizationUser.UserId.HasValue
&& organizationUsersTwoFactorEnabled
.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value)
.twoFactorIsEnabled;
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
if (organization.PlanType == PlanType.Free)
{
CheckForOtherFreeOrganizationOwnership(organizationUser, orgUsersAndOrgs);
}
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(organizationUser, e.Message));
}
}
return result;
}
private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled)
{
// An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant
// The user will be subject to the same checks when they try to accept the invite
if (OrganizationService.GetPriorActiveOrganizationUserStatusType(orgUser) == OrganizationUserStatusType.Invited)
{
return;
}
var userId = orgUser.UserId.Value;
// Enforce Single Organization Policy of organization user is being restored to
var allOrgUsers = await organizationUserRepository.GetManyByUserAsync(userId);
var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId);
var singleOrgPoliciesApplyingToRevokedUsers = await policyService.GetPoliciesApplicableToUserAsync(userId,
PolicyType.SingleOrg, OrganizationUserStatusType.Revoked);
var singleOrgPolicyApplies =
singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId);
var singleOrgCompliant = true;
var belongsToOtherOrgCompliant = true;
var twoFactorCompliant = true;
if (hasOtherOrgs && singleOrgPolicyApplies)
{
singleOrgCompliant = false;
}
// Enforce Single Organization Policy of other organizations user is a member of
var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(userId, PolicyType.SingleOrg);
if (anySingleOrgPolicies)
{
belongsToOtherOrgCompliant = false;
}
// Enforce 2FA Policy of organization user is trying to join
if (!userHasTwoFactorEnabled)
{
var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId,
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked);
if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
{
twoFactorCompliant = false;
}
}
var user = await userRepository.GetByIdAsync(userId);
if (!singleOrgCompliant && !twoFactorCompliant)
{
throw new BadRequestException(user.Email +
" is not compliant with the single organization and two-step login policy");
}
else if (!singleOrgCompliant)
{
throw new BadRequestException(user.Email + " is not compliant with the single organization policy");
}
else if (!belongsToOtherOrgCompliant)
{
throw new BadRequestException(user.Email +
" belongs to an organization that doesn't allow them to join multiple organizations");
}
else if (!twoFactorCompliant)
{
throw new BadRequestException(user.Email + " is not compliant with the two-step login policy");
}
}
}

View File

@ -34,6 +34,8 @@ public class ResetPasswordPolicyRequirementFactory : BasePolicyRequirementFactor
protected override IEnumerable<OrganizationUserType> ExemptRoles => []; protected override IEnumerable<OrganizationUserType> ExemptRoles => [];
protected override IEnumerable<OrganizationUserStatusType> ExemptStatuses => [OrganizationUserStatusType.Revoked];
public override ResetPasswordPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails) public override ResetPasswordPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{ {
var result = policyDetails var result = policyDetails

View File

@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations;
namespace Bit.Core.Repositories;
public interface IOrganizationIntegrationConfigurationRepository : IRepository<OrganizationIntegrationConfiguration, Guid>
{
Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
Guid organizationId,
IntegrationType integrationType,
EventType eventType);
}

View File

@ -0,0 +1,7 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.Repositories;
public interface IOrganizationIntegrationRepository : IRepository<OrganizationIntegration, Guid>
{
}

View File

@ -24,4 +24,5 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
/// </summary> /// </summary>
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId); Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);
Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType);
Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids);
} }

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.KeyManagement.UserKey; using Bit.Core.KeyManagement.UserKey;
@ -68,4 +69,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
/// <param name="role">The role to search for</param> /// <param name="role">The role to search for</param>
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns> /// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role); Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection);
} }

View File

@ -48,10 +48,6 @@ public interface IOrganizationService
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId, Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId); IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted); Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
/// <summary> /// <summary>
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'. /// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.

View File

@ -6,20 +6,19 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -27,18 +26,17 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.Mail;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Tools.Enums; using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
namespace Bit.Core.Services; namespace Bit.Core.Services;
@ -59,7 +57,6 @@ public class OrganizationService : IOrganizationService
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoUserRepository _ssoUserRepository; private readonly ISsoUserRepository _ssoUserRepository;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
@ -71,14 +68,12 @@ public class OrganizationService : IOrganizationService
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IOrganizationBillingService _organizationBillingService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
public OrganizationService( public OrganizationService(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -96,7 +91,6 @@ public class OrganizationService : IOrganizationService
IPaymentService paymentService, IPaymentService paymentService,
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IPolicyService policyService, IPolicyService policyService,
ISsoConfigRepository ssoConfigRepository,
ISsoUserRepository ssoUserRepository, ISsoUserRepository ssoUserRepository,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
@ -106,16 +100,14 @@ public class OrganizationService : IOrganizationService
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IProviderRepository providerRepository, IProviderRepository providerRepository,
IFeatureService featureService, IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IOrganizationBillingService organizationBillingService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient, IPricingClient pricingClient,
IPolicyRequirementQuery policyRequirementQuery) IPolicyRequirementQuery policyRequirementQuery,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -132,7 +124,6 @@ public class OrganizationService : IOrganizationService
_paymentService = paymentService; _paymentService = paymentService;
_policyRepository = policyRepository; _policyRepository = policyRepository;
_policyService = policyService; _policyService = policyService;
_ssoConfigRepository = ssoConfigRepository;
_ssoUserRepository = ssoUserRepository; _ssoUserRepository = ssoUserRepository;
_referenceEventService = referenceEventService; _referenceEventService = referenceEventService;
_globalSettings = globalSettings; _globalSettings = globalSettings;
@ -144,14 +135,12 @@ public class OrganizationService : IOrganizationService
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_providerRepository = providerRepository; _providerRepository = providerRepository;
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService; _featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_organizationBillingService = organizationBillingService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
} }
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -579,6 +568,7 @@ public class OrganizationService : IOrganizationService
UseSecretsManager = license.UseSecretsManager, UseSecretsManager = license.UseSecretsManager,
SmSeats = license.SmSeats, SmSeats = license.SmSeats,
SmServiceAccounts = license.SmServiceAccounts, SmServiceAccounts = license.SmServiceAccounts,
UseRiskInsights = license.UseRiskInsights,
}; };
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false);
@ -1058,74 +1048,14 @@ public class OrganizationService : IOrganizationService
await SendInviteAsync(orgUser, org, initOrganization); await SendInviteAsync(orgUser, org, initOrganization);
} }
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
{ await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) =>
} await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
users: [orgUser],
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) organization: organization,
{ initOrganization: initOrganization));
// convert single org user into array of 1 org user
var orgUsers = new[] { orgUser };
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization);
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(
IEnumerable<OrganizationUser> orgUsers,
Organization organization,
bool initOrganization = false)
{
// Materialize the sequence into a list to avoid multiple enumeration warnings
var orgUsersList = orgUsers.ToList();
// Email links must include information about the org and user for us to make routing decisions client side
// Given an org user, determine if existing BW user exists
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails);
// hash existing users emails list for O(1) lookups
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
ou => ou.Id,
ou => existingUserEmailsHashSet.Contains(ou.Email)
);
// Determine if org has SSO enabled and if user is required to login with SSO
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
}
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
return new OrganizationInvitesInfo(
organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens,
orgUserHasExistingUserDict,
initOrganization
);
}
internal async Task<(bool canScale, string failureReason)> CanScaleAsync( internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
Organization organization, Organization organization,
@ -1794,7 +1724,7 @@ public class OrganizationService : IOrganizationService
await RepositoryRevokeUserAsync(organizationUser); await RepositoryRevokeUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue) if (organizationUser.UserId.HasValue)
{ {
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
} }
@ -1806,7 +1736,7 @@ public class OrganizationService : IOrganizationService
await RepositoryRevokeUserAsync(organizationUser); await RepositoryRevokeUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue) if (organizationUser.UserId.HasValue)
{ {
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
} }
@ -1875,145 +1805,7 @@ public class OrganizationService : IOrganizationService
await _organizationUserRepository.RevokeAsync(organizationUser.Id); await _organizationUserRepository.RevokeAsync(organizationUser.Id);
organizationUser.Status = OrganizationUserStatusType.Revoked; organizationUser.Status = OrganizationUserStatusType.Revoked;
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue) if (organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(organizationUser, e.Message));
}
}
return result;
}
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
{
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
{
throw new BadRequestException("You cannot restore yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
!await _currentContext.OrganizationOwner(organizationUser.OrganizationId))
{
throw new BadRequestException("Only owners can restore other owners.");
}
await RepositoryRestoreUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
{
await RepositoryRestoreUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, systemUser);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already active.");
}
var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
if (availableSeats < 1)
{
await AutoAddSeatsAsync(organization, 1);
}
var userTwoFactorIsEnabled = false;
// Only check Two Factor Authentication status if the user is linked to a user account
if (organizationUser.UserId.HasValue)
{
userTwoFactorIsEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(new[] { organizationUser.UserId.Value })).FirstOrDefault().twoFactorIsEnabled;
}
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
await _organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
}
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
{
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
.ToList();
if (!filteredUsers.Any())
{
throw new BadRequestException("Users invalid.");
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
await AutoAddSeatsAsync(organization, newSeatsRequired);
var deletingUserIsOwner = false;
if (restoringUserId.HasValue)
{
deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
}
// Query Two Factor Authentication status for all users in the organization
// This is an optimization to avoid querying the Two Factor Authentication status for each user individually
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var organizationUser in filteredUsers)
{
try
{
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already active.");
}
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId)
{
throw new BadRequestException("You cannot restore yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue && !deletingUserIsOwner)
{
throw new BadRequestException("Only owners can restore other owners.");
}
var twoFactorIsEnabled = organizationUser.UserId.HasValue
&& organizationUsersTwoFactorEnabled.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value).twoFactorIsEnabled;
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
await _organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{ {
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
} }
@ -2095,7 +1887,7 @@ public class OrganizationService : IOrganizationService
} }
} }
static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) public static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser)
{ {
// Determine status to revert back to // Determine status to revert back to
var status = OrganizationUserStatusType.Invited; var status = OrganizationUserStatusType.Invited;

View File

@ -6,10 +6,39 @@ public abstract record ValidationResult<T>;
public record Valid<T> : ValidationResult<T> public record Valid<T> : ValidationResult<T>
{ {
public Valid() { }
public Valid(T Value)
{
this.Value = Value;
}
public T Value { get; init; } public T Value { get; init; }
} }
public record Invalid<T> : ValidationResult<T> public record Invalid<T> : ValidationResult<T>
{ {
public IEnumerable<Error<T>> Errors { get; init; } public IEnumerable<Error<T>> Errors { get; init; } = [];
public string ErrorMessageString => string.Join(" ", Errors.Select(e => e.Message));
public Invalid() { }
public Invalid(Error<T> error) : this([error]) { }
public Invalid(IEnumerable<Error<T>> errors)
{
Errors = errors;
}
}
public static class ValidationResultMappers
{
public static ValidationResult<B> Map<A, B>(this ValidationResult<A> validationResult, B invalidValue) =>
validationResult switch
{
Valid<A> => new Valid<B>(invalidValue),
Invalid<A> invalid => new Invalid<B>(invalid.Errors.Select(x => x.ToError(invalidValue))),
_ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type")
};
} }

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request; namespace Bit.Core.Auth.Models.Api.Request;
@ -7,6 +8,13 @@ public class OtherDeviceKeysUpdateRequestModel : DeviceKeysUpdateRequestModel
{ {
[Required] [Required]
public Guid DeviceId { get; set; } public Guid DeviceId { get; set; }
public Device ToDevice(Device existingDevice)
{
existingDevice.EncryptedPublicKey = EncryptedPublicKey;
existingDevice.EncryptedUserKey = EncryptedUserKey;
return existingDevice;
}
} }
public class DeviceKeysUpdateRequestModel public class DeviceKeysUpdateRequestModel

View File

@ -1,5 +1,4 @@
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Utilities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
@ -19,7 +18,7 @@ public class DeviceAuthRequestResponseModel : ResponseModel
Type = deviceAuthDetails.Type, Type = deviceAuthDetails.Type,
Identifier = deviceAuthDetails.Identifier, Identifier = deviceAuthDetails.Identifier,
CreationDate = deviceAuthDetails.CreationDate, CreationDate = deviceAuthDetails.CreationDate,
IsTrusted = deviceAuthDetails.IsTrusted() IsTrusted = deviceAuthDetails.IsTrusted,
}; };
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null) if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)

View File

@ -287,14 +287,14 @@ public class AuthRequestService : IAuthRequestService
private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user) private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications)) var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
if (adminEmails.Count == 0)
{ {
_logger.LogWarning("Skipped sending device approval notification to admins - feature flag disabled"); _logger.LogWarning("There are no admin emails to send to.");
return; return;
} }
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync( await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(
adminEmails, adminEmails,
organizationUser.OrganizationId, organizationUser.OrganizationId,

View File

@ -47,6 +47,8 @@ public static class StripeConstants
public static class MetadataKeys public static class MetadataKeys
{ {
public const string OrganizationId = "organizationId"; public const string OrganizationId = "organizationId";
public const string ProviderId = "providerId";
public const string UserId = "userId";
} }
public static class PaymentBehavior public static class PaymentBehavior

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -28,6 +29,13 @@ public static class BillingExtensions
Status: ProviderStatusType.Billable Status: ProviderStatusType.Billable
}; };
public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) =>
inviteOrganizationProvider is
{
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
Status: ProviderStatusType.Billable
};
public static bool SupportsConsolidatedBilling(this ProviderType providerType) public static bool SupportsConsolidatedBilling(this ProviderType providerType)
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise; => providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;

Some files were not shown because too many files have changed in this diff Show More