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

Merge branch 'main' into PM-19357

This commit is contained in:
jaasen-livefront 2025-04-09 11:34:33 -07:00
commit 3fae927f77
No known key found for this signature in database
330 changed files with 25326 additions and 4076 deletions

10
.github/CODEOWNERS vendored
View File

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

View File

@ -7,22 +7,18 @@ on:
- "main" - "main"
- "rc" - "rc"
- "hotfix-rc" - "hotfix-rc"
pull_request_target: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
workflow_call:
inputs: {}
env: env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs: jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs:
- check-run
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -40,6 +36,8 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs:
- lint - lint
outputs:
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -75,6 +73,14 @@ jobs:
base_path: ./bitwarden_license/src base_path: ./bitwarden_license/src
node: true node: true
steps: steps:
- name: Check secrets
id: check-secrets
env:
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
run: |
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
@ -134,6 +140,7 @@ jobs:
id-token: write id-token: write
needs: needs:
- build-artifacts - build-artifacts
if: ${{ needs.build-artifacts.outputs.has_secrets == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -227,7 +234,7 @@ jobs:
- name: Generate Docker image tag - name: Generate Docker image tag
id: tag id: tag
run: | run: |
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
else else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
@ -289,11 +296,11 @@ jobs:
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}" "GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign - name: Install Cosign
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Sign image with Cosign - name: Sign image with Cosign
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env: env:
DIGEST: ${{ steps.build-docker.outputs.digest }} DIGEST: ${{ steps.build-docker.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }} TAGS: ${{ steps.image-tags.outputs.tags }}
@ -317,6 +324,8 @@ jobs:
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} 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 }}
upload: upload:
name: Upload name: Upload
@ -341,7 +350,7 @@ jobs:
- name: Make Docker stubs - name: Make Docker stubs
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
# Set proper setup image based on branch # Set proper setup image based on branch
@ -383,7 +392,7 @@ jobs:
- name: Make Docker stub checksums - name: Make Docker stub checksums
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
@ -391,7 +400,7 @@ jobs:
- name: Upload Docker stub US artifact - name: Upload Docker stub US artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -401,7 +410,7 @@ jobs:
- name: Upload Docker stub EU artifact - name: Upload Docker stub EU artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -411,7 +420,7 @@ jobs:
- name: Upload Docker stub US checksum artifact - name: Upload Docker stub US checksum artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -421,7 +430,7 @@ jobs:
- name: Upload Docker stub EU checksum artifact - name: Upload Docker stub EU checksum artifact
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
@ -550,7 +559,7 @@ jobs:
self-host-build: self-host-build:
name: Trigger self-host build name: Trigger self-host build
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs:
@ -585,7 +594,7 @@ jobs:
trigger-k8s-deploy: trigger-k8s-deploy:
name: Trigger k8s deploy name: Trigger k8s deploy
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs:
- build-docker - build-docker
@ -618,57 +627,20 @@ jobs:
} }
}) })
trigger-ee-updates: setup-ephemeral-environment:
name: Trigger Ephemeral Environment updates name: Setup Ephemeral Environment
needs: build-docker
if: | if: |
github.event_name == 'pull_request_target' needs.build-artifacts.outputs.has_secrets == 'true'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') && github.event_name == 'pull_request'
runs-on: ubuntu-24.04
needs:
- build-docker
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger Ephemeral Environment update
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'devops',
workflow_id: '_update_ephemeral_tags.yml',
ref: 'main',
inputs: {
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
}
})
trigger-ephemeral-environment-sync:
name: Trigger Ephemeral Environment Sync
needs: trigger-ee-updates
if: |
github.event_name == 'pull_request_target'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
with: with:
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
project: server project: server
sync_environment: true
pull_request_number: ${{ github.event.number }} pull_request_number: ${{ github.event.number }}
create_branch: true
secrets: inherit secrets: inherit
check-failures: check-failures:
name: Check for failures name: Check for failures
if: always() if: always()
@ -684,7 +656,7 @@ jobs:
steps: steps:
- name: Check if any job failed - name: Check if any job failed
if: | if: |
github.event_name != 'pull_request_target' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure') && contains(needs.*.result, 'failure')
run: exit 1 run: exit 1

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

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

View File

@ -5,34 +5,13 @@ on:
types: [labeled] types: [labeled]
jobs: jobs:
trigger-ee-updates: setup-ephemeral-environment:
name: Trigger Ephemeral Environment updates name: Setup Ephemeral Environment
runs-on: ubuntu-24.04
if: github.event.label.name == 'ephemeral-environment' if: github.event.label.name == 'ephemeral-environment'
steps: uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} project: server
pull_request_number: ${{ github.event.number }}
- name: Retrieve GitHub PAT secrets sync_environment: true
id: retrieve-secret-pat create_branch: true
uses: bitwarden/gh-actions/get-keyvault-secrets@main secrets: inherit
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger Ephemeral Environment update
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'devops',
workflow_id: '_update_ephemeral_tags.yml',
ref: 'main',
inputs: {
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
}
})

View File

@ -49,6 +49,8 @@ jobs:
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with: with:
sarif_file: cx_result.sarif 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: quality:
name: Quality scan name: Quality scan

View File

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

View File

@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -319,6 +321,10 @@ Global
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -370,6 +376,7 @@ Global
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.Enums;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Business; using Bit.Core.Exceptions;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
namespace Bit.Scim.Models; namespace Bit.Scim.Models;
@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel
{ {
public ScimUserRequestModel() public ScimUserRequestModel()
: base(false) : base(false)
{ } {
}
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider) public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
{ {
@ -25,6 +29,31 @@ public class ScimUserRequestModel : BaseScimUserModel
}; };
} }
public InviteOrganizationUsersRequest ToRequest(
ScimProviderType scimProvider,
InviteOrganization inviteOrganization,
DateTimeOffset performedAt)
{
var email = EmailForInvite(scimProvider);
if (string.IsNullOrWhiteSpace(email) || !Active)
{
throw new BadRequestException();
}
return new InviteOrganizationUsersRequest(
invites:
[
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
email: email,
externalId: ExternalIdForInvite()
)
],
inviteOrganization: inviteOrganization,
performedBy: Guid.Empty, // SCIM does not have a user id
performedAt: performedAt);
}
private string EmailForInvite(ScimProviderType scimProvider) private string EmailForInvite(ScimProviderType scimProvider)
{ {
var email = PrimaryEmail?.ToLowerInvariant(); var email = PrimaryEmail?.ToLowerInvariant();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@ public class GroupsControllerPatchTests : IClassFixture<ScimApplicationFactory>,
{ {
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = _factory.GetDatabaseContext();
_factory.ReinitializeDbForTests(databaseContext); _factory.ReinitializeDbForTests(databaseContext);
return Task.CompletedTask; 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 System.Text.Json;
using Bit.Core;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Scim.IntegrationTest.Factories; using Bit.Scim.IntegrationTest.Factories;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
using Bit.Test.Common.Helpers; using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit; using Xunit;
namespace Bit.Scim.IntegrationTest.Controllers.v2; namespace Bit.Scim.IntegrationTest.Controllers.v2;
@ -276,9 +279,18 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
} }
[Fact] [Theory]
public async Task Post_Success() [InlineData(true)]
[InlineData(false)]
public async Task Post_Success(bool isScimInviteUserOptimizationEnabled)
{ {
var localFactory = new ScimApplicationFactory();
localFactory.SubstituteService((IFeatureService featureService)
=> featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
.Returns(isScimInviteUserOptimizationEnabled));
localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
var email = "user5@example.com"; var email = "user5@example.com";
var displayName = "Test User 5"; var displayName = "Test User 5";
var externalId = "UE"; var externalId = "UE";
@ -306,7 +318,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
}; };
var context = await _factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel); var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
@ -316,7 +328,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id"); AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = localFactory.GetDatabaseContext();
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count()); Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
} }

View File

@ -1,15 +1,18 @@
using System.Text.Json; using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups; using Bit.Scim.Groups;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -20,19 +23,16 @@ public class PatchGroupCommandTests
{ {
[Theory] [Theory]
[BitAutoData] [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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() var scimPatchModel = new ScimPatchModel
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Path = "members", Path = "members",
@ -42,26 +42,31 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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] [Theory]
[BitAutoData] [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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(group.Id) .GetByIdAsync(organization.Id)
.Returns(group); .Returns(organization);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Path = "displayname", Path = "displayname",
@ -71,27 +76,55 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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); await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name); 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] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName) public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(group.Id) .GetByIdAsync(organization.Id)
.Returns(group); .Returns(organization);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
@ -100,12 +133,39 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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); await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name); 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] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId) 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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id, true)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "add", Op = "add",
Path = $"members[value eq \"{userId}\"]", Path = $"members[value eq \"{userId}\"]",
@ -133,9 +189,47 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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] [Theory]
@ -145,18 +239,14 @@ public class PatchGroupCommandTests
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id, true)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "add", Op = "add",
Path = $"members", Path = $"members",
@ -166,9 +256,101 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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] [Theory]
@ -177,10 +359,6 @@ public class PatchGroupCommandTests
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new Models.ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
@ -194,21 +372,19 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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); await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
} }
[Theory] [Theory]
[BitAutoData] [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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
@ -217,30 +393,58 @@ public class PatchGroupCommandTests
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "remove", Op = "remove",
Path = $"members", 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 } 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] [Theory]
[BitAutoData] [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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() var scimPatchModel = new Models.ScimPatchModel
.GetByIdAsync(group.Id) {
.Returns(group); 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 var scimPatchModel = new Models.ScimPatchModel
{ {
@ -248,45 +452,11 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default); await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default); await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(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 System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -43,7 +44,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
} }
[Theory] [Theory]
@ -71,7 +72,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
} }
[Theory] [Theory]
@ -147,7 +148,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM); await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM); await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
} }

View File

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

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

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

View File

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

View File

@ -184,7 +184,7 @@ public class UsersController : Controller
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId) private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
{ {
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
? await _userService.IsManagedByAnyOrganizationAsync(userId) ? await _userService.IsClaimedByAnyOrganizationAsync(userId)
: null; : null;
} }
} }

View File

@ -8,6 +8,9 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
@ -53,10 +56,13 @@ public class OrganizationUsersController : Controller
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public OrganizationUsersController( public OrganizationUsersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -77,10 +83,13 @@ public class OrganizationUsersController : Controller
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService, IFeatureService featureService,
IPricingClient pricingClient) IPricingClient pricingClient,
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -100,10 +109,13 @@ public class OrganizationUsersController : Controller
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService; _featureService = featureService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -115,11 +127,11 @@ public class OrganizationUsersController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
var managedByOrganization = await GetManagedByOrganizationStatusAsync( var claimedByOrganizationStatus = await GetClaimedByOrganizationStatusAsync(
organizationUser.OrganizationId, organizationUser.OrganizationId,
[organizationUser.Id]); [organizationUser.Id]);
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections); var response = new OrganizationUserDetailsResponseModel(organizationUser, claimedByOrganizationStatus[organizationUser.Id], collections);
if (includeGroups) if (includeGroups)
{ {
@ -163,13 +175,13 @@ public class OrganizationUsersController : Controller
} }
); );
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id)); var organizationUsersClaimedStatus = await GetClaimedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
var responses = organizationUsers var responses = organizationUsers
.Select(o => .Select(o =>
{ {
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
var managedByOrganization = organizationUsersManagementStatus[o.Id]; var claimedByOrganization = organizationUsersClaimedStatus[o.Id];
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization); var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization);
return orgUser; return orgUser;
}); });
@ -303,7 +315,7 @@ public class OrganizationUsersController : Controller
await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName); await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName);
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); 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")] [HttpPost("{organizationUserId}/accept")]
@ -315,11 +327,13 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); 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)) 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); await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
@ -357,7 +371,7 @@ public class OrganizationUsersController : Controller
} }
var userId = _userService.GetProperUserId(User); 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")] [HttpPost("confirm")]
@ -371,7 +385,7 @@ public class OrganizationUsersController : Controller
} }
var userId = _userService.GetProperUserId(User); 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 => return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
@ -577,7 +591,7 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
} }
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
@ -596,7 +610,7 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r => return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
@ -620,14 +634,14 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/restore")] [HttpPut("{id}/restore")]
public async Task RestoreAsync(Guid orgId, Guid id) public async Task RestoreAsync(Guid orgId, Guid id)
{ {
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _organizationService.RestoreUserAsync(orgUser, userId)); await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId));
} }
[HttpPatch("restore")] [HttpPatch("restore")]
[HttpPut("restore")] [HttpPut("restore")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _organizationService.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
} }
[HttpPatch("enable-secrets-manager")] [HttpPatch("enable-secrets-manager")]
@ -703,14 +717,14 @@ public class OrganizationUsersController : Controller
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
} }
private async Task<IDictionary<Guid, bool>> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds) private async Task<IDictionary<Guid, bool>> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{ {
return userIds.ToDictionary(kvp => kvp, kvp => false); return userIds.ToDictionary(kvp => kvp, kvp => false);
} }
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds); var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds);
return usersOrganizationManagementStatus; return usersOrganizationClaimedStatus;
} }
} }

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;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.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.AdminConsole.Repositories;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
@ -61,7 +63,9 @@ public class OrganizationsController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand;
private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
public OrganizationsController( public OrganizationsController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -84,7 +88,9 @@ public class OrganizationsController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
IOrganizationDeleteCommand organizationDeleteCommand, IOrganizationDeleteCommand organizationDeleteCommand,
IPricingClient pricingClient) IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient,
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -106,7 +112,9 @@ public class OrganizationsController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand; _cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand;
_organizationDeleteCommand = organizationDeleteCommand; _organizationDeleteCommand = organizationDeleteCommand;
_policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -135,10 +143,10 @@ public class OrganizationsController : Controller
var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId, var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId,
OrganizationUserStatusType.Confirmed); OrganizationUserStatusType.Confirmed);
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId); var organizationsClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); var organizationIdsClaimingActiveUser = organizationsClaimingActiveUser.Select(o => o.Id);
var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingActiveUser));
return new ListResponseModel<ProfileOrganizationResponseModel>(responses); return new ListResponseModel<ProfileOrganizationResponseModel>(responses);
} }
@ -163,8 +171,13 @@ public class OrganizationsController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
var resetPasswordPolicy = if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); {
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) if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
{ {
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
@ -172,6 +185,7 @@ public class OrganizationsController : Controller
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
} }
[HttpPost("")] [HttpPost("")]
@ -266,9 +280,9 @@ public class OrganizationsController : Controller
} }
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id)) && (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id))
{ {
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details."); throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.");
} }
await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id); await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id);
@ -479,7 +493,7 @@ public class OrganizationsController : Controller
} }
[HttpPost("{id}/keys")] [HttpPost("{id}/keys")]
public async Task<OrganizationKeysResponseModel> PostKeys(string id, [FromBody] OrganizationKeysRequestModel model) public async Task<OrganizationKeysResponseModel> PostKeys(Guid id, [FromBody] OrganizationKeysRequestModel model)
{ {
var user = await _userService.GetUserByPrincipalAsync(User); var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null) if (user == null)
@ -487,7 +501,7 @@ public class OrganizationsController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
var org = await _organizationService.UpdateOrganizationKeysAsync(new Guid(id), model.PublicKey, var org = await _organizationUpdateKeysCommand.UpdateOrganizationKeysAsync(id, model.PublicKey,
model.EncryptedPrivateKey); model.EncryptedPrivateKey);
return new OrganizationKeysResponseModel(org); return new OrganizationKeysResponseModel(org);
} }

View File

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

View File

@ -66,24 +66,34 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
{ {
public OrganizationUserDetailsResponseModel( public OrganizationUserDetailsResponseModel(
OrganizationUser organizationUser, OrganizationUser organizationUser,
bool managedByOrganization, bool claimedByOrganization,
string ssoExternalId,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization; ClaimedByOrganization = claimedByOrganization;
SsoExternalId = ssoExternalId;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool managedByOrganization, bool claimedByOrganization,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization; ClaimedByOrganization = claimedByOrganization;
SsoExternalId = organizationUser.SsoExternalId;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public bool ManagedByOrganization { get; set; } [Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
public bool ManagedByOrganization
{
get => ClaimedByOrganization;
set => ClaimedByOrganization = value;
}
public bool ClaimedByOrganization { get; set; }
public string SsoExternalId { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
@ -117,7 +127,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails") bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails")
: base(organizationUser, obj) : base(organizationUser, obj)
{ {
if (organizationUser == null) if (organizationUser == null)
@ -134,7 +144,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
Groups = organizationUser.Groups; Groups = organizationUser.Groups;
// Prevent reset password when using key connector. // Prevent reset password when using key connector.
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
ManagedByOrganization = managedByOrganization; ClaimedByOrganization = claimedByOrganization;
} }
public string Name { get; set; } public string Name { get; set; }
@ -142,11 +152,17 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
public string AvatarColor { get; set; } public string AvatarColor { get; set; }
public bool TwoFactorEnabled { get; set; } public bool TwoFactorEnabled { get; set; }
public bool SsoBound { get; set; } public bool SsoBound { get; set; }
[Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
public bool ManagedByOrganization
{
get => ClaimedByOrganization;
set => ClaimedByOrganization = value;
}
/// <summary> /// <summary>
/// Indicates if the organization manages the user. If a user is "managed" by an organization, /// Indicates if the organization claimed the user. If a user is "claimed" by an organization,
/// the organization has greater control over their account, and some user actions are restricted. /// the organization has greater control over their account, and some user actions are restricted.
/// </summary> /// </summary>
public bool ManagedByOrganization { get; set; } public bool ClaimedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
public IEnumerable<Guid> Groups { get; set; } public IEnumerable<Guid> Groups { get; set; }
} }

View File

@ -18,7 +18,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
public ProfileOrganizationResponseModel( public ProfileOrganizationResponseModel(
OrganizationUserOrganizationDetails organization, OrganizationUserOrganizationDetails organization,
IEnumerable<Guid> organizationIdsManagingUser) IEnumerable<Guid> organizationIdsClaimingUser)
: this("profileOrganization") : this("profileOrganization")
{ {
Id = organization.OrganizationId; Id = organization.OrganizationId;
@ -51,7 +51,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId); SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
Identifier = organization.Identifier; Identifier = organization.Identifier;
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions); Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
ResetPasswordEnrolled = organization.ResetPasswordKey != null; ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey);
UserId = organization.UserId; UserId = organization.UserId;
OrganizationUserId = organization.OrganizationUserId; OrganizationUserId = organization.OrganizationUserId;
ProviderId = organization.ProviderId; ProviderId = organization.ProviderId;
@ -70,7 +70,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
LimitCollectionDeletion = organization.LimitCollectionDeletion; LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion; LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId); UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
if (organization.SsoConfig != null) if (organization.SsoConfig != null)
@ -133,15 +133,26 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool LimitItemDeletion { get; set; } public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <summary> /// <summary>
/// Indicates if the organization manages the user. /// Obsolete.
///
/// See <see cref="UserIsClaimedByOrganization"/>
/// </summary>
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
public bool UserIsManagedByOrganization
{
get => UserIsClaimedByOrganization;
set => UserIsClaimedByOrganization = value;
}
/// <summary>
/// Indicates if the organization claims the user.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// An organization manages a user if the user's email domain is verified by the organization and the user is a member of it. /// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it.
/// The organization must be enabled and able to have verified domains. /// The organization must be enabled and able to have verified domains.
/// </remarks> /// </remarks>
/// <returns> /// <returns>
/// False if the Account Deprovisioning feature flag is disabled. /// False if the Account Deprovisioning feature flag is disabled.
/// </returns> /// </returns>
public bool UserIsManagedByOrganization { get; set; } public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
} }

View File

@ -30,7 +30,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
Email = user.Email; Email = user.Email;
Status = user.Status; Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null; ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
} }
[SetsRequiredMembers] [SetsRequiredMembers]
@ -49,7 +49,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
TwoFactorEnabled = twoFactorEnabled; TwoFactorEnabled = twoFactorEnabled;
Status = user.Status; Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null; ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
SsoExternalId = user.SsoExternalId; SsoExternalId = user.SsoExternalId;
} }

View File

@ -124,11 +124,11 @@ public class AccountsController : Controller
throw new BadRequestException("MasterPasswordHash", "Invalid password."); throw new BadRequestException("MasterPasswordHash", "Invalid password.");
} }
var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail); var claimedUserValidationResult = await _userService.ValidateClaimedUserDomainAsync(user, model.NewEmail);
if (!managedUserValidationResult.Succeeded) if (!claimedUserValidationResult.Succeeded)
{ {
throw new BadRequestException(managedUserValidationResult.Errors); throw new BadRequestException(claimedUserValidationResult.Errors);
} }
await _userService.InitiateEmailChangeAsync(user, model.NewEmail); await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
@ -355,6 +355,7 @@ public class AccountsController : Controller
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }
[Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")]
[HttpPost("key")] [HttpPost("key")]
public async Task PostKey([FromBody] UpdateKeyRequestModel model) public async Task PostKey([FromBody] UpdateKeyRequestModel model)
{ {
@ -436,11 +437,11 @@ public class AccountsController : Controller
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, twoFactorEnabled, providerUserOrganizationDetails, twoFactorEnabled,
hasPremiumFromOrg, organizationIdsManagingActiveUser); hasPremiumFromOrg, organizationIdsClaimingActiveUser);
return response; return response;
} }
@ -450,9 +451,9 @@ public class AccountsController : Controller
var userId = _userService.GetProperUserId(User); var userId = _userService.GetProperUserId(User);
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value, var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
OrganizationUserStatusType.Confirmed); OrganizationUserStatusType.Confirmed);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value); var organizationIdsClaimingUser = await GetOrganizationIdsClaimingUserAsync(userId.Value);
var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
return new ListResponseModel<ProfileOrganizationResponseModel>(responseData); return new ListResponseModel<ProfileOrganizationResponseModel>(responseData);
} }
@ -470,9 +471,9 @@ public class AccountsController : Controller
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser); var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser);
return response; return response;
} }
@ -489,9 +490,9 @@ public class AccountsController : Controller
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser); var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
return response; return response;
} }
@ -559,9 +560,9 @@ public class AccountsController : Controller
} }
else else
{ {
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id)) && await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
{ {
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details."); throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
} }
@ -762,9 +763,9 @@ public class AccountsController : Controller
await _userService.SaveUserAsync(user); await _userService.SaveUserAsync(user);
} }
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId) private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{ {
var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId); var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
return organizationManagingUser.Select(o => o.Id); return organizationsClaimingUser.Select(o => o.Id);
} }
} }

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

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
#nullable enable
namespace Bit.Api.Auth.Models.Request;
public class UntrustDevicesRequestModel
{
[Required]
public IEnumerable<Guid> Devices { get; set; } = null!;
}

View File

@ -58,10 +58,10 @@ public class AccountsController(
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
userHasPremiumFromOrganization, organizationIdsManagingActiveUser); userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
return new PaymentResponseModel return new PaymentResponseModel
{ {
UserProfile = profile, UserProfile = profile,
@ -229,9 +229,9 @@ public class AccountsController(
await paymentService.SaveTaxInfoAsync(user, taxInfo); await paymentService.SaveTaxInfoAsync(user, taxInfo);
} }
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId) private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{ {
var organizationManagingUser = await userService.GetOrganizationsManagingUserAsync(userId); var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
return organizationManagingUser.Select(o => o.Id); return organizationsClaimingUser.Select(o => o.Id);
} }
} }

View File

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

View File

@ -409,9 +409,9 @@ public class OrganizationsController(
organizationId, organizationId,
OrganizationUserStatusType.Confirmed); OrganizationUserStatusType.Confirmed);
var organizationIdsManagingActiveUser = (await userService.GetOrganizationsManagingUserAsync(userId)) var organizationIdsClaimingActiveUser = (await userService.GetOrganizationsClaimingUserAsync(userId))
.Select(o => o.Id); .Select(o => o.Id);
return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsManagingActiveUser); return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsClaimingActiveUser);
} }
} }

View File

@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request; using Bit.Api.Models.Request;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -22,6 +22,7 @@ public class DevicesController : Controller
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IDeviceService _deviceService; private readonly IDeviceService _deviceService;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IUntrustDevicesCommand _untrustDevicesCommand;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ILogger<DevicesController> _logger; private readonly ILogger<DevicesController> _logger;
@ -30,6 +31,7 @@ public class DevicesController : Controller
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
IDeviceService deviceService, IDeviceService deviceService,
IUserService userService, IUserService userService,
IUntrustDevicesCommand untrustDevicesCommand,
IUserRepository userRepository, IUserRepository userRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
ILogger<DevicesController> logger) ILogger<DevicesController> logger)
@ -37,6 +39,7 @@ public class DevicesController : Controller
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_deviceService = deviceService; _deviceService = deviceService;
_userService = userService; _userService = userService;
_untrustDevicesCommand = untrustDevicesCommand;
_userRepository = userRepository; _userRepository = userRepository;
_currentContext = currentContext; _currentContext = currentContext;
_logger = logger; _logger = logger;
@ -125,7 +128,7 @@ public class DevicesController : Controller
} }
[HttpPost("{identifier}/retrieve-keys")] [HttpPost("{identifier}/retrieve-keys")]
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier, [FromBody] SecretVerificationRequestModel model) public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
{ {
var user = await _userService.GetUserByPrincipalAsync(User); var user = await _userService.GetUserByPrincipalAsync(User);
@ -134,14 +137,7 @@ public class DevicesController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!await _userService.VerifySecretAsync(user, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id); var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
if (device == null) if (device == null)
{ {
throw new NotFoundException(); throw new NotFoundException();
@ -173,6 +169,19 @@ public class DevicesController : Controller
model.OtherDevices ?? Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>()); model.OtherDevices ?? Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>());
} }
[HttpPost("untrust")]
public async Task PostUntrust([FromBody] UntrustDevicesRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
await _untrustDevicesCommand.UntrustDevices(user, model.Devices);
}
[HttpPut("identifier/{identifier}/token")] [HttpPut("identifier/{identifier}/token")]
[HttpPost("identifier/{identifier}/token")] [HttpPost("identifier/{identifier}/token")]
public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model) public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model)

View File

@ -1,10 +1,24 @@
#nullable enable #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.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;
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.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -19,18 +33,48 @@ public class AccountsKeyManagementController : Controller
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand; private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand;
private readonly IUserService _userService; 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, public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService, IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IEmergencyAccessRepository emergencyAccessRepository, 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; _userService = userService;
_featureService = featureService; _featureService = featureService;
_regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand; _regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_emergencyAccessRepository = emergencyAccessRepository; _emergencyAccessRepository = emergencyAccessRepository;
_rotateUserAccountKeysCommand = rotateUserKeyCommandV2;
_cipherValidator = cipherValidator;
_folderValidator = folderValidator;
_sendValidator = sendValidator;
_emergencyAccessValidator = emergencyAccessValidator;
_organizationUserValidator = organizationUserValidator;
_webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
} }
[HttpPost("regenerate-keys")] [HttpPost("regenerate-keys")]
@ -47,4 +91,46 @@ public class AccountsKeyManagementController : Controller
await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id), await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id),
usersOrganizationAccounts, designatedEmergencyAccess); 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) public async Task<IEnumerable<WebAuthnLoginRotateKeyData>> ValidateAsync(User user, IEnumerable<WebAuthnLoginRotateKeyRequestModel> keysToRotate)
{ {
// 2024-06: Remove after 3 releases, for backward compatibility
if (keysToRotate == null)
{
return new List<WebAuthnLoginRotateKeyData>();
}
var result = new List<WebAuthnLoginRotateKeyData>(); var result = new List<WebAuthnLoginRotateKeyData>();
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
if (existing == null || !existing.Any()) if (existing == null)
{ {
return result; return result;
} }
foreach (var ea in existing) var validCredentials = existing.Where(credential => credential.SupportsPrf);
if (!validCredentials.Any())
{
return result;
}
foreach (var ea in validCredentials)
{ {
var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id); var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id);
if (keyToRotate == null) if (keyToRotate == null)

View File

@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails, IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
bool twoFactorEnabled, bool twoFactorEnabled,
bool premiumFromOrganization, bool premiumFromOrganization,
IEnumerable<Guid> organizationIdsManagingUser) : base("profile") IEnumerable<Guid> organizationIdsClaimingUser) : base("profile")
{ {
if (user == null) if (user == null)
{ {
@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel
AvatarColor = user.AvatarColor; AvatarColor = user.AvatarColor;
CreationDate = user.CreationDate; CreationDate = user.CreationDate;
VerifyDevices = user.VerifyDevices; VerifyDevices = user.VerifyDevices;
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser)); Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
ProviderOrganizations = ProviderOrganizations =
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po)); providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));

View File

@ -22,6 +22,7 @@ public class NotificationResponseModel : ResponseModel
Title = notificationStatusDetails.Title; Title = notificationStatusDetails.Title;
Body = notificationStatusDetails.Body; Body = notificationStatusDetails.Body;
Date = notificationStatusDetails.RevisionDate; Date = notificationStatusDetails.RevisionDate;
TaskId = notificationStatusDetails.TaskId;
ReadDate = notificationStatusDetails.ReadDate; ReadDate = notificationStatusDetails.ReadDate;
DeletedDate = notificationStatusDetails.DeletedDate; DeletedDate = notificationStatusDetails.DeletedDate;
} }
@ -40,6 +41,8 @@ public class NotificationResponseModel : ResponseModel
public DateTime Date { get; set; } public DateTime Date { get; set; }
public Guid? TaskId { get; set; }
public DateTime? ReadDate { get; set; } public DateTime? ReadDate { get; set; }
public DateTime? DeletedDate { get; set; } public DateTime? DeletedDate { get; set; }

View File

@ -31,7 +31,7 @@ using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures; using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request;
#if !OSS #if !OSS
using Bit.Commercial.Core.SecretsManager; using Bit.Commercial.Core.SecretsManager;
@ -168,6 +168,9 @@ public class Startup
services services
.AddScoped<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>, .AddScoped<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>,
WebAuthnLoginKeyRotationValidator>(); WebAuthnLoginKeyRotationValidator>();
services
.AddScoped<IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>,
DeviceRotationValidator>();
// Services // Services
services.AddBaseServices(globalSettings); services.AddBaseServices(globalSettings);

View File

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

View File

@ -5,6 +5,7 @@ using Bit.Core;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Commands.Interfaces;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums; using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Queries; using Bit.Core.Vault.Queries;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -89,11 +90,28 @@ public class SecurityTaskController : Controller
public async Task<ListResponseModel<SecurityTasksResponseModel>> BulkCreateTasks(Guid orgId, public async Task<ListResponseModel<SecurityTasksResponseModel>> BulkCreateTasks(Guid orgId,
[FromBody] BulkCreateSecurityTasksRequestModel model) [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); return new ListResponseModel<SecurityTasksResponseModel>(response);
} }
} }

View File

@ -104,13 +104,13 @@ public class SyncController : Controller
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id); var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id);
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
return response; return response;
} }

View File

@ -23,7 +23,7 @@ public class SyncResponseModel : ResponseModel
bool userTwoFactorEnabled, bool userTwoFactorEnabled,
bool userHasPremiumFromOrganization, bool userHasPremiumFromOrganization,
IDictionary<Guid, OrganizationAbility> organizationAbilities, IDictionary<Guid, OrganizationAbility> organizationAbilities,
IEnumerable<Guid> organizationIdsManagingUser, IEnumerable<Guid> organizationIdsClaimingingUser,
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails, IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
IEnumerable<ProviderUserProviderDetails> providerUserDetails, IEnumerable<ProviderUserProviderDetails> providerUserDetails,
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails, IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
@ -37,7 +37,7 @@ public class SyncResponseModel : ResponseModel
: base("sync") : base("sync")
{ {
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser); providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser);
Folders = folders.Select(f => new FolderResponseModel(f)); Folders = folders.Select(f => new FolderResponseModel(f));
Ciphers = ciphers.Select(cipher => Ciphers = ciphers.Select(cipher =>
new CipherDetailsResponseModel( new CipherDetailsResponseModel(

View File

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

View File

@ -1,6 +1,5 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Billing.Jobs; using Bit.Billing.Jobs;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@ -24,7 +23,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly IPushNotificationService _pushNotificationService; private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly ISchedulerFactory _schedulerFactory; private readonly ISchedulerFactory _schedulerFactory;
private readonly IFeatureService _featureService;
private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationEnableCommand _organizationEnableCommand;
private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
@ -39,7 +37,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
IPushNotificationService pushNotificationService, IPushNotificationService pushNotificationService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ISchedulerFactory schedulerFactory, ISchedulerFactory schedulerFactory,
IFeatureService featureService,
IOrganizationEnableCommand organizationEnableCommand, IOrganizationEnableCommand organizationEnableCommand,
IOrganizationDisableCommand organizationDisableCommand, IOrganizationDisableCommand organizationDisableCommand,
IPricingClient pricingClient) IPricingClient pricingClient)
@ -53,7 +50,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
_pushNotificationService = pushNotificationService; _pushNotificationService = pushNotificationService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_schedulerFactory = schedulerFactory; _schedulerFactory = schedulerFactory;
_featureService = featureService;
_organizationEnableCommand = organizationEnableCommand; _organizationEnableCommand = organizationEnableCommand;
_organizationDisableCommand = organizationDisableCommand; _organizationDisableCommand = organizationDisableCommand;
_pricingClient = pricingClient; _pricingClient = pricingClient;
@ -227,12 +223,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId) private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId)
{ {
var isResellerManagedOrgAlertEnabled = _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert);
if (!isResellerManagedOrgAlertEnabled)
{
return;
}
var scheduler = await _schedulerFactory.GetScheduler(); var scheduler = await _schedulerFactory.GetScheduler();
var job = JobBuilder.Create<SubscriptionCancellationJob>() var job = JobBuilder.Create<SubscriptionCancellationJob>()

View File

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

View File

@ -87,8 +87,7 @@ public class Startup
// TODO: no longer be required - see PM-1880 // TODO: no longer be required - see PM-1880
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>(); services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
// Mvc services.AddControllers(config =>
services.AddMvc(config =>
{ {
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute()); config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
}); });

View File

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

View File

@ -1,21 +0,0 @@
@model LoginModel
@{
ViewData["Title"] = "Login";
}
<div class="row justify-content-md-center">
<div class="col-4">
<p>Please enter your email address below to log in.</p>
<form asp-action="" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Email" class="sr-only">Email Address</label>
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
required autofocus>
<span asp-validation-for="Email" class="invalid-feedback"></span>
<small class="form-text text-body-secondary">We'll email you a secure login link.</small>
</div>
<button class="btn btn-primary btn-block" type="submit">Continue</button>
</form>
</div>
</div>

View File

@ -1,14 +0,0 @@
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
</p>

View File

@ -1,41 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] | Bitwarden Billing Portal</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
<link rel="stylesheet" href="~/styles/billing.css">
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
<div class="container">
<a class="navbar-brand" href="#"><i class="fa fa-lg fa-fw fa-shield"></i> Billing</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
</ul>
</div>
</div>
</nav>
<main role="main" class="container">
@RenderBody()
</main>
@RenderSection("Scripts", required: false)
</body>
</html>

View File

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

View File

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

View File

@ -1,6 +0,0 @@
.custom-select.input-validation-error ~ .invalid-feedback,
.custom-select.input-validation-error ~ .invalid-tooltip,
.form-control.input-validation-error ~ .invalid-feedback,
.form-control.input-validation-error ~ .invalid-tooltip {
display: block;
}

View File

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

View File

@ -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; namespace Bit.Core.AdminConsole.Errors;
public record Error<T>(string Message, T ErroredValue); public record Error<T>(string Message, T ErroredValue);
public static class ErrorMappers
{
public static Error<B> ToError<A, B>(this Error<A> errorA, B erroredValue) => new(errorA.Message, erroredValue);
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.AdminConsole.Errors;
public record InvalidResultTypeError<T>(T Value) : Error<T>(Code, Value)
{
public const string Code = "Invalid result type.";
};

View File

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

View File

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

View File

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

View File

@ -154,6 +154,6 @@ public class VerifyOrganizationDomainCommand(
var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId); var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId);
await mailService.SendClaimedDomainUserEmailAsync(new ManagedUserDomainClaimedEmails(domainUserEmails, organization)); await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization));
} }
} }

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

@ -15,11 +15,11 @@ using Bit.Core.Tools.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganizationUserAccountCommand public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand
{ {
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
@ -28,10 +28,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
private readonly IPushNotificationService _pushService; private readonly IPushNotificationService _pushService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderUserRepository _providerUserRepository;
public DeleteManagedOrganizationUserAccountCommand( public DeleteClaimedOrganizationUserAccountCommand(
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IUserRepository userRepository, IUserRepository userRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
@ -43,7 +43,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
{ {
_userService = userService; _userService = userService;
_eventService = eventService; _eventService = eventService;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_userRepository = userRepository; _userRepository = userRepository;
_currentContext = currentContext; _currentContext = currentContext;
@ -62,10 +62,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
throw new NotFoundException("Member not found."); throw new NotFoundException("Member not found.");
} }
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, new[] { organizationUserId }); var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, new[] { organizationUserId });
var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true); var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true);
await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, managementStatus, hasOtherConfirmedOwners); await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners);
var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value); var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value);
if (user == null) if (user == null)
@ -83,7 +83,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList(); var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList();
var users = await _userRepository.GetManyAsync(userIds); var users = await _userRepository.GetManyAsync(userIds);
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, orgUserIds); var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds);
var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true); var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true);
var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>(); var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>();
@ -97,7 +97,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
throw new NotFoundException("Member not found."); throw new NotFoundException("Member not found.");
} }
await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, managementStatus, hasOtherConfirmedOwners); await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners);
var user = users.FirstOrDefault(u => u.Id == orgUser.UserId); var user = users.FirstOrDefault(u => u.Id == orgUser.UserId);
if (user == null) if (user == null)
@ -129,7 +129,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
return results; return results;
} }
private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary<Guid, bool> managementStatus, bool hasOtherConfirmedOwners) private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary<Guid, bool> claimedStatus, bool hasOtherConfirmedOwners)
{ {
if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited) if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited)
{ {
@ -154,9 +154,14 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
} }
} }
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged) if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId))
{ {
throw new BadRequestException("Member is not managed by the organization."); throw new BadRequestException("Custom users can not delete admins.");
}
if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed)
{
throw new BadRequestException("Member is not claimed by the organization.");
} }
} }

View File

@ -4,12 +4,12 @@ using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersManagementStatusQuery public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaimedStatusQuery
{ {
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
public GetOrganizationUsersManagementStatusQuery( public GetOrganizationUsersClaimedStatusQuery(
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IOrganizationUserRepository organizationUserRepository) IOrganizationUserRepository organizationUserRepository)
{ {
@ -17,11 +17,11 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
} }
public async Task<IDictionary<Guid, bool>> GetUsersOrganizationManagementStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds) public async Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds)
{ {
if (organizationUserIds.Any()) if (organizationUserIds.Any())
{ {
// Users can only be managed by an Organization that is enabled and can have organization domains // Users can only be claimed by an Organization that is enabled and can have organization domains
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
// TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622).
@ -31,7 +31,7 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa
// Get all organization users with claimed domains by the organization // Get all organization users with claimed domains by the organization
var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId); var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
// Create a dictionary with the OrganizationUserId and a boolean indicating if the user is managed by the organization // Create a dictionary with the OrganizationUserId and a boolean indicating if the user is claimed by the organization
return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId)); return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId));
} }
} }

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

@ -2,7 +2,7 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IDeleteManagedOrganizationUserAccountCommand public interface IDeleteClaimedOrganizationUserAccountCommand
{ {
/// <summary> /// <summary>
/// Removes a user from an organization and deletes all of their associated user data. /// Removes a user from an organization and deletes all of their associated user data.

View File

@ -1,19 +1,19 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IGetOrganizationUsersManagementStatusQuery public interface IGetOrganizationUsersClaimedStatusQuery
{ {
/// <summary> /// <summary>
/// Checks whether each user in the provided list of organization user IDs is managed by the specified organization. /// Checks whether each user in the provided list of organization user IDs is claimed by the specified organization.
/// </summary> /// </summary>
/// <param name="organizationId">The unique identifier of the organization to check against.</param> /// <param name="organizationId">The unique identifier of the organization to check against.</param>
/// <param name="organizationUserIds">A list of OrganizationUserIds to be checked.</param> /// <param name="organizationUserIds">A list of OrganizationUserIds to be checked.</param>
/// <remarks> /// <remarks>
/// A managed user is a user whose email domain matches one of the Organization's verified domains. /// A claimed user is a user whose email domain matches one of the Organization's verified domains.
/// The organization must be enabled and be on an Enterprise plan. /// The organization must be enabled and be on an Enterprise plan.
/// </remarks> /// </remarks>
/// <returns> /// <returns>
/// A dictionary containing the OrganizationUserId and a boolean indicating if the user is managed by the organization. /// A dictionary containing the OrganizationUserId and a boolean indicating if the user is claimed by the organization.
/// </returns> /// </returns>
Task<IDictionary<Guid, bool>> GetUsersOrganizationManagementStatusAsync(Guid organizationId, Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds); IEnumerable<Guid> organizationUserIds);
} }

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

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