1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-07 22:18:17 -05:00

Merge branch 'main' into test-docker-stuff

This commit is contained in:
tangowithfoxtrot 2025-04-07 10:48:29 -07:00
commit 13f4e697bc
No known key found for this signature in database
318 changed files with 24750 additions and 3737 deletions

10
.github/CODEOWNERS vendored
View File

@ -20,12 +20,19 @@
# Database Operations for database changes
src/Sql/** @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/PostgresMigrations/** @bitwarden/dept-dbops
util/SqlServerEFScaffold/** @bitwarden/dept-dbops
util/SqliteMigrations/** @bitwarden/dept-dbops
# Shared util projects
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
# Auth team
**/Auth @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
.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-rc-branch.yml @bitwarden/team-platform-dev
.github/workflows/repository-management.yml @bitwarden/team-platform-dev

View File

@ -7,21 +7,18 @@ on:
- "main"
- "rc"
- "hotfix-rc"
pull_request_target:
pull_request:
types: [opened, synchronize]
workflow_call:
inputs: {}
env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
lint:
name: Lint
runs-on: ubuntu-22.04
needs: check-run
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -34,60 +31,57 @@ jobs:
- name: Verify format
run: dotnet format --verify-no-changes
build-docker:
name: Build Docker images
runs-on: ubuntu-22.04
needs: lint
build-container:
name: Build container images
runs-on: ubuntu-24.04
permissions:
id-token: write
security-events: write
outputs:
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
strategy:
fail-fast: false
matrix:
include:
- project_name: Admin
base_path: ./src
dotnet: true
- project_name: Api
base_path: ./src
dotnet: true
- project_name: Attachments
base_path: ./util
- project_name: Billing
base_path: ./src
upload_artifact: true
- project_name: Events
base_path: ./src
upload_artifact: true
- project_name: EventsProcessor
base_path: ./src
upload_artifact: true
- project_name: Icons
base_path: ./src
upload_artifact: true
- project_name: Identity
base_path: ./src
upload_artifact: true
- project_name: MsSql
base_path: ./util
- project_name: MsSqlMigratorUtility
base_path: ./util
upload_artifact: true
- project_name: Nginx
base_path: ./util
- project_name: Notifications
base_path: ./src
upload_artifact: true
- project_name: Scim
base_path: ./bitwarden_license/src
upload_artifact: true
- project_name: Setup
base_path: ./util
upload_artifact: true
- project_name: Sso
base_path: ./bitwarden_license/src
upload_artifact: true
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
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
@ -113,6 +107,13 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
########## ACRs ##########
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -122,11 +123,11 @@ jobs:
- name: Log in to ACR - production subscription
run: az acr login -n bitwardenprod
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
########## Generate image tag and build container image ##########
- name: Generate container image tag
id: tag
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")
else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
@ -137,7 +138,7 @@ jobs:
fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY
echo "### :mega: Container Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY
- name: Set up project name
id: setup
@ -168,8 +169,8 @@ jobs:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: echo "name=${_AZ_REGISTRY}/${PROJECT_NAME}:buildcache" >> $GITHUB_OUTPUT
- name: Build Docker image
id: build-docker
- name: Build Container image
id: build-container
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with:
cache-from: type=registry,ref=${{ steps.cache-name.outputs.name }}
@ -184,13 +185,13 @@ jobs:
tags: ${{ steps.image-tags.outputs.tags }}
- 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
- 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:
DIGEST: ${{ steps.build-docker.outputs.digest }}
DIGEST: ${{ steps.build-container.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }}
run: |
IFS="," read -a tags <<< "${TAGS}"
@ -200,7 +201,7 @@ jobs:
done
cosign sign --yes ${images}
- name: Scan Docker image
- name: Scan container image
id: container-scan
uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0
with:
@ -212,11 +213,13 @@ jobs:
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
build-stub-swagger:
name: Build Docker-Stub/Swagger
runs-on: ubuntu-22.04
needs: build-docker
runs-on: ubuntu-24.04
needs: build-container
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -239,7 +242,7 @@ jobs:
- name: Make Docker stubs
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')
run: |
# Set proper setup image based on branch
@ -281,7 +284,7 @@ jobs:
- name: Make Docker stub checksums
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')
run: |
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
@ -289,7 +292,7 @@ jobs:
- name: Upload Docker stub US artifact
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')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
@ -299,7 +302,7 @@ jobs:
- name: Upload Docker stub EU artifact
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')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
@ -309,7 +312,7 @@ jobs:
- name: Upload Docker stub US checksum artifact
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')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
@ -319,7 +322,7 @@ jobs:
- name: Upload Docker stub EU checksum artifact
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')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
@ -396,8 +399,7 @@ jobs:
build-mssqlmigratorutility:
name: Build MSSQL migrator utility
runs-on: ubuntu-22.04
needs: lint
runs-on: ubuntu-24.04
defaults:
run:
shell: bash
@ -454,10 +456,10 @@ jobs:
self-host-build:
name: Trigger self-host build
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')
runs-on: ubuntu-22.04
needs: build-docker
runs-on: ubuntu-24.04
needs: build-container
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -488,9 +490,9 @@ jobs:
trigger-k8s-deploy:
name: Trigger k8s deploy
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
needs: build-docker
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
needs: build-container
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -523,10 +525,11 @@ jobs:
trigger-ee-updates:
name: Trigger Ephemeral Environment updates
if: |
github.event_name == 'pull_request_target'
needs.build-container.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04
needs: build-docker
needs: build-container
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -557,9 +560,12 @@ jobs:
trigger-ephemeral-environment-sync:
name: Trigger Ephemeral Environment Sync
needs: trigger-ee-updates
needs:
- build-container
- trigger-ee-updates
if: |
github.event_name == 'pull_request_target'
needs.build-container.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
with:
@ -569,14 +575,13 @@ jobs:
pull_request_number: ${{ github.event.number }}
secrets: inherit
check-failures:
name: Check for failures
if: false
runs-on: ubuntu-22.04
if: always()
runs-on: ubuntu-24.04
needs:
- lint
- build-docker
- build-container
- build-stub-swagger
- build-mssqlmigratorutility
- self-host-build
@ -586,7 +591,7 @@ jobs:
steps:
- name: Check if any job failed
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')
&& contains(needs.*.result, 'failure')
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

@ -49,6 +49,8 @@ jobs:
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: cx_result.sarif
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
quality:
name: Quality scan

View File

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

View File

@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -370,6 +376,7 @@ Global
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {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}
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
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.OrganizationFeatures.OrganizationUsers.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.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -28,6 +31,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxStrategy _automaticTaxStrategy;
public RemoveOrganizationFromProviderCommand(
IEventService eventService,
@ -40,7 +44,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
IProviderBillingService providerBillingService,
ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient)
IPricingClient pricingClient,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
{
_eventService = eventService;
_mailService = mailService;
@ -53,6 +58,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
_automaticTaxStrategy = automaticTaxStrategy;
}
public async Task RemoveOrganizationFromProvider(
@ -107,10 +113,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
organization.IsValidClient() &&
!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Description = string.Empty,
Email = organization.BillingEmail
Email = organization.BillingEmail,
Expand = ["tax", "tax_ids"]
});
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
@ -120,7 +127,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Customer = organization.GatewayCustomerId,
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
Metadata = new Dictionary<string, string>
{
{ "organizationId", organization.Id.ToString() }
@ -130,6 +136,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
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);
organization.GatewaySubscriptionId = subscription.Id;

View File

@ -14,6 +14,7 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
@ -22,6 +23,7 @@ using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using CsvHelper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Stripe;
@ -29,10 +31,10 @@ namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService(
IEventService eventService,
IFeatureService featureService,
IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IPricingClient pricingClient,
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository,
@ -40,7 +42,9 @@ public class ProviderBillingService(
IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
ITaxService taxService) : IProviderBillingService
ITaxService taxService,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
: IProviderBillingService
{
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization(
@ -143,36 +147,29 @@ public class ProviderBillingService(
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.");
}
if (plan.PlanType == command.NewPlan)
if (providerPlan.PlanType == newPlanType)
{
return;
}
var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType);
var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan);
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
var oldPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
var newPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, newPlanType);
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
providerPlan.PlanType = newPlanType;
await providerPlanRepository.ReplaceAsync(providerPlan);
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => x.Price.Id == oldPriceId);
var updateOptions = new SubscriptionUpdateOptions
{
@ -180,7 +177,7 @@ public class ProviderBillingService(
[
new SubscriptionItemOptions
{
Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId,
Price = newPriceId,
Quantity = oldSubscriptionItem!.Quantity
},
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)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 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)
{
@ -205,8 +204,8 @@ public class ProviderBillingService(
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = newPlanConfiguration.Name;
organization.PlanType = newPlanType;
organization.Plan = newPlan.Name;
await organizationRepository.ReplaceAsync(organization);
}
}
@ -400,7 +399,7 @@ public class ProviderBillingService(
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
var update = CurrySeatScalingUpdate(
var scaleQuantityTo = CurrySeatScalingUpdate(
provider,
providerPlan,
newlyAssignedSeatTotal);
@ -423,9 +422,7 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
await update(
seatMinimum,
newlyAssignedSeatTotal);
await scaleQuantityTo(newlyAssignedSeatTotal);
}
/*
* Above the limit => Above the limit:
@ -434,9 +431,7 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
await update(
currentlyAssignedSeatTotal,
newlyAssignedSeatTotal);
await scaleQuantityTo(newlyAssignedSeatTotal);
}
/*
* Above the limit => Below the limit:
@ -445,9 +440,7 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal <= seatMinimum)
{
await update(
currentlyAssignedSeatTotal,
seatMinimum);
await scaleQuantityTo(seatMinimum);
}
}
@ -557,7 +550,8 @@ public class ProviderBillingService(
{
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);
@ -580,19 +574,17 @@ public class ProviderBillingService(
throw new BillingException();
}
var priceId = ProviderPriceAdapter.GetActivePriceId(provider, providerPlan.PlanType);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
Price = priceId,
Quantity = providerPlan.SeatMinimum
});
}
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id,
DaysUntilDue = 30,
@ -605,6 +597,15 @@ public class ProviderBillingService(
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
};
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
try
{
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
@ -643,43 +644,37 @@ public class ProviderBillingService(
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.");
}
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
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 =
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 = newPlan.PasswordManager.StripeProviderPortalSeatPlanId;
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, updatedPlanType);
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
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
{
@ -694,7 +689,7 @@ public class ProviderBillingService(
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum
Quantity = updatedSeatMinimum
});
}
}
@ -702,9 +697,9 @@ public class ProviderBillingService(
{
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
if (updatedSeatMinimum <= totalSeats)
{
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
providerPlan.PurchasedSeats = totalSeats - updatedSeatMinimum;
}
else
{
@ -713,12 +708,12 @@ public class ProviderBillingService(
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum
Quantity = updatedSeatMinimum
});
}
}
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
providerPlan.SeatMinimum = updatedSeatMinimum;
await providerPlanRepository.ReplaceAsync(providerPlan);
}
@ -726,23 +721,33 @@ public class ProviderBillingService(
if (subscriptionItemOptionsList.Count > 0)
{
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
}
}
private Func<int, int, Task> CurrySeatScalingUpdate(
private Func<int, Task> CurrySeatScalingUpdate(
Provider provider,
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(
provider,
plan,
currentlySubscribedSeats,
newlySubscribedSeats);
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
var item = subscription.Items.First(item => item.Price.Id == priceId);
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
Items = [
new SubscriptionItemOptions
{
Id = item.Id,
Price = priceId,
Quantity = newlySubscribedSeats
}
]
});
var newlyPurchasedSeats = 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 +0,0 @@
*
!obj/build-output/publish/*
!obj/Docker/empty/
!entrypoint.sh

View File

@ -1,10 +1,8 @@
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
@ -24,10 +22,8 @@ public class GroupsController : Controller
private readonly IGetGroupsListQuery _getGroupsListQuery;
private readonly IDeleteGroupCommand _deleteGroupCommand;
private readonly IPatchGroupCommand _patchGroupCommand;
private readonly IPatchGroupCommandvNext _patchGroupCommandvNext;
private readonly IPostGroupCommand _postGroupCommand;
private readonly IPutGroupCommand _putGroupCommand;
private readonly IFeatureService _featureService;
public GroupsController(
IGroupRepository groupRepository,
@ -35,10 +31,8 @@ public class GroupsController : Controller
IGetGroupsListQuery getGroupsListQuery,
IDeleteGroupCommand deleteGroupCommand,
IPatchGroupCommand patchGroupCommand,
IPatchGroupCommandvNext patchGroupCommandvNext,
IPostGroupCommand postGroupCommand,
IPutGroupCommand putGroupCommand,
IFeatureService featureService
IPutGroupCommand putGroupCommand
)
{
_groupRepository = groupRepository;
@ -46,10 +40,8 @@ public class GroupsController : Controller
_getGroupsListQuery = getGroupsListQuery;
_deleteGroupCommand = deleteGroupCommand;
_patchGroupCommand = patchGroupCommand;
_patchGroupCommandvNext = patchGroupCommandvNext;
_postGroupCommand = postGroupCommand;
_putGroupCommand = putGroupCommand;
_featureService = featureService;
}
[HttpGet("{id}")]
@ -103,21 +95,13 @@ public class GroupsController : Controller
[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests))
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
throw new NotFoundException("Group not found.");
}
await _patchGroupCommandvNext.PatchGroupAsync(group, model);
return new NoContentResult();
throw new NotFoundException("Group not found.");
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
await _patchGroupCommand.PatchGroupAsync(group, model);
return new NoContentResult();
}

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@ -23,7 +24,7 @@ public class UsersController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IPatchUserCommand _patchUserCommand;
private readonly IPostUserCommand _postUserCommand;
private readonly ILogger<UsersController> _logger;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public UsersController(
IOrganizationUserRepository organizationUserRepository,
@ -32,7 +33,7 @@ public class UsersController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand,
ILogger<UsersController> logger)
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
@ -40,7 +41,7 @@ public class UsersController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand;
_logger = logger;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
}
[HttpGet("{id}")]
@ -93,7 +94,7 @@ public class UsersController : Controller
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)
{

View File

@ -61,10 +61,12 @@ WORKDIR /app
# App stage #
###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
LABEL com.bitwarden.product="bitwarden"
EXPOSE 5000
ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
EXPOSE 5000
ENV PROJECT_NAME=Scim
RUN apt-get update \

View File

@ -5,5 +5,5 @@ namespace Bit.Scim.Groups.Interfaces;
public interface IPatchGroupCommand
{
Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model);
Task PatchGroupAsync(Group group, ScimPatchModel model);
}

View File

@ -1,9 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IPatchGroupCommandvNext
{
Task PatchGroupAsync(Group group, ScimPatchModel model);
}

View File

@ -5,8 +5,10 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
namespace Bit.Scim.Groups;
@ -16,118 +18,137 @@ public class PatchGroupCommand : IPatchGroupCommand
private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommand> _logger;
private readonly IOrganizationRepository _organizationRepository;
public PatchGroupCommand(
IGroupRepository groupRepository,
IGroupService groupService,
IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommand> logger)
ILogger<PatchGroupCommand> logger,
IOrganizationRepository organizationRepository)
{
_groupRepository = groupRepository;
_groupService = groupService;
_updateGroupCommand = updateGroupCommand;
_logger = logger;
_organizationRepository = organizationRepository;
}
public async Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model)
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organization.Id)
{
throw new NotFoundException("Group not found.");
}
var operationHandled = false;
foreach (var operation in model.Operations)
{
// Replace operations
if (operation.Op?.ToLowerInvariant() == "replace")
{
// Replace a list of members
if (operation.Path?.ToLowerInvariant() == "members")
await HandleOperationAsync(group, operation);
}
}
private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
{
switch (operation.Op?.ToLowerInvariant())
{
// Replace a list of members
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids);
operationHandled = true;
break;
}
// Replace group name from path
else if (operation.Path?.ToLowerInvariant() == "displayname")
// Replace group name from path
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
{
group.Name = operation.Value.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
operationHandled = true;
break;
}
// Replace group name from value object
else if (string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
// Replace group name from value object
case PatchOps.Replace when
string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
{
group.Name = displayNameProperty.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
operationHandled = true;
break;
}
}
// Add a single member
else if (operation.Op?.ToLowerInvariant() == "add" &&
case PatchOps.Add when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
{
var addId = GetOperationPathId(operation.Path);
if (addId.HasValue)
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var addId):
{
await AddMembersAsync(group, [addId]);
break;
}
// Add a list of members
case PatchOps.Add when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
break;
}
// Remove a single member
case PatchOps.Remove when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var removeId):
{
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
break;
}
// Remove a list of members
case PatchOps.Remove when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
orgUserIds.Add(addId.Value);
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
break;
}
}
// Add a list of members
else if (operation.Op?.ToLowerInvariant() == "add" &&
operation.Path?.ToLowerInvariant() == "members")
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Add(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
// Remove a single member
else if (operation.Op?.ToLowerInvariant() == "remove" &&
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
{
var removeId = GetOperationPathId(operation.Path);
if (removeId.HasValue)
{
await _groupService.DeleteUserAsync(group, removeId.Value, EventSystemUser.SCIM);
operationHandled = true;
}
}
// Remove a list of members
else if (operation.Op?.ToLowerInvariant() == "remove" &&
operation.Path?.ToLowerInvariant() == "members")
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
}
if (!operationHandled)
{
_logger.LogWarning("Group patch operation not handled: {0} : ",
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
default:
{
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
break;
}
}
}
private List<Guid> GetOperationValueIds(JsonElement objArray)
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
{
var ids = new List<Guid>();
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
// is removed. To avoid excessive load on the database, we check against the high availability replica and
// return early if they already exist.
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
if (usersToAdd.IsSubsetOf(groupMembers))
{
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
return;
}
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
}
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new HashSet<Guid>();
foreach (var obj in objArray.EnumerateArray())
{
if (obj.TryGetProperty("value", out var valueProperty))
@ -141,13 +162,9 @@ public class PatchGroupCommand : IPatchGroupCommand
return ids;
}
private Guid? GetOperationPathId(string path)
private static bool TryGetOperationPathId(string path, out Guid pathId)
{
// Parse Guid from string like: members[value eq "{GUID}"}]
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
{
return id;
}
return null;
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
}
}

View File

@ -1,170 +0,0 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
namespace Bit.Scim.Groups;
public class PatchGroupCommandvNext : IPatchGroupCommandvNext
{
private readonly IGroupRepository _groupRepository;
private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommandvNext> _logger;
private readonly IOrganizationRepository _organizationRepository;
public PatchGroupCommandvNext(
IGroupRepository groupRepository,
IGroupService groupService,
IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommandvNext> logger,
IOrganizationRepository organizationRepository)
{
_groupRepository = groupRepository;
_groupService = groupService;
_updateGroupCommand = updateGroupCommand;
_logger = logger;
_organizationRepository = organizationRepository;
}
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
{
foreach (var operation in model.Operations)
{
await HandleOperationAsync(group, operation);
}
}
private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
{
switch (operation.Op?.ToLowerInvariant())
{
// Replace a list of members
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids);
break;
}
// Replace group name from path
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
{
group.Name = operation.Value.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}
// Replace group name from value object
case PatchOps.Replace when
string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
{
group.Name = displayNameProperty.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}
// Add a single member
case PatchOps.Add when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var addId):
{
await AddMembersAsync(group, [addId]);
break;
}
// Add a list of members
case PatchOps.Add when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
break;
}
// Remove a single member
case PatchOps.Remove when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var removeId):
{
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
break;
}
// Remove a list of members
case PatchOps.Remove when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
break;
}
default:
{
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
break;
}
}
}
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
{
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
// is removed. To avoid excessive load on the database, we check against the high availability replica and
// return early if they already exist.
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
if (usersToAdd.IsSubsetOf(groupMembers))
{
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
return;
}
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
}
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new HashSet<Guid>();
foreach (var obj in objArray.EnumerateArray())
{
if (obj.TryGetProperty("value", out var valueProperty))
{
if (valueProperty.TryGetGuid(out var guid))
{
ids.Add(guid);
}
}
}
return ids;
}
private static bool TryGetOperationPathId(string path, out Guid pathId)
{
// Parse Guid from string like: members[value eq "{GUID}"}]
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
}
}

View File

@ -1,8 +1,11 @@
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.Models.Business;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
namespace Bit.Scim.Models;
@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel
{
public ScimUserRequestModel()
: base(false)
{ }
{
}
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)
{
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.Repositories;
using Bit.Core.Services;
@ -11,15 +12,18 @@ public class PatchUserCommand : IPatchUserCommand
{
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly ILogger<PatchUserCommand> _logger;
public PatchUserCommand(
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
ILogger<PatchUserCommand> logger)
{
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_logger = logger;
}
@ -71,7 +75,7 @@ public class PatchUserCommand : IPatchUserCommand
{
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
{
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
return true;
}
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.Models.Commands;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Context;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
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;
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)
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_paymentService = paymentService;
_scimContext = scimContext;
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
{
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
}
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 email = invite.Emails.Single();
@ -44,7 +104,7 @@ public class PostUserCommand : IPostUserCommand
throw new BadRequestException();
}
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
if (orgUserByEmail != null)
{
@ -57,13 +117,21 @@ public class PostUserCommand : IPostUserCommand
throw new ConflictException();
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
invite.AccessSecretsManager = hasStandaloneSecretsManager;
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
invite, externalId);
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
EventSystemUser.SCIM,
invite,
externalId);
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
return orgUser;
}

View File

@ -10,7 +10,6 @@ public static class ScimServiceCollectionExtensions
public static void AddScimGroupCommands(this IServiceCollection services)
{
services.AddScoped<IPatchGroupCommand, PatchGroupCommand>();
services.AddScoped<IPatchGroupCommandvNext, PatchGroupCommandvNext>();
services.AddScoped<IPostGroupCommand, PostGroupCommand>();
services.AddScoped<IPutGroupCommand, PutGroupCommand>();
}

View File

@ -61,10 +61,12 @@ WORKDIR /app
# App stage #
###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
LABEL com.bitwarden.product="bitwarden"
EXPOSE 5000
ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
EXPOSE 5000
ENV PROJECT_NAME=Sso
RUN apt-get update \

View File

@ -228,6 +228,26 @@ public class RemoveOrganizationFromProviderCommandTests
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 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.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
@ -115,6 +116,8 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Type = ProviderType.MultiOrganizationEnterprise;
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var existingPlan = new ProviderPlan
{
@ -132,10 +135,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.ProviderSubscriptionGetAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is(provider.Id))
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
.Returns(new Subscription
{
Id = provider.GatewaySubscriptionId,
@ -158,7 +158,7 @@ public class ProviderBillingServiceTests
});
var command =
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId);
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
.Returns(StaticStore.GetPlan(command.NewPlan));
@ -170,6 +170,8 @@ public class ProviderBillingServiceTests
await providerPlanRepository.Received(1)
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId),
@ -405,6 +407,23 @@ public class ProviderBillingServiceTests
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
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
@ -427,11 +446,9 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
await sutProvider.GetDependency<IPaymentService>().DidNotReceiveWithAnyArgs().AdjustSeats(
Arg.Any<Provider>(),
Arg.Any<Bit.Core.Models.StaticStore.Plan>(),
Arg.Any<int>(),
Arg.Any<int>());
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionUpdateOptions>());
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
pPlan => pPlan.AllocatedSeats == 60));
@ -474,6 +491,23 @@ public class ProviderBillingServiceTests
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
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
@ -496,11 +530,12 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
provider,
StaticStore.GetPlan(providerPlan.PlanType),
providerPlan.SeatMinimum!.Value,
105);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
options.Items.First().Quantity == 105));
// 105 total seats - 100 minimum = 5 purchased seats
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);
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
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
@ -566,11 +618,12 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 110 current + 10 seat scale up = 120 seats
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
provider,
StaticStore.GetPlan(providerPlan.PlanType),
110,
120);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
options.Items.First().Quantity == 120));
// 120 total seats - 100 seat minimum = 20 purchased seats
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);
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
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
@ -636,11 +706,12 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
provider,
StaticStore.GetPlan(providerPlan.PlanType),
110,
providerPlan.SeatMinimum!.Value);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
options.Items.First().Quantity == providerPlan.SeatMinimum!.Value));
// Being below the seat minimum means no purchased seats.
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
@ -924,11 +995,15 @@ public class ProviderBillingServiceTests
{
provider.GatewaySubscriptionId = null;
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer
{
Id = "customer_id",
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(new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
});
var providerPlans = new List<ProviderPlan>
{
@ -973,13 +1048,18 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider,
Provider provider)
{
provider.Type = ProviderType.Msp;
provider.GatewaySubscriptionId = null;
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer
var customer = new Customer
{
Id = "customer_id",
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>
{
@ -1012,11 +1092,21 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.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 };
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>(
sub =>
sub.AutomaticTax.Enabled == true &&
@ -1024,9 +1114,9 @@ public class ProviderBillingServiceTests
sub.Customer == "customer_id" &&
sub.DaysUntilDue == 30 &&
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(1).Price == enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId &&
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
sub.Items.ElementAt(1).Quantity == 100 &&
sub.Metadata["providerId"] == provider.Id.ToString() &&
sub.OffSession == true &&
@ -1048,8 +1138,7 @@ public class ProviderBillingServiceTests
{
// Arrange
var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
provider,
[
(PlanType.TeamsMonthly, -10),
(PlanType.EnterpriseMonthly, 50)
@ -1068,6 +1157,8 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
@ -1097,9 +1188,7 @@ public class ProviderBillingServiceTests
}
};
stripeAdapter.ProviderSubscriptionGetAsync(
provider.GatewaySubscriptionId,
provider.Id).Returns(subscription);
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
var providerPlans = new List<ProviderPlan>
{
@ -1116,8 +1205,7 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
provider,
[
(PlanType.EnterpriseMonthly, 30),
(PlanType.TeamsMonthly, 20)
@ -1149,6 +1237,8 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
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>
{
@ -1195,8 +1285,7 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
provider,
[
(PlanType.EnterpriseMonthly, 70),
(PlanType.TeamsMonthly, 50)
@ -1228,6 +1317,8 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
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>
{
@ -1274,8 +1365,7 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
provider,
[
(PlanType.EnterpriseMonthly, 60),
(PlanType.TeamsMonthly, 60)
@ -1301,6 +1391,8 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
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>
{
@ -1347,8 +1439,7 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
provider,
[
(PlanType.EnterpriseMonthly, 80),
(PlanType.TeamsMonthly, 80)
@ -1380,6 +1471,8 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
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>
{
@ -1426,8 +1519,7 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
provider,
[
(PlanType.EnterpriseMonthly, 70),
(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

@ -20,6 +20,7 @@ public class GroupsControllerPatchTests : IClassFixture<ScimApplicationFactory>,
{
var databaseContext = _factory.GetDatabaseContext();
_factory.ReinitializeDbForTests(databaseContext);
return Task.CompletedTask;
}

View File

@ -1,251 +0,0 @@
using System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.IntegrationTest.Factories;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.Helpers;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Scim.IntegrationTest.Controllers.v2;
public class GroupsControllerPatchTestsvNext : IClassFixture<ScimApplicationFactory>, IAsyncLifetime
{
private readonly ScimApplicationFactory _factory;
public GroupsControllerPatchTestsvNext(ScimApplicationFactory factory)
{
_factory = factory;
// Enable the feature flag for new PatchGroupsCommand and stub out the old command to be safe
_factory.SubstituteService((IFeatureService featureService)
=> featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests).Returns(true));
_factory.SubstituteService((IPatchGroupCommand patchGroupCommand)
=> patchGroupCommand.PatchGroupAsync(Arg.Any<Organization>(), Arg.Any<Guid>(), Arg.Any<ScimPatchModel>())
.ThrowsAsync(new Exception("This test suite should be testing the vNext command, but the existing command was called.")));
}
public Task InitializeAsync()
{
var databaseContext = _factory.GetDatabaseContext();
_factory.ReinitializeDbForTests(databaseContext);
return Task.CompletedTask;
}
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Patch_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_ReplaceMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Single(databaseContext.GroupUsers);
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
var groupUser = databaseContext.GroupUsers.FirstOrDefault();
Assert.Equal(ScimApplicationFactory.TestOrganizationUserId2, groupUser.OrganizationUserId);
}
[Fact]
public async Task Patch_AddSingleMember_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId2}\"]",
Value = JsonDocument.Parse("{}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount + 1, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_AddListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId2;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}},{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId3}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId3));
}
[Fact]
public async Task Patch_RemoveSingleMember_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId1}\"]",
Value = JsonDocument.Parse("{}").RootElement
},
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
Assert.Equal(ScimApplicationFactory.InitialGroupCount, databaseContext.Groups.Count());
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
}
[Fact]
public async Task Patch_RemoveListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId1}\"}}, {{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId4}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Empty(databaseContext.GroupUsers);
}
[Fact]
public async Task Patch_NotFound()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = Guid.NewGuid();
var inputModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var expectedResponse = new ScimErrorResponseModel
{
Status = StatusCodes.Status404NotFound,
Detail = "Group not found.",
Schemas = new List<string> { ScimConstants.Scim2SchemaError }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
var responseModel = JsonSerializer.Deserialize<ScimErrorResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
}

View File

@ -1,9 +1,12 @@
using System.Text.Json;
using Bit.Core;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Scim.IntegrationTest.Factories;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;
namespace Bit.Scim.IntegrationTest.Controllers.v2;
@ -276,9 +279,18 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
[Fact]
public async Task Post_Success()
[Theory]
[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 displayName = "Test User 5";
var externalId = "UE";
@ -306,7 +318,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
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);
@ -316,7 +328,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
var databaseContext = _factory.GetDatabaseContext();
var databaseContext = localFactory.GetDatabaseContext();
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
}

View File

@ -1,15 +1,18 @@
using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@ -20,19 +23,16 @@ public class PatchGroupCommandTests
{
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, IEnumerable<Guid> userIds)
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommand> sutProvider,
Organization organization, Group group, IEnumerable<Guid> userIds)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
new()
{
Op = "replace",
Path = "members",
@ -42,26 +42,31 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => userIds.Contains(id))));
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count() &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var scimPatchModel = new Models.ScimPatchModel
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
new()
{
Op = "replace",
Path = "displayname",
@ -71,27 +76,55 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromPath_MissingOrganization_Throws(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns((Organization)null);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Path = "displayname",
Value = JsonDocument.Parse($"\"{displayName}\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PatchGroupAsync(group, scimPatchModel));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var scimPatchModel = new Models.ScimPatchModel
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
new()
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
@ -100,12 +133,39 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_MissingOrganization_Throws(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns((Organization)null);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PatchGroupAsync(group, scimPatchModel));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId)
@ -113,18 +173,14 @@ public class PatchGroupCommandTests
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
@ -133,9 +189,47 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Append(userId).Contains(id))));
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg => arg.Single() == userId));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup(
SutProvider<PatchGroupCommand> sutProvider,
Organization organization,
Group group,
ICollection<Guid> existingMembers)
{
// User being added is already in group
var userId = existingMembers.First();
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.DidNotReceiveWithAnyArgs()
.AddGroupUsersByIdAsync(default, default);
}
[Theory]
@ -145,18 +239,14 @@ public class PatchGroupCommandTests
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
new()
{
Op = "add",
Path = $"members",
@ -166,9 +256,101 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Concat(userIds).Contains(id))));
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group,
ICollection<Guid> existingMembers)
{
// Create 3 userIds
var fixture = new Fixture { RepeatCount = 3 };
var userIds = fixture.CreateMany<Guid>().ToList();
// Copy the list and add a duplicate
var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList();
Assert.Equal(4, userIdsWithDuplicate.Count);
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer
.Serialize(userIdsWithDuplicate
.Select(uid => new { value = uid })
.ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == 3 &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup(
SutProvider<PatchGroupCommand> sutProvider,
Organization organization, Group group,
ICollection<Guid> existingMembers,
ICollection<Guid> userIds)
{
// A user is already in the group, but some still need to be added
userIds.Add(existingMembers.First());
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
@ -177,10 +359,6 @@ public class PatchGroupCommandTests
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
@ -194,21 +372,19 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers)
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommand> sutProvider,
Organization organization, Group group, ICollection<Guid> existingMembers)
{
List<Guid> usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()];
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers);
@ -217,30 +393,58 @@ public class PatchGroupCommandTests
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
new()
{
Op = "remove",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(existingMembers.Select(uid => new { value = uid }).ToArray())).RootElement
Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Contains(id))));
var expectedRemainingUsers = existingMembers.Skip(2).ToList();
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == expectedRemainingUsers.Count &&
arg.ToHashSet().SetEquals(expectedRemainingUsers)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_NoAction_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group)
public async Task PatchGroup_InvalidOperation_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = [new ScimPatchModel.OperationModel { Op = "invalid operation" }],
Schemas = [ScimConstants.Scim2SchemaUser]
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
// Assert: no operation performed
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
// Assert: logging
sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning(default);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_NoOperation_Success(
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new Models.ScimPatchModel
{
@ -248,45 +452,11 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_NotFound_Throws(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Guid groupId)
{
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.PatchGroupAsync(organization, groupId, scimPatchModel));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_MismatchingOrganizationId_Throws(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Guid groupId)
{
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(groupId)
.Returns(new Group
{
Id = groupId,
OrganizationId = Guid.NewGuid()
});
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.PatchGroupAsync(organization, groupId, scimPatchModel));
}
}

View File

@ -1,381 +0,0 @@
using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Groups;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Scim.Test.Groups;
[SutProviderCustomize]
public class PatchGroupCommandvNextTests
{
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group, IEnumerable<Guid> userIds)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Path = "members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count() &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Path = "displayname",
Value = JsonDocument.Parse($"\"{displayName}\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg => arg.Single() == userId));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup(
SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization,
Group group,
ICollection<Guid> existingMembers)
{
// User being added is already in group
var userId = existingMembers.First();
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.DidNotReceiveWithAnyArgs()
.AddGroupUsersByIdAsync(default, default);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, ICollection<Guid> userIds)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group,
ICollection<Guid> existingMembers)
{
// Create 3 userIds
var fixture = new Fixture { RepeatCount = 3 };
var userIds = fixture.CreateMany<Guid>().ToList();
// Copy the list and add a duplicate
var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList();
Assert.Equal(4, userIdsWithDuplicate.Count);
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer
.Serialize(userIdsWithDuplicate
.Select(uid => new { value = uid })
.ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == 3 &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup(
SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group,
ICollection<Guid> existingMembers,
ICollection<Guid> userIds)
{
// A user is already in the group, but some still need to be added
userIds.Add(existingMembers.First());
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_RemoveSingleMember_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, Guid userId)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group, ICollection<Guid> existingMembers)
{
List<Guid> usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()];
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "remove",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
var expectedRemainingUsers = existingMembers.Skip(2).ToList();
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == expectedRemainingUsers.Count &&
arg.ToHashSet().SetEquals(expectedRemainingUsers)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_NoAction_Success(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
}
}

View File

@ -1,4 +1,5 @@
using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -43,7 +44,7 @@ public class PatchUserCommandTests
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]
@ -71,7 +72,7 @@ public class PatchUserCommandTests
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]
@ -147,7 +148,7 @@ public class PatchUserCommandTests
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);
}

View File

@ -27,7 +27,7 @@ public class PostUserCommandTests
ExternalId = externalId,
Emails = emails,
Active = true,
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
Schemas = [ScimConstants.Scim2SchemaUser]
};
sutProvider.GetDependency<IOrganizationUserRepository>()
@ -39,13 +39,16 @@ public class PostUserCommandTests
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IOrganizationService>()
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
.InviteUserAsync(organizationId,
invitingUserId: null,
EventSystemUser.SCIM,
Arg.Is<OrganizationUserInvite>(i =>
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
i.Type == OrganizationUserType.User &&
!i.Collections.Any() &&
!i.Groups.Any() &&
i.AccessSecretsManager), externalId)
i.AccessSecretsManager),
externalId)
.Returns(newUser);
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

@ -1,4 +0,0 @@
*
!obj/build-output/publish/*
!obj/Docker/empty/
!entrypoint.sh

View File

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

View File

@ -1,4 +0,0 @@
# *
# !obj/build-output/publish/*
# !obj/Docker/empty/
# !entrypoint.sh

View File

@ -8,6 +8,9 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
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.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
@ -55,8 +58,11 @@ public class OrganizationUsersController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public OrganizationUsersController(
IOrganizationRepository organizationRepository,
@ -79,8 +85,11 @@ public class OrganizationUsersController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService,
IPricingClient pricingClient)
IPricingClient pricingClient,
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -102,8 +111,11 @@ public class OrganizationUsersController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
_pricingClient = pricingClient;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
}
[HttpGet("{id}")]
@ -303,7 +315,7 @@ public class OrganizationUsersController : Controller
await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName);
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
await _organizationService.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
}
[HttpPost("{organizationUserId}/accept")]
@ -315,11 +327,13 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException();
}
var useMasterPasswordPolicy = await ShouldHandleResetPasswordAsync(orgId);
var useMasterPasswordPolicy = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
? (await _policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id)).AutoEnrollEnabled(orgId)
: await ShouldHandleResetPasswordAsync(orgId);
if (useMasterPasswordPolicy && string.IsNullOrWhiteSpace(model.ResetPasswordKey))
{
throw new BadRequestException(string.Empty, "Master Password reset is required, but not provided.");
throw new BadRequestException("Master Password reset is required, but not provided.");
}
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
@ -357,7 +371,7 @@ public class OrganizationUsersController : Controller
}
var userId = _userService.GetProperUserId(User);
var result = await _organizationService.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value);
var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value);
}
[HttpPost("confirm")]
@ -371,7 +385,7 @@ public class OrganizationUsersController : Controller
}
var userId = _userService.GetProperUserId(User);
var results = await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value);
var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
@ -620,14 +634,14 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/restore")]
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")]
[HttpPut("restore")]
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")]

View File

@ -16,6 +16,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
@ -61,6 +63,7 @@ public class OrganizationsController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand;
private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
public OrganizationsController(
@ -84,6 +87,7 @@ public class OrganizationsController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
IOrganizationDeleteCommand organizationDeleteCommand,
IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient)
{
_organizationRepository = organizationRepository;
@ -106,6 +110,7 @@ public class OrganizationsController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand;
_organizationDeleteCommand = organizationDeleteCommand;
_policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient;
}
@ -163,8 +168,13 @@ public class OrganizationsController : Controller
throw new NotFoundException();
}
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
{
var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id));
}
var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
{
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
@ -172,6 +182,7 @@ public class OrganizationsController : Controller
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
}
[HttpPost("")]

View File

@ -13,7 +13,17 @@ public static class PolicyDetailResponses
{
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

@ -355,6 +355,7 @@ public class AccountsController : Controller
throw new BadRequestException(ModelState);
}
[Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")]
[HttpPost("key")]
public async Task PostKey([FromBody] UpdateKeyRequestModel model)
{

View File

@ -0,0 +1,66 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.Auth.Models.Request.Accounts;
public class MasterPasswordUnlockDataModel : IValidatableObject
{
public required KdfType KdfType { get; set; }
public required int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
[StrictEmailAddress]
[StringLength(256)]
public required string Email { get; set; }
[StringLength(300)]
public required string MasterKeyAuthenticationHash { get; set; }
[EncryptedString] public required string MasterKeyEncryptedUserKey { get; set; }
[StringLength(50)]
public string? MasterPasswordHint { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (KdfType == KdfType.PBKDF2_SHA256)
{
if (KdfMemory.HasValue || KdfParallelism.HasValue)
{
yield return new ValidationResult("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256", new[] { nameof(KdfMemory), nameof(KdfParallelism) });
}
}
else if (KdfType == KdfType.Argon2id)
{
if (!KdfMemory.HasValue || !KdfParallelism.HasValue)
{
yield return new ValidationResult("KdfMemory and KdfParallelism must have values for Argon2id", new[] { nameof(KdfMemory), nameof(KdfParallelism) });
}
}
else
{
yield return new ValidationResult("Invalid KdfType", new[] { nameof(KdfType) });
}
}
public MasterPasswordUnlockData ToUnlockData()
{
var data = new MasterPasswordUnlockData
{
KdfType = KdfType,
KdfIterations = KdfIterations,
KdfMemory = KdfMemory,
KdfParallelism = KdfParallelism,
Email = Email,
MasterKeyAuthenticationHash = MasterKeyAuthenticationHash,
MasterKeyEncryptedUserKey = MasterKeyEncryptedUserKey,
MasterPasswordHint = MasterPasswordHint
};
return data;
}
}

View File

@ -76,6 +76,13 @@ public class OrganizationSponsorshipsController : Controller
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{
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(
sponsoringOrg,
@ -89,6 +96,14 @@ public class OrganizationSponsorshipsController : Controller
[SelfHosted(NotSelfHostedOnly = true)]
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
.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.");
}
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(
sponsorship,
await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId));

View File

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Api.Request;
@ -125,7 +124,7 @@ public class DevicesController : Controller
}
[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);
@ -134,14 +133,7 @@ public class DevicesController : Controller
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);
if (device == null)
{
throw new NotFoundException();

View File

@ -61,10 +61,12 @@ WORKDIR /app
# App stage #
###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
LABEL com.bitwarden.product="bitwarden"
EXPOSE 5000
ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
EXPOSE 5000
ENV PROJECT_NAME=Api
RUN apt-get update \

View File

@ -1,10 +1,24 @@
#nullable enable
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.KeyManagement.Validators;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -19,18 +33,48 @@ public class AccountsKeyManagementController : Controller
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand;
private readonly IUserService _userService;
private readonly IRotateUserAccountKeysCommand _rotateUserAccountKeysCommand;
private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
private readonly IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> _folderValidator;
private readonly IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> _sendValidator;
private readonly IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>
_emergencyAccessValidator;
private readonly IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>,
IReadOnlyList<OrganizationUser>>
_organizationUserValidator;
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
_webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository,
IEmergencyAccessRepository emergencyAccessRepository,
IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand)
IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand,
IRotateUserAccountKeysCommand rotateUserKeyCommandV2,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> folderValidator,
IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> sendValidator,
IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>
emergencyAccessValidator,
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
organizationUserValidator,
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
{
_userService = userService;
_featureService = featureService;
_regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand;
_organizationUserRepository = organizationUserRepository;
_emergencyAccessRepository = emergencyAccessRepository;
_rotateUserAccountKeysCommand = rotateUserKeyCommandV2;
_cipherValidator = cipherValidator;
_folderValidator = folderValidator;
_sendValidator = sendValidator;
_emergencyAccessValidator = emergencyAccessValidator;
_organizationUserValidator = organizationUserValidator;
_webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
}
[HttpPost("regenerate-keys")]
@ -47,4 +91,46 @@ public class AccountsKeyManagementController : Controller
await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id),
usersOrganizationAccounts, designatedEmergencyAccess);
}
[HttpPost("rotate-user-account-keys")]
public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var dataModel = new RotateUserAccountKeysData
{
OldMasterKeyAuthenticationHash = model.OldMasterKeyAuthenticationHash,
UserKeyEncryptedAccountPrivateKey = model.AccountKeys.UserKeyEncryptedAccountPrivateKey,
AccountPublicKey = model.AccountKeys.AccountPublicKey,
MasterPasswordUnlockData = model.AccountUnlockData.MasterPasswordUnlockData.ToUnlockData(),
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
DeviceKeys = await _deviceValidator.ValidateAsync(user, model.AccountUnlockData.DeviceKeyUnlockData),
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
Sends = await _sendValidator.ValidateAsync(user, model.AccountData.Sends),
};
var result = await _rotateUserAccountKeysCommand.RotateUserAccountKeysAsync(user, dataModel);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
}

View File

@ -0,0 +1,10 @@
#nullable enable
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
public class AccountKeysRequestModel
{
[EncryptedString] public required string UserKeyEncryptedAccountPrivateKey { get; set; }
public required string AccountPublicKey { get; set; }
}

View File

@ -0,0 +1,13 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.KeyManagement.Models.Requests;
public class RotateUserAccountKeysAndDataRequestModel
{
[StringLength(300)]
public required string OldMasterKeyAuthenticationHash { get; set; }
public required UnlockDataRequestModel AccountUnlockData { get; set; }
public required AccountKeysRequestModel AccountKeys { get; set; }
public required AccountDataRequestModel AccountData { get; set; }
}

View File

@ -0,0 +1,18 @@
#nullable enable
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.Auth.Models.Api.Request;
namespace Bit.Api.KeyManagement.Models.Requests;
public class UnlockDataRequestModel
{
// All methods to get to the userkey
public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; }
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }
public required IEnumerable<OtherDeviceKeysUpdateRequestModel> DeviceKeyUnlockData { get; set; }
}

View File

@ -0,0 +1,12 @@
#nullable enable
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
namespace Bit.Api.KeyManagement.Models.Requests;
public class AccountDataRequestModel
{
public required IEnumerable<CipherWithIdRequestModel> Ciphers { get; set; }
public required IEnumerable<FolderWithIdRequestModel> Folders { get; set; }
public required IEnumerable<SendWithIdRequestModel> Sends { 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)
{
// 2024-06: Remove after 3 releases, for backward compatibility
if (keysToRotate == null)
{
return new List<WebAuthnLoginRotateKeyData>();
}
var result = new List<WebAuthnLoginRotateKeyData>();
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
if (existing == null || !existing.Any())
if (existing == null)
{
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);
if (keyToRotate == null)

View File

@ -22,6 +22,7 @@ public class NotificationResponseModel : ResponseModel
Title = notificationStatusDetails.Title;
Body = notificationStatusDetails.Body;
Date = notificationStatusDetails.RevisionDate;
TaskId = notificationStatusDetails.TaskId;
ReadDate = notificationStatusDetails.ReadDate;
DeletedDate = notificationStatusDetails.DeletedDate;
}
@ -40,6 +41,8 @@ public class NotificationResponseModel : ResponseModel
public DateTime Date { get; set; }
public Guid? TaskId { get; set; }
public DateTime? ReadDate { 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.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request;
#if !OSS
using Bit.Commercial.Core.SecretsManager;
@ -168,6 +168,9 @@ public class Startup
services
.AddScoped<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>,
WebAuthnLoginKeyRotationValidator>();
services
.AddScoped<IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>,
DeviceRotationValidator>();
// Services
services.AddBaseServices(globalSettings);

View File

@ -16,6 +16,7 @@ using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.Permissions;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Queries;
@ -345,6 +346,77 @@ public class CiphersController : Controller
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>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary>
@ -763,12 +835,12 @@ public class CiphersController : Controller
[HttpDelete("{id}/admin")]
[HttpPost("{id}/delete-admin")]
public async Task DeleteAdmin(string id)
public async Task DeleteAdmin(Guid id)
{
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 ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{
throw new NotFoundException();
}
@ -808,7 +880,7 @@ public class CiphersController : Controller
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
!await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
{
throw new NotFoundException();
}
@ -830,12 +902,12 @@ public class CiphersController : Controller
}
[HttpPut("{id}/delete-admin")]
public async Task PutDeleteAdmin(string id)
public async Task PutDeleteAdmin(Guid id)
{
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 ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{
throw new NotFoundException();
}
@ -871,7 +943,7 @@ public class CiphersController : Controller
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
!await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
{
throw new NotFoundException();
}
@ -899,12 +971,12 @@ public class CiphersController : Controller
}
[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 cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
var cipher = await GetByIdAsync(id, userId);
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();
}
@ -944,7 +1016,7 @@ public class CiphersController : Controller
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();
}

View File

@ -5,6 +5,7 @@ using Bit.Core;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Core.Vault.Commands.Interfaces;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Queries;
using Microsoft.AspNetCore.Authorization;
@ -89,11 +90,28 @@ public class SecurityTaskController : Controller
public async Task<ListResponseModel<SecurityTasksResponseModel>> BulkCreateTasks(Guid orgId,
[FromBody] BulkCreateSecurityTasksRequestModel model)
{
var securityTasks = await _createManyTasksCommand.CreateAsync(orgId, model.Tasks);
// Retrieve existing pending security tasks for the organization
var pendingSecurityTasks = await _getTasksForOrganizationQuery.GetTasksAsync(orgId, SecurityTaskStatus.Pending);
await _createManyTaskNotificationsCommand.CreateAsync(orgId, securityTasks);
// Get the security tasks that are already associated with a cipher within the submitted model
var existingTasks = pendingSecurityTasks.Where(x => model.Tasks.Any(y => y.CipherId == x.CipherId)).ToList();
var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
// Get tasks that need to be created
var tasksToCreateFromModel = model.Tasks.Where(x => !existingTasks.Any(y => y.CipherId == x.CipherId)).ToList();
ICollection<SecurityTask> newSecurityTasks = new List<SecurityTask>();
if (tasksToCreateFromModel.Count != 0)
{
newSecurityTasks = await _createManyTasksCommand.CreateAsync(orgId, tasksToCreateFromModel);
}
// Combine existing tasks and newly created tasks
var allTasks = existingTasks.Concat(newSecurityTasks);
await _createManyTaskNotificationsCommand.CreateAsync(orgId, allTasks);
var response = allTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
return new ListResponseModel<SecurityTasksResponseModel>(response);
}
}

View File

@ -1,4 +0,0 @@
*
!obj/build-output/publish/*
!obj/Docker/empty/
!entrypoint.sh

View File

@ -2,9 +2,6 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Billing</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<!-- Temp exclusions until warnings are fixed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS9113</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Billing' " />

View File

@ -57,9 +57,9 @@ WORKDIR /app
# App stage #
###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
LABEL com.bitwarden.product="bitwarden"
ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
EXPOSE 5000

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.Enums;
using Bit.Core.Billing.Extensions;
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.Repositories;
using Bit.Core.Services;
@ -12,6 +15,7 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class UpcomingInvoiceHandler(
IFeatureService featureService,
ILogger<StripeEventProcessor> logger,
IMailService mailService,
IOrganizationRepository organizationRepository,
@ -21,7 +25,8 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand)
IValidateSponsorshipCommand validateSponsorshipCommand,
IAutomaticTaxFactory automaticTaxFactory)
: IUpcomingInvoiceHandler
{
public async Task HandleAsync(Event parsedEvent)
@ -136,6 +141,21 @@ public class UpcomingInvoiceHandler(
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 ||
!subscription.Customer.HasBillingLocation() ||
await IsNonTaxableNonUSBusinessUseSubscription(subscription))

View File

@ -87,8 +87,7 @@ public class Startup
// TODO: no longer be required - see PM-1880
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
// Mvc
services.AddMvc(config =>
services.AddControllers(config =>
{
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;
SmSeats = license.SmSeats;
SmServiceAccounts = license.SmServiceAccounts;
UseRiskInsights = license.UseRiskInsights;
}
}

View File

@ -0,0 +1,18 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.AdminConsole.Entities;
public class OrganizationIntegration : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public IntegrationType Type { get; set; }
public string? Configuration { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public void SetNewId() => Id = CoreHelpers.GenerateComb();
}

View File

@ -0,0 +1,19 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.AdminConsole.Entities;
public class OrganizationIntegrationConfiguration : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid OrganizationIntegrationId { get; set; }
public EventType EventType { get; set; }
public string? Configuration { get; set; }
public string? Template { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public void SetNewId() => Id = CoreHelpers.GenerateComb();
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Enums;
public enum IntegrationType : int
{
Slack = 1,
Webhook = 2,
}

View File

@ -1,3 +1,8 @@
namespace Bit.Core.AdminConsole.Errors;
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,
LimitItemDeletion = LimitItemDeletion,
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
Status = Status
Status = Status,
UseRiskInsights = UseRiskInsights,
};
}
}

View File

@ -0,0 +1,186 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
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;
public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IUserRepository _userRepository;
private readonly IEventService _eventService;
private readonly IMailService _mailService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IPushNotificationService _pushNotificationService;
private readonly IPushRegistrationService _pushRegistrationService;
private readonly IPolicyService _policyService;
private readonly IDeviceRepository _deviceRepository;
public ConfirmOrganizationUserCommand(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IUserRepository userRepository,
IEventService eventService,
IMailService mailService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IPushNotificationService pushNotificationService,
IPushRegistrationService pushRegistrationService,
IPolicyService policyService,
IDeviceRepository deviceRepository)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_userRepository = userRepository;
_eventService = eventService;
_mailService = mailService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_pushNotificationService = pushNotificationService;
_pushRegistrationService = pushRegistrationService;
_policyService = policyService;
_deviceRepository = deviceRepository;
}
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId)
{
var result = await ConfirmUsersAsync(
organizationId,
new Dictionary<Guid, string>() { { organizationUserId, key } },
confirmingUserId);
if (!result.Any())
{
throw new BadRequestException("User not valid.");
}
var (orgUser, error) = result[0];
if (error != "")
{
throw new BadRequestException(error);
}
return orgUser;
}
public async Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId)
{
var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys);
var validSelectedOrganizationUsers = selectedOrganizationUsers
.Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null)
.ToList();
if (!validSelectedOrganizationUsers.Any())
{
return new List<Tuple<OrganizationUser, string>>();
}
var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList();
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds);
var users = await _userRepository.GetManyAsync(validSelectedUserIds);
var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds);
var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u);
var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value)
.ToDictionary(u => u.Key, u => u.ToList());
var succeededUsers = new List<OrganizationUser>();
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var user in users)
{
if (!keyedFilteredUsers.ContainsKey(user.Id))
{
continue;
}
var orgUser = keyedFilteredUsers[user.Id];
var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List<OrganizationUser>());
try
{
if (organization.PlanType == PlanType.Free && (orgUser.Type == OrganizationUserType.Admin
|| orgUser.Type == OrganizationUserType.Owner))
{
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id);
if (adminCount > 0)
{
throw new BadRequestException("User can only be an admin of one free organization.");
}
}
var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled;
await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled);
orgUser.Status = OrganizationUserStatusType.Confirmed;
orgUser.Key = keys[orgUser.Id];
orgUser.Email = null;
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager);
await DeleteAndPushUserRegistrationAsync(organizationId, user.Id);
succeededUsers.Add(orgUser);
result.Add(Tuple.Create(orgUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(orgUser, e.Message));
}
}
await _organizationUserRepository.ReplaceManyAsync(succeededUsers);
return result;
}
private async Task CheckPoliciesAsync(Guid organizationId, User user,
ICollection<OrganizationUser> userOrgs, bool twoFactorEnabled)
{
// Enforce Two Factor Authentication Policy for this organization
var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication))
.Any(p => p.OrganizationId == organizationId);
if (orgRequiresTwoFactor && !twoFactorEnabled)
{
throw new BadRequestException("User does not have two-step login enabled.");
}
var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId);
var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg);
var otherSingleOrgPolicies =
singleOrgPolicies.Where(p => p.OrganizationId != organizationId);
// Enforce Single Organization Policy for this organization
if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId))
{
throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations.");
}
// Enforce Single Organization Policy of other organizations user is a member of
if (otherSingleOrgPolicies.Any())
{
throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it.");
}
}
private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId)
{
var devices = await GetUserDeviceIdsAsync(userId);
await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices,
organizationId.ToString());
await _pushNotificationService.PushSyncOrgKeysAsync(userId);
}
private async Task<IEnumerable<string>> GetUserDeviceIdsAsync(Guid userId)
{
var devices = await _deviceRepository.GetManyByUserIdAsync(userId);
return devices
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
.Select(d => d.Id.ToString());
}
}

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)
{
throw new BadRequestException("Member is not managed by the organization.");

View File

@ -0,0 +1,30 @@
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
/// <summary>
/// Command to confirm organization users who have accepted their invitations.
/// </summary>
public interface IConfirmOrganizationUserCommand
{
/// <summary>
/// Confirms a single organization user who has accepted their invitation.
/// </summary>
/// <param name="organizationId">The ID of the organization.</param>
/// <param name="organizationUserId">The ID of the organization user to confirm.</param>
/// <param name="key">The encrypted organization key for the user.</param>
/// <param name="confirmingUserId">The ID of the user performing the confirmation.</param>
/// <returns>The confirmed organization user.</returns>
/// <exception cref="BadRequestException">Thrown when the user is not valid or cannot be confirmed.</exception>
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId);
/// <summary>
/// Confirms multiple organization users who have accepted their invitations.
/// </summary>
/// <param name="organizationId">The ID of the organization.</param>
/// <param name="keys">A dictionary mapping organization user IDs to their encrypted organization keys.</param>
/// <param name="confirmingUserId">The ID of the user performing the confirmation.</param>
/// <returns>A list of tuples containing the organization user and an error message (if any).</returns>
Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId);
}

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

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