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:
commit
3fae927f77
10
.github/CODEOWNERS
vendored
10
.github/CODEOWNERS
vendored
@ -20,12 +20,19 @@
|
|||||||
# Database Operations for database changes
|
# Database Operations for database changes
|
||||||
src/Sql/** @bitwarden/dept-dbops
|
src/Sql/** @bitwarden/dept-dbops
|
||||||
util/EfShared/** @bitwarden/dept-dbops
|
util/EfShared/** @bitwarden/dept-dbops
|
||||||
util/Migrator/** @bitwarden/dept-dbops
|
util/Migrator/** @bitwarden/team-platform-dev # The Platform team owns the Migrator project code
|
||||||
|
util/Migrator/DbScripts/** @bitwarden/dept-dbops
|
||||||
|
util/Migrator/DbScripts_finalization/** @bitwarden/dept-dbops
|
||||||
|
util/Migrator/DbScripts_transition/** @bitwarden/dept-dbops
|
||||||
|
util/Migrator/MySql/** @bitwarden/dept-dbops
|
||||||
util/MySqlMigrations/** @bitwarden/dept-dbops
|
util/MySqlMigrations/** @bitwarden/dept-dbops
|
||||||
util/PostgresMigrations/** @bitwarden/dept-dbops
|
util/PostgresMigrations/** @bitwarden/dept-dbops
|
||||||
util/SqlServerEFScaffold/** @bitwarden/dept-dbops
|
util/SqlServerEFScaffold/** @bitwarden/dept-dbops
|
||||||
util/SqliteMigrations/** @bitwarden/dept-dbops
|
util/SqliteMigrations/** @bitwarden/dept-dbops
|
||||||
|
|
||||||
|
# Shared util projects
|
||||||
|
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
|
||||||
|
|
||||||
# Auth team
|
# Auth team
|
||||||
**/Auth @bitwarden/team-auth-dev
|
**/Auth @bitwarden/team-auth-dev
|
||||||
bitwarden_license/src/Sso @bitwarden/team-auth-dev
|
bitwarden_license/src/Sso @bitwarden/team-auth-dev
|
||||||
@ -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
|
||||||
|
96
.github/workflows/build.yml
vendored
96
.github/workflows/build.yml
vendored
@ -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
21
.github/workflows/build_target.yml
vendored
Normal 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
|
39
.github/workflows/ephemeral-environment.yml
vendored
39
.github/workflows/ephemeral-environment.yml
vendored
@ -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
|
with:
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
project: server
|
||||||
with:
|
pull_request_number: ${{ github.event.number }}
|
||||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
sync_environment: true
|
||||||
|
create_branch: true
|
||||||
- name: Retrieve GitHub PAT secrets
|
secrets: inherit
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
2
.github/workflows/scan.yml
vendored
2
.github/workflows/scan.yml
vendored
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -0,0 +1,133 @@
|
|||||||
|
// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
|
||||||
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.Billing;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
|
public static class ProviderPriceAdapter
|
||||||
|
{
|
||||||
|
public static class MSP
|
||||||
|
{
|
||||||
|
public static class Active
|
||||||
|
{
|
||||||
|
public const string Enterprise = "provider-portal-enterprise-monthly-2025";
|
||||||
|
public const string Teams = "provider-portal-teams-monthly-2025";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Legacy
|
||||||
|
{
|
||||||
|
public const string Enterprise = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||||
|
public const string Teams = "password-manager-provider-portal-teams-monthly-2024";
|
||||||
|
public static readonly List<string> List = [Enterprise, Teams];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class BusinessUnit
|
||||||
|
{
|
||||||
|
public static class Active
|
||||||
|
{
|
||||||
|
public const string Annually = "business-unit-portal-enterprise-annually-2025";
|
||||||
|
public const string Monthly = "business-unit-portal-enterprise-monthly-2025";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Legacy
|
||||||
|
{
|
||||||
|
public const string Annually = "password-manager-provider-portal-enterprise-annually-2024";
|
||||||
|
public const string Monthly = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||||
|
public static readonly List<string> List = [Annually, Monthly];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> and <paramref name="subscription"/> to determine
|
||||||
|
/// whether the <paramref name="provider"/> is on active or legacy pricing and then returns a Stripe price ID for the provided
|
||||||
|
/// <paramref name="planType"/> based on that determination.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||||
|
/// <param name="subscription">The provider's subscription.</param>
|
||||||
|
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||||
|
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||||
|
public static string GetPriceId(
|
||||||
|
Provider provider,
|
||||||
|
Subscription subscription,
|
||||||
|
PlanType planType)
|
||||||
|
{
|
||||||
|
var priceIds = subscription.Items.Select(item => item.Price.Id);
|
||||||
|
|
||||||
|
var invalidPlanType =
|
||||||
|
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||||
|
|
||||||
|
return provider.Type switch
|
||||||
|
{
|
||||||
|
ProviderType.Msp => MSP.Legacy.List.Intersect(priceIds).Any()
|
||||||
|
? planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Legacy.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Legacy.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
}
|
||||||
|
: planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
|
||||||
|
? planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Legacy.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
}
|
||||||
|
: planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
_ => throw new BillingException(
|
||||||
|
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> to return the active Stripe price ID for the provided
|
||||||
|
/// <paramref name="planType"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||||
|
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||||
|
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||||
|
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||||
|
public static string GetActivePriceId(
|
||||||
|
Provider provider,
|
||||||
|
PlanType planType)
|
||||||
|
{
|
||||||
|
var invalidPlanType =
|
||||||
|
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||||
|
|
||||||
|
return provider.Type switch
|
||||||
|
{
|
||||||
|
ProviderType.Msp => planType switch
|
||||||
|
{
|
||||||
|
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||||
|
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
ProviderType.MultiOrganizationEnterprise => planType switch
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||||
|
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||||
|
_ => throw invalidPlanType
|
||||||
|
},
|
||||||
|
_ => throw new BillingException(
|
||||||
|
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,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}")]
|
||||||
@ -103,21 +95,13 @@ 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);
|
||||||
|
if (group == null || group.OrganizationId != organizationId)
|
||||||
{
|
{
|
||||||
var group = await _groupRepository.GetByIdAsync(id);
|
throw new NotFoundException("Group not found.");
|
||||||
if (group == null || group.OrganizationId != organizationId)
|
|
||||||
{
|
|
||||||
throw new NotFoundException("Group not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await _patchGroupCommandvNext.PatchGroupAsync(group, model);
|
|
||||||
return new NoContentResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
await _patchGroupCommand.PatchGroupAsync(group, model);
|
||||||
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
|
|
||||||
|
|
||||||
return new NoContentResult();
|
return new NoContentResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
@ -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,118 +18,137 @@ 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")
|
}
|
||||||
{
|
}
|
||||||
// Replace a list of members
|
|
||||||
if (operation.Path?.ToLowerInvariant() == "members")
|
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);
|
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
|
|
||||||
else if (operation.Path?.ToLowerInvariant() == "displayname")
|
// Replace group name from path
|
||||||
|
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
|
||||||
{
|
{
|
||||||
group.Name = operation.Value.GetString();
|
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);
|
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||||
operationHandled = true;
|
break;
|
||||||
}
|
}
|
||||||
// Replace group name from value object
|
|
||||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
// Replace group name from value object
|
||||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
|
case PatchOps.Replace when
|
||||||
|
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);
|
{
|
||||||
if (addId.HasValue)
|
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();
|
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||||
orgUserIds.Add(addId.Value);
|
foreach (var v in GetOperationValueIds(operation.Value))
|
||||||
|
{
|
||||||
|
orgUserIds.Remove(v);
|
||||||
|
}
|
||||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||||
operationHandled = true;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Add a list of members
|
|
||||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
|
||||||
operation.Path?.ToLowerInvariant() == "members")
|
|
||||||
{
|
|
||||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
|
||||||
foreach (var v in GetOperationValueIds(operation.Value))
|
|
||||||
{
|
|
||||||
orgUserIds.Add(v);
|
|
||||||
}
|
|
||||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
|
||||||
operationHandled = true;
|
|
||||||
}
|
|
||||||
// Remove a single member
|
|
||||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
|
||||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
|
||||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
|
||||||
{
|
|
||||||
var removeId = GetOperationPathId(operation.Path);
|
|
||||||
if (removeId.HasValue)
|
|
||||||
{
|
|
||||||
await _groupService.DeleteUserAsync(group, removeId.Value, EventSystemUser.SCIM);
|
|
||||||
operationHandled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove a list of members
|
|
||||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
|
||||||
operation.Path?.ToLowerInvariant() == "members")
|
|
||||||
{
|
|
||||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
|
||||||
foreach (var v in GetOperationValueIds(operation.Value))
|
|
||||||
{
|
|
||||||
orgUserIds.Remove(v);
|
|
||||||
}
|
|
||||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
|
||||||
operationHandled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!operationHandled)
|
default:
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Group patch operation not handled: {0} : ",
|
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
|
||||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Guid> GetOperationValueIds(JsonElement objArray)
|
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
|
||||||
{
|
{
|
||||||
var ids = new List<Guid>();
|
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
|
||||||
|
// is removed. To avoid excessive load on the database, we check against the high availability replica and
|
||||||
|
// return early if they already exist.
|
||||||
|
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
|
||||||
|
if (usersToAdd.IsSubsetOf(groupMembers))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<Guid>();
|
||||||
foreach (var obj in objArray.EnumerateArray())
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +1,11 @@
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
|
|
||||||
namespace Bit.Scim.Models;
|
namespace Bit.Scim.Models;
|
||||||
|
|
||||||
@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel
|
|||||||
{
|
{
|
||||||
public ScimUserRequestModel()
|
public ScimUserRequestModel()
|
||||||
: base(false)
|
: base(false)
|
||||||
{ }
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
@ -25,6 +29,31 @@ public class ScimUserRequestModel : BaseScimUserModel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersRequest ToRequest(
|
||||||
|
ScimProviderType scimProvider,
|
||||||
|
InviteOrganization inviteOrganization,
|
||||||
|
DateTimeOffset performedAt)
|
||||||
|
{
|
||||||
|
var email = EmailForInvite(scimProvider);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || !Active)
|
||||||
|
{
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InviteOrganizationUsersRequest(
|
||||||
|
invites:
|
||||||
|
[
|
||||||
|
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
|
||||||
|
email: email,
|
||||||
|
externalId: ExternalIdForInvite()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty, // SCIM does not have a user id
|
||||||
|
performedAt: performedAt);
|
||||||
|
}
|
||||||
|
|
||||||
private string EmailForInvite(ScimProviderType scimProvider)
|
private string EmailForInvite(ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
var email = PrimaryEmail?.ToLowerInvariant();
|
var email = PrimaryEmail?.ToLowerInvariant();
|
||||||
|
@ -1,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)
|
||||||
|
@ -1,39 +1,99 @@
|
|||||||
using Bit.Core.Enums;
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Scim.Context;
|
using Bit.Scim.Context;
|
||||||
using Bit.Scim.Models;
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Users.Interfaces;
|
using Bit.Scim.Users.Interfaces;
|
||||||
|
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
|
||||||
|
|
||||||
namespace Bit.Scim.Users;
|
namespace Bit.Scim.Users;
|
||||||
|
|
||||||
public class PostUserCommand : IPostUserCommand
|
public class PostUserCommand(
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IOrganizationService organizationService,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IScimContext scimContext,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IPricingClient pricingClient)
|
||||||
|
: IPostUserCommand
|
||||||
{
|
{
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
|
||||||
private readonly IOrganizationService _organizationService;
|
|
||||||
private readonly IPaymentService _paymentService;
|
|
||||||
private readonly IScimContext _scimContext;
|
|
||||||
|
|
||||||
public PostUserCommand(
|
|
||||||
IOrganizationRepository organizationRepository,
|
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
|
||||||
IOrganizationService organizationService,
|
|
||||||
IPaymentService paymentService,
|
|
||||||
IScimContext scimContext)
|
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
|
||||||
_organizationUserRepository = organizationUserRepository;
|
{
|
||||||
_organizationService = organizationService;
|
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
|
||||||
_paymentService = paymentService;
|
}
|
||||||
_scimContext = scimContext;
|
|
||||||
|
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
|
||||||
|
ScimUserRequestModel model,
|
||||||
|
Guid organizationId,
|
||||||
|
ScimProviderType scimProvider)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization is null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
|
||||||
|
var request = model.ToRequest(
|
||||||
|
scimProvider: scimProvider,
|
||||||
|
inviteOrganization: new InviteOrganization(organization, plan),
|
||||||
|
performedAt: timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var orgUsers = await organizationUserRepository
|
||||||
|
.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
|
if (orgUsers.Any(existingUser =>
|
||||||
|
request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
throw new ConflictException("User already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
var invitedOrganizationUserId = result switch
|
||||||
|
{
|
||||||
|
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
|
||||||
|
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors
|
||||||
|
.Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null,
|
||||||
|
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
|
||||||
|
_ => throw new InvalidOperationException()
|
||||||
|
};
|
||||||
|
|
||||||
|
var organizationUser = invitedOrganizationUserId.HasValue
|
||||||
|
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return organizationUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
|
||||||
|
ScimUserRequestModel model,
|
||||||
|
Guid organizationId,
|
||||||
|
ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
var scimProvider = _scimContext.RequestScimProvider;
|
|
||||||
var invite = model.ToOrganizationUserInvite(scimProvider);
|
var invite = model.ToOrganizationUserInvite(scimProvider);
|
||||||
|
|
||||||
var email = invite.Emails.Single();
|
var email = invite.Emails.Single();
|
||||||
@ -44,7 +104,7 @@ public class PostUserCommand : IPostUserCommand
|
|||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||||
if (orgUserByEmail != null)
|
if (orgUserByEmail != null)
|
||||||
{
|
{
|
||||||
@ -57,13 +117,21 @@ public class PostUserCommand : IPostUserCommand
|
|||||||
throw new ConflictException();
|
throw new ConflictException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
|
||||||
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
||||||
|
|
||||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
|
||||||
invite, externalId);
|
EventSystemUser.SCIM,
|
||||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
invite,
|
||||||
|
externalId);
|
||||||
|
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
}
|
}
|
||||||
|
@ -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>();
|
||||||
}
|
}
|
||||||
|
@ -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 =>
|
||||||
|
@ -4,6 +4,7 @@ using Bit.Commercial.Core.Billing;
|
|||||||
using Bit.Commercial.Core.Billing.Models;
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
@ -115,6 +116,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
||||||
|
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
var existingPlan = new ProviderPlan
|
var existingPlan = new ProviderPlan
|
||||||
{
|
{
|
||||||
@ -132,10 +135,7 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
|
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(
|
|
||||||
Arg.Is(provider.GatewaySubscriptionId),
|
|
||||||
Arg.Is(provider.Id))
|
|
||||||
.Returns(new Subscription
|
.Returns(new Subscription
|
||||||
{
|
{
|
||||||
Id = provider.GatewaySubscriptionId,
|
Id = provider.GatewaySubscriptionId,
|
||||||
@ -158,7 +158,7 @@ public class ProviderBillingServiceTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
var command =
|
var command =
|
||||||
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId);
|
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
|
||||||
.Returns(StaticStore.GetPlan(command.NewPlan));
|
.Returns(StaticStore.GetPlan(command.NewPlan));
|
||||||
@ -170,6 +170,8 @@ public class ProviderBillingServiceTests
|
|||||||
await providerPlanRepository.Received(1)
|
await providerPlanRepository.Received(1)
|
||||||
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
|
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
|
||||||
|
|
||||||
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
await stripeAdapter.Received(1)
|
await stripeAdapter.Received(1)
|
||||||
.SubscriptionUpdateAsync(
|
.SubscriptionUpdateAsync(
|
||||||
Arg.Is(provider.GatewaySubscriptionId),
|
Arg.Is(provider.GatewaySubscriptionId),
|
||||||
@ -405,6 +407,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 50 seats currently assigned with a seat minimum of 100
|
// 50 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -427,11 +446,9 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
|
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
|
||||||
await sutProvider.GetDependency<IPaymentService>().DidNotReceiveWithAnyArgs().AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(
|
||||||
Arg.Any<Provider>(),
|
Arg.Any<string>(),
|
||||||
Arg.Any<Bit.Core.Models.StaticStore.Plan>(),
|
Arg.Any<SubscriptionUpdateOptions>());
|
||||||
Arg.Any<int>(),
|
|
||||||
Arg.Any<int>());
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
pPlan => pPlan.AllocatedSeats == 60));
|
pPlan => pPlan.AllocatedSeats == 60));
|
||||||
@ -474,6 +491,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 95 seats currently assigned with a seat minimum of 100
|
// 95 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -496,11 +530,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
|
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
providerPlan.SeatMinimum!.Value,
|
options =>
|
||||||
105);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == 105));
|
||||||
|
|
||||||
// 105 total seats - 100 minimum = 5 purchased seats
|
// 105 total seats - 100 minimum = 5 purchased seats
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -544,6 +579,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 110 seats currently assigned with a seat minimum of 100
|
// 110 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -566,11 +618,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
|
||||||
|
|
||||||
// 110 current + 10 seat scale up = 120 seats
|
// 110 current + 10 seat scale up = 120 seats
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
110,
|
options =>
|
||||||
120);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == 120));
|
||||||
|
|
||||||
// 120 total seats - 100 seat minimum = 20 purchased seats
|
// 120 total seats - 100 seat minimum = 20 purchased seats
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -614,6 +667,23 @@ public class ProviderBillingServiceTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem { Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } },
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 110 seats currently assigned with a seat minimum of 100
|
// 110 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
@ -636,11 +706,12 @@ public class ProviderBillingServiceTests
|
|||||||
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
|
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
|
||||||
|
|
||||||
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
|
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
|
||||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
|
||||||
provider,
|
provider.GatewaySubscriptionId,
|
||||||
StaticStore.GetPlan(providerPlan.PlanType),
|
Arg.Is<SubscriptionUpdateOptions>(
|
||||||
110,
|
options =>
|
||||||
providerPlan.SeatMinimum!.Value);
|
options.Items.First().Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
|
options.Items.First().Quantity == providerPlan.SeatMinimum!.Value));
|
||||||
|
|
||||||
// Being below the seat minimum means no purchased seats.
|
// Being below the seat minimum means no purchased seats.
|
||||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||||
@ -924,11 +995,15 @@ public class ProviderBillingServiceTests
|
|||||||
{
|
{
|
||||||
provider.GatewaySubscriptionId = null;
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
{
|
.GetCustomerOrThrow(
|
||||||
Id = "customer_id",
|
provider,
|
||||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
|
||||||
});
|
.Returns(new Customer
|
||||||
|
{
|
||||||
|
Id = "customer_id",
|
||||||
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
|
});
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -973,13 +1048,18 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider,
|
SutProvider<ProviderBillingService> sutProvider,
|
||||||
Provider provider)
|
Provider provider)
|
||||||
{
|
{
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
provider.GatewaySubscriptionId = null;
|
provider.GatewaySubscriptionId = null;
|
||||||
|
|
||||||
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider).Returns(new Customer
|
var customer = new Customer
|
||||||
{
|
{
|
||||||
Id = "customer_id",
|
Id = "customer_id",
|
||||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||||
});
|
};
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetCustomerOrThrow(
|
||||||
|
provider,
|
||||||
|
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1012,11 +1092,21 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
.Returns(providerPlans);
|
.Returns(providerPlans);
|
||||||
|
|
||||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
|
||||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||||
|
.When(x => x.SetCreateOptions(
|
||||||
|
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||||
|
options.Customer == "customer_id")
|
||||||
|
, Arg.Is<Customer>(p => p == customer)))
|
||||||
|
.Do(x =>
|
||||||
|
{
|
||||||
|
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||||
sub =>
|
sub =>
|
||||||
sub.AutomaticTax.Enabled == true &&
|
sub.AutomaticTax.Enabled == true &&
|
||||||
@ -1024,9 +1114,9 @@ public class ProviderBillingServiceTests
|
|||||||
sub.Customer == "customer_id" &&
|
sub.Customer == "customer_id" &&
|
||||||
sub.DaysUntilDue == 30 &&
|
sub.DaysUntilDue == 30 &&
|
||||||
sub.Items.Count == 2 &&
|
sub.Items.Count == 2 &&
|
||||||
sub.Items.ElementAt(0).Price == teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId &&
|
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||||
sub.Items.ElementAt(0).Quantity == 100 &&
|
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||||
sub.Items.ElementAt(1).Price == enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId &&
|
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||||
sub.OffSession == true &&
|
sub.OffSession == true &&
|
||||||
@ -1048,8 +1138,7 @@ public class ProviderBillingServiceTests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.TeamsMonthly, -10),
|
(PlanType.TeamsMonthly, -10),
|
||||||
(PlanType.EnterpriseMonthly, 50)
|
(PlanType.EnterpriseMonthly, 50)
|
||||||
@ -1068,6 +1157,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1097,9 +1188,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
provider.Id).Returns(subscription);
|
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1116,8 +1205,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 30),
|
(PlanType.EnterpriseMonthly, 30),
|
||||||
(PlanType.TeamsMonthly, 20)
|
(PlanType.TeamsMonthly, 20)
|
||||||
@ -1149,6 +1237,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1178,7 +1268,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1195,8 +1285,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 70),
|
(PlanType.EnterpriseMonthly, 70),
|
||||||
(PlanType.TeamsMonthly, 50)
|
(PlanType.TeamsMonthly, 50)
|
||||||
@ -1228,6 +1317,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1257,7 +1348,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1274,8 +1365,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 60),
|
(PlanType.EnterpriseMonthly, 60),
|
||||||
(PlanType.TeamsMonthly, 60)
|
(PlanType.TeamsMonthly, 60)
|
||||||
@ -1301,6 +1391,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1330,7 +1422,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1347,8 +1439,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 80),
|
(PlanType.EnterpriseMonthly, 80),
|
||||||
(PlanType.TeamsMonthly, 80)
|
(PlanType.TeamsMonthly, 80)
|
||||||
@ -1380,6 +1471,8 @@ public class ProviderBillingServiceTests
|
|||||||
SutProvider<ProviderBillingService> sutProvider)
|
SutProvider<ProviderBillingService> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||||
|
|
||||||
@ -1409,7 +1502,7 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
var providerPlans = new List<ProviderPlan>
|
||||||
{
|
{
|
||||||
@ -1426,8 +1519,7 @@ public class ProviderBillingServiceTests
|
|||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|
||||||
var command = new UpdateProviderSeatMinimumsCommand(
|
var command = new UpdateProviderSeatMinimumsCommand(
|
||||||
provider.Id,
|
provider,
|
||||||
provider.GatewaySubscriptionId,
|
|
||||||
[
|
[
|
||||||
(PlanType.EnterpriseMonthly, 70),
|
(PlanType.EnterpriseMonthly, 70),
|
||||||
(PlanType.TeamsMonthly, 30)
|
(PlanType.TeamsMonthly, 30)
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
90
perf/load/sync.js
Normal 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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
]);
|
]);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -66,24 +66,34 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
|
|||||||
{
|
{
|
||||||
public OrganizationUserDetailsResponseModel(
|
public OrganizationUserDetailsResponseModel(
|
||||||
OrganizationUser organizationUser,
|
OrganizationUser organizationUser,
|
||||||
bool managedByOrganization,
|
bool claimedByOrganization,
|
||||||
|
string ssoExternalId,
|
||||||
IEnumerable<CollectionAccessSelection> collections)
|
IEnumerable<CollectionAccessSelection> collections)
|
||||||
: base(organizationUser, "organizationUserDetails")
|
: base(organizationUser, "organizationUserDetails")
|
||||||
{
|
{
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
|
SsoExternalId = ssoExternalId;
|
||||||
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
bool managedByOrganization,
|
bool claimedByOrganization,
|
||||||
IEnumerable<CollectionAccessSelection> collections)
|
IEnumerable<CollectionAccessSelection> collections)
|
||||||
: base(organizationUser, "organizationUserDetails")
|
: base(organizationUser, "organizationUserDetails")
|
||||||
{
|
{
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
|
SsoExternalId = organizationUser.SsoExternalId;
|
||||||
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ManagedByOrganization { get; set; }
|
[Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
|
||||||
|
public bool ManagedByOrganization
|
||||||
|
{
|
||||||
|
get => ClaimedByOrganization;
|
||||||
|
set => ClaimedByOrganization = value;
|
||||||
|
}
|
||||||
|
public bool ClaimedByOrganization { get; set; }
|
||||||
|
public string SsoExternalId { get; set; }
|
||||||
|
|
||||||
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
||||||
|
|
||||||
@ -117,7 +127,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
|
|||||||
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
||||||
{
|
{
|
||||||
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
|
bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails")
|
||||||
: base(organizationUser, obj)
|
: base(organizationUser, obj)
|
||||||
{
|
{
|
||||||
if (organizationUser == null)
|
if (organizationUser == null)
|
||||||
@ -134,7 +144,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
|
|||||||
Groups = organizationUser.Groups;
|
Groups = organizationUser.Groups;
|
||||||
// Prevent reset password when using key connector.
|
// Prevent reset password when using key connector.
|
||||||
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
|
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
@ -142,11 +152,17 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
|
|||||||
public string AvatarColor { get; set; }
|
public string AvatarColor { get; set; }
|
||||||
public bool TwoFactorEnabled { get; set; }
|
public bool TwoFactorEnabled { get; set; }
|
||||||
public bool SsoBound { get; set; }
|
public bool SsoBound { get; set; }
|
||||||
|
[Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
|
||||||
|
public bool ManagedByOrganization
|
||||||
|
{
|
||||||
|
get => ClaimedByOrganization;
|
||||||
|
set => ClaimedByOrganization = value;
|
||||||
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indicates if the organization manages the user. If a user is "managed" by an organization,
|
/// Indicates if the organization claimed the user. If a user is "claimed" by an organization,
|
||||||
/// the organization has greater control over their account, and some user actions are restricted.
|
/// the organization has greater control over their account, and some user actions are restricted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ManagedByOrganization { get; set; }
|
public bool ClaimedByOrganization { get; set; }
|
||||||
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
||||||
public IEnumerable<Guid> Groups { get; set; }
|
public IEnumerable<Guid> Groups { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
|
|
||||||
public ProfileOrganizationResponseModel(
|
public ProfileOrganizationResponseModel(
|
||||||
OrganizationUserOrganizationDetails organization,
|
OrganizationUserOrganizationDetails organization,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser)
|
IEnumerable<Guid> organizationIdsClaimingUser)
|
||||||
: this("profileOrganization")
|
: this("profileOrganization")
|
||||||
{
|
{
|
||||||
Id = organization.OrganizationId;
|
Id = organization.OrganizationId;
|
||||||
@ -51,7 +51,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
|
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
|
||||||
Identifier = organization.Identifier;
|
Identifier = organization.Identifier;
|
||||||
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
|
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
|
||||||
ResetPasswordEnrolled = organization.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey);
|
||||||
UserId = organization.UserId;
|
UserId = organization.UserId;
|
||||||
OrganizationUserId = organization.OrganizationUserId;
|
OrganizationUserId = organization.OrganizationUserId;
|
||||||
ProviderId = organization.ProviderId;
|
ProviderId = organization.ProviderId;
|
||||||
@ -70,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; }
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
Email = user.Email;
|
Email = user.Email;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SetsRequiredMembers]
|
[SetsRequiredMembers]
|
||||||
@ -49,7 +49,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
TwoFactorEnabled = twoFactorEnabled;
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
|
||||||
SsoExternalId = user.SsoExternalId;
|
SsoExternalId = user.SsoExternalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,11 +124,11 @@ public class AccountsController : Controller
|
|||||||
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail);
|
var claimedUserValidationResult = await _userService.ValidateClaimedUserDomainAsync(user, model.NewEmail);
|
||||||
|
|
||||||
if (!managedUserValidationResult.Succeeded)
|
if (!claimedUserValidationResult.Succeeded)
|
||||||
{
|
{
|
||||||
throw new BadRequestException(managedUserValidationResult.Errors);
|
throw new BadRequestException(claimedUserValidationResult.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
11
src/Api/Auth/Models/Request/UntrustDevicesModel.cs
Normal file
11
src/Api/Auth/Models/Request/UntrustDevicesModel.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Bit.Api.Auth.Models.Request;
|
||||||
|
|
||||||
|
public class UntrustDevicesRequestModel
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public IEnumerable<Guid> Devices { get; set; } = null!;
|
||||||
|
}
|
@ -58,10 +58,10 @@ public class AccountsController(
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
|
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
|
||||||
userHasPremiumFromOrganization, organizationIdsManagingActiveUser);
|
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||||
return new PaymentResponseModel
|
return new PaymentResponseModel
|
||||||
{
|
{
|
||||||
UserProfile = profile,
|
UserProfile = profile,
|
||||||
@ -229,9 +229,9 @@ public class AccountsController(
|
|||||||
await paymentService.SaveTaxInfoAsync(user, taxInfo);
|
await paymentService.SaveTaxInfoAsync(user, taxInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId)
|
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var organizationManagingUser = await userService.GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
|
||||||
return organizationManagingUser.Select(o => o.Id);
|
return organizationsClaimingUser.Select(o => o.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
53
src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
Normal file
53
src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
using Bit.Core.Auth.Models.Api.Request;
|
||||||
|
using Bit.Core.Auth.Utilities;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
|
||||||
|
namespace Bit.Api.KeyManagement.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Device implementation for <see cref="IRotationValidator{T,R}"/>
|
||||||
|
/// </summary>
|
||||||
|
public class DeviceRotationValidator : IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>
|
||||||
|
{
|
||||||
|
private readonly IDeviceRepository _deviceRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates a new <see cref="DeviceRotationValidator"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="deviceRepository">Retrieves all user <see cref="Device"/>s</param>
|
||||||
|
public DeviceRotationValidator(IDeviceRepository deviceRepository)
|
||||||
|
{
|
||||||
|
_deviceRepository = deviceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Device>> ValidateAsync(User user, IEnumerable<OtherDeviceKeysUpdateRequestModel> devices)
|
||||||
|
{
|
||||||
|
var result = new List<Device>();
|
||||||
|
|
||||||
|
var existingTrustedDevices = (await _deviceRepository.GetManyByUserIdAsync(user.Id)).Where(d => d.IsTrusted()).ToList();
|
||||||
|
if (existingTrustedDevices.Count == 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var existing in existingTrustedDevices)
|
||||||
|
{
|
||||||
|
var device = devices.FirstOrDefault(c => c.DeviceId == existing.Id);
|
||||||
|
if (device == null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("All existing trusted devices must be included in the rotation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.EncryptedUserKey == null || device.EncryptedPublicKey == null)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Rotated encryption keys must be provided for all devices that are trusted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(device.ToDevice(existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
@ -17,20 +17,20 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator<IEnumerable<
|
|||||||
|
|
||||||
public async Task<IEnumerable<WebAuthnLoginRotateKeyData>> ValidateAsync(User user, IEnumerable<WebAuthnLoginRotateKeyRequestModel> keysToRotate)
|
public async Task<IEnumerable<WebAuthnLoginRotateKeyData>> ValidateAsync(User user, IEnumerable<WebAuthnLoginRotateKeyRequestModel> keysToRotate)
|
||||||
{
|
{
|
||||||
// 2024-06: Remove after 3 releases, for backward compatibility
|
|
||||||
if (keysToRotate == null)
|
|
||||||
{
|
|
||||||
return new List<WebAuthnLoginRotateKeyData>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new List<WebAuthnLoginRotateKeyData>();
|
var result = new List<WebAuthnLoginRotateKeyData>();
|
||||||
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
||||||
if (existing == null || !existing.Any())
|
if (existing == null)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var ea in existing)
|
var validCredentials = existing.Where(credential => credential.SupportsPrf);
|
||||||
|
if (!validCredentials.Any())
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var ea in validCredentials)
|
||||||
{
|
{
|
||||||
var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id);
|
var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id);
|
||||||
if (keyToRotate == null)
|
if (keyToRotate == null)
|
||||||
|
@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
bool twoFactorEnabled,
|
bool twoFactorEnabled,
|
||||||
bool premiumFromOrganization,
|
bool premiumFromOrganization,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser) : base("profile")
|
IEnumerable<Guid> organizationIdsClaimingUser) : base("profile")
|
||||||
{
|
{
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
AvatarColor = user.AvatarColor;
|
AvatarColor = user.AvatarColor;
|
||||||
CreationDate = user.CreationDate;
|
CreationDate = user.CreationDate;
|
||||||
VerifyDevices = user.VerifyDevices;
|
VerifyDevices = user.VerifyDevices;
|
||||||
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser));
|
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
|
||||||
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
||||||
ProviderOrganizations =
|
ProviderOrganizations =
|
||||||
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
||||||
|
@ -22,6 +22,7 @@ public class NotificationResponseModel : ResponseModel
|
|||||||
Title = notificationStatusDetails.Title;
|
Title = notificationStatusDetails.Title;
|
||||||
Body = notificationStatusDetails.Body;
|
Body = notificationStatusDetails.Body;
|
||||||
Date = notificationStatusDetails.RevisionDate;
|
Date = notificationStatusDetails.RevisionDate;
|
||||||
|
TaskId = notificationStatusDetails.TaskId;
|
||||||
ReadDate = notificationStatusDetails.ReadDate;
|
ReadDate = notificationStatusDetails.ReadDate;
|
||||||
DeletedDate = notificationStatusDetails.DeletedDate;
|
DeletedDate = notificationStatusDetails.DeletedDate;
|
||||||
}
|
}
|
||||||
@ -40,6 +41,8 @@ public class NotificationResponseModel : ResponseModel
|
|||||||
|
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
|
|
||||||
|
public Guid? TaskId { get; set; }
|
||||||
|
|
||||||
public DateTime? ReadDate { get; set; }
|
public DateTime? ReadDate { get; set; }
|
||||||
|
|
||||||
public DateTime? DeletedDate { get; set; }
|
public DateTime? DeletedDate { get; set; }
|
||||||
|
@ -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);
|
||||||
|
@ -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.");
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -104,13 +104,13 @@ public class SyncController : Controller
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id);
|
var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id);
|
||||||
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id);
|
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
|
||||||
|
|
||||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||||
|
|
||||||
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
||||||
organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
||||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
bool userTwoFactorEnabled,
|
bool userTwoFactorEnabled,
|
||||||
bool userHasPremiumFromOrganization,
|
bool userHasPremiumFromOrganization,
|
||||||
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser,
|
IEnumerable<Guid> organizationIdsClaimingingUser,
|
||||||
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
||||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
@ -37,7 +37,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
: base("sync")
|
: base("sync")
|
||||||
{
|
{
|
||||||
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser);
|
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser);
|
||||||
Folders = folders.Select(f => new FolderResponseModel(f));
|
Folders = folders.Select(f => new FolderResponseModel(f));
|
||||||
Ciphers = ciphers.Select(cipher =>
|
Ciphers = ciphers.Select(cipher =>
|
||||||
new CipherDetailsResponseModel(
|
new CipherDetailsResponseModel(
|
||||||
|
@ -2,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' " />
|
||||||
|
@ -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>()
|
||||||
|
@ -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))
|
||||||
|
@ -87,8 +87,7 @@ public class Startup
|
|||||||
// TODO: no longer be required - see PM-1880
|
// TODO: no longer be required - see PM-1880
|
||||||
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
|
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
|
||||||
|
|
||||||
// Mvc
|
services.AddControllers(config =>
|
||||||
services.AddMvc(config =>
|
|
||||||
{
|
{
|
||||||
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
|
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
|
||||||
});
|
});
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "Index";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h2>Index</h2>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
|||||||
@model LoginModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Login";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="row justify-content-md-center">
|
|
||||||
<div class="col-4">
|
|
||||||
<p>Please enter your email address below to log in.</p>
|
|
||||||
<form asp-action="" method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="Email" class="sr-only">Email Address</label>
|
|
||||||
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
|
||||||
required autofocus>
|
|
||||||
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
|
||||||
<small class="form-text text-body-secondary">We'll email you a secure login link.</small>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-block" type="submit">Continue</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,14 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "Error";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
|
||||||
<p>
|
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
|
|
||||||
</p>
|
|
@ -1,41 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>@ViewData["Title"] | Bitwarden Billing Portal</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
|
|
||||||
integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
|
|
||||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"
|
|
||||||
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="~/styles/billing.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
|
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand" href="#"><i class="fa fa-lg fa-fw fa-shield"></i> Billing</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
|
|
||||||
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
|
||||||
<ul class="navbar-nav mr-auto">
|
|
||||||
<li class="nav-item active">
|
|
||||||
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="#">Link</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main role="main" class="container">
|
|
||||||
@RenderBody()
|
|
||||||
</main>
|
|
||||||
|
|
||||||
@RenderSection("Scripts", required: false)
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,3 +0,0 @@
|
|||||||
@using Bit.Billing
|
|
||||||
@using Bit.Billing.Models
|
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
|
@ -1,3 +0,0 @@
|
|||||||
@{
|
|
||||||
Layout = "_Layout";
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
.custom-select.input-validation-error ~ .invalid-feedback,
|
|
||||||
.custom-select.input-validation-error ~ .invalid-tooltip,
|
|
||||||
.form-control.input-validation-error ~ .invalid-feedback,
|
|
||||||
.form-control.input-validation-error ~ .invalid-tooltip {
|
|
||||||
display: block;
|
|
||||||
}
|
|
@ -313,5 +313,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
|||||||
UseSecretsManager = license.UseSecretsManager;
|
UseSecretsManager = license.UseSecretsManager;
|
||||||
SmSeats = license.SmSeats;
|
SmSeats = license.SmSeats;
|
||||||
SmServiceAccounts = license.SmServiceAccounts;
|
SmServiceAccounts = license.SmServiceAccounts;
|
||||||
|
UseRiskInsights = license.UseRiskInsights;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
18
src/Core/AdminConsole/Entities/OrganizationIntegration.cs
Normal file
18
src/Core/AdminConsole/Entities/OrganizationIntegration.cs
Normal 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();
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
7
src/Core/AdminConsole/Enums/IntegrationType.cs
Normal file
7
src/Core/AdminConsole/Enums/IntegrationType.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.Enums;
|
||||||
|
|
||||||
|
public enum IntegrationType : int
|
||||||
|
{
|
||||||
|
Slack = 1,
|
||||||
|
Webhook = 2,
|
||||||
|
}
|
@ -1,3 +1,8 @@
|
|||||||
namespace Bit.Core.AdminConsole.Errors;
|
namespace Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
public record Error<T>(string Message, T ErroredValue);
|
public record Error<T>(string Message, T ErroredValue);
|
||||||
|
|
||||||
|
public static class ErrorMappers
|
||||||
|
{
|
||||||
|
public static Error<B> ToError<A, B>(this Error<A> errorA, B erroredValue) => new(errorA.Message, erroredValue);
|
||||||
|
}
|
||||||
|
6
src/Core/AdminConsole/Errors/InvalidResultTypeError.cs
Normal file
6
src/Core/AdminConsole/Errors/InvalidResultTypeError.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
public record InvalidResultTypeError<T>(T Value) : Error<T>(Code, Value)
|
||||||
|
{
|
||||||
|
public const string Code = "Invalid result type.";
|
||||||
|
};
|
35
src/Core/AdminConsole/Models/Business/InviteOrganization.cs
Normal file
35
src/Core/AdminConsole/Models/Business/InviteOrganization.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
public record InviteOrganization
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; init; }
|
||||||
|
public int? Seats { get; init; }
|
||||||
|
public int? MaxAutoScaleSeats { get; init; }
|
||||||
|
public int? SmSeats { get; init; }
|
||||||
|
public int? SmMaxAutoScaleSeats { get; init; }
|
||||||
|
public Plan Plan { get; init; }
|
||||||
|
public string GatewayCustomerId { get; init; }
|
||||||
|
public string GatewaySubscriptionId { get; init; }
|
||||||
|
public bool UseSecretsManager { get; init; }
|
||||||
|
|
||||||
|
public InviteOrganization()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganization(Organization organization, Plan plan)
|
||||||
|
{
|
||||||
|
OrganizationId = organization.Id;
|
||||||
|
Seats = organization.Seats;
|
||||||
|
MaxAutoScaleSeats = organization.MaxAutoscaleSeats;
|
||||||
|
SmSeats = organization.SmSeats;
|
||||||
|
SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats;
|
||||||
|
Plan = plan;
|
||||||
|
GatewayCustomerId = organization.GatewayCustomerId;
|
||||||
|
GatewaySubscriptionId = organization.GatewaySubscriptionId;
|
||||||
|
UseSecretsManager = organization.UseSecretsManager;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Bit.Core.Models.Data.Organizations;
|
||||||
|
|
||||||
|
public class OrganizationIntegrationConfigurationDetails
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid OrganizationIntegrationId { get; set; }
|
||||||
|
public IntegrationType IntegrationType { get; set; }
|
||||||
|
public EventType EventType { get; set; }
|
||||||
|
public string? Configuration { get; set; }
|
||||||
|
public string? IntegrationConfiguration { get; set; }
|
||||||
|
public string? Template { get; set; }
|
||||||
|
|
||||||
|
public JsonObject MergedConfiguration
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var integrationJson = IntegrationConfigurationJson;
|
||||||
|
|
||||||
|
foreach (var kvp in ConfigurationJson)
|
||||||
|
{
|
||||||
|
integrationJson[kvp.Key] = kvp.Value?.DeepClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return integrationJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject ConfigurationJson
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configuration = Configuration ?? string.Empty;
|
||||||
|
return JsonNode.Parse(configuration) as JsonObject ?? new JsonObject();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject IntegrationConfigurationJson
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var integration = IntegrationConfiguration ?? string.Empty;
|
||||||
|
return JsonNode.Parse(integration) as JsonObject ?? new JsonObject();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -148,7 +148,8 @@ public class SelfHostedOrganizationDetails : Organization
|
|||||||
LimitCollectionDeletion = LimitCollectionDeletion,
|
LimitCollectionDeletion = LimitCollectionDeletion,
|
||||||
LimitItemDeletion = LimitItemDeletion,
|
LimitItemDeletion = LimitItemDeletion,
|
||||||
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
|
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
|
||||||
Status = Status
|
Status = Status,
|
||||||
|
UseRiskInsights = UseRiskInsights,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -154,6 +154,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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);
|
||||||
|
}
|
@ -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.
|
@ -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);
|
||||||
}
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public static class ErrorMapper
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the ErrorT to a Bit.Exception class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="error"></param>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static Exception MapToBitException<T>(Error<T> error) =>
|
||||||
|
error switch
|
||||||
|
{
|
||||||
|
UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message),
|
||||||
|
_ => new BadRequestException(error.Message)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This maps the ErrorT object to the Bit.Exception class.
|
||||||
|
///
|
||||||
|
/// This should be replaced by an IActionResult mapper when possible.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="errors"></param>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static Exception MapToBitException<T>(ICollection<Error<T>> errors) =>
|
||||||
|
errors switch
|
||||||
|
{
|
||||||
|
not null when errors.Count == 1 => MapToBitException(errors.First()),
|
||||||
|
not null when errors.Count > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))),
|
||||||
|
_ => new BadRequestException()
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "Failed to invite users";
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record NoUsersToInviteError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "No users to invite";
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record UserAlreadyExistsError(ScimInviteOrganizationUsersResponse Response) : Error<ScimInviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "User already exists";
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the contract for inviting organization users via SCIM (System for Cross-domain Identity Management).
|
||||||
|
/// Provides functionality for handling single email invitation requests within an organization context.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInviteOrganizationUsersCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an invitation to add an organization user via SCIM (System for Cross-domain Identity Management) system.
|
||||||
|
/// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value.
|
||||||
|
/// Success will be the successful return object.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">
|
||||||
|
/// Contains the details for inviting a single organization user via email.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>Response from InviteScimOrganiation<see cref="ScimInviteOrganizationUsersResponse"/></returns>
|
||||||
|
Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request);
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is for sending the invite to an organization user.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISendOrganizationInvitesCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This sends emails out to organization users for a given organization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"><see cref="SendInvitesRequest"/></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task SendInvitesAsync(SendInvitesRequest request);
|
||||||
|
}
|
@ -0,0 +1,282 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Enums;
|
||||||
|
using Bit.Core.Tools.Models.Business;
|
||||||
|
using Bit.Core.Tools.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersCommand(IEventService eventService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IInviteUsersValidator inviteUsersValidator,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IReferenceEventService referenceEventService,
|
||||||
|
ICurrentContext currentContext,
|
||||||
|
IApplicationCacheService applicationCacheService,
|
||||||
|
IMailService mailService,
|
||||||
|
ILogger<InviteOrganizationUsersCommand> logger,
|
||||||
|
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||||
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
||||||
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
|
IProviderUserRepository providerUserRepository
|
||||||
|
) : IInviteOrganizationUsersCommand
|
||||||
|
{
|
||||||
|
|
||||||
|
public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached.";
|
||||||
|
|
||||||
|
public async Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var result = await InviteOrganizationUsersAsync(request);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case Failure<InviteOrganizationUsersResponse> failure:
|
||||||
|
return new Failure<ScimInviteOrganizationUsersResponse>(
|
||||||
|
failure.Errors.Select(error => new Error<ScimInviteOrganizationUsersResponse>(error.Message,
|
||||||
|
new ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault()
|
||||||
|
})));
|
||||||
|
|
||||||
|
case Success<InviteOrganizationUsersResponse> success when success.Value.InvitedUsers.Any():
|
||||||
|
var user = success.Value.InvitedUsers.First();
|
||||||
|
|
||||||
|
await eventService.LogOrganizationUserEventAsync<IOrganizationUser>(
|
||||||
|
organizationUser: user,
|
||||||
|
type: EventType.OrganizationUser_Invited,
|
||||||
|
systemUser: EventSystemUser.SCIM,
|
||||||
|
date: request.PerformedAt.UtcDateTime);
|
||||||
|
|
||||||
|
return new Success<ScimInviteOrganizationUsersResponse>(new ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
InvitedUser = user
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new Failure<ScimInviteOrganizationUsersResponse>(
|
||||||
|
new InvalidResultTypeError<ScimInviteOrganizationUsersResponse>(
|
||||||
|
new ScimInviteOrganizationUsersResponse()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult<InviteOrganizationUsersResponse>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray();
|
||||||
|
|
||||||
|
if (invitesToSend.Length == 0)
|
||||||
|
{
|
||||||
|
return new Failure<InviteOrganizationUsersResponse>(new NoUsersToInviteError(
|
||||||
|
new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
Invites = invitesToSend.ToArray(),
|
||||||
|
InviteOrganization = request.InviteOrganization,
|
||||||
|
PerformedBy = request.PerformedBy,
|
||||||
|
PerformedAt = request.PerformedAt,
|
||||||
|
OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId),
|
||||||
|
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationResult is Invalid<InviteOrganizationUsersValidationRequest> invalid)
|
||||||
|
{
|
||||||
|
return invalid.MapToFailure(r => new InviteOrganizationUsersResponse(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedRequest = validationResult as Valid<InviteOrganizationUsersValidationRequest>;
|
||||||
|
|
||||||
|
var organizationUserToInviteEntities = invitesToSend
|
||||||
|
.Select(x => x.MapToDataModel(request.PerformedAt, validatedRequest!.Value.InviteOrganization))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(validatedRequest!.Value.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await organizationUserRepository.CreateManyAsync(organizationUserToInviteEntities);
|
||||||
|
|
||||||
|
await AdjustPasswordManagerSeatsAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
await AdjustSecretsManagerSeatsAsync(validatedRequest);
|
||||||
|
|
||||||
|
await SendAdditionalEmailsAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
await SendInvitesAsync(organizationUserToInviteEntities, organization);
|
||||||
|
|
||||||
|
await PublishReferenceEventAsync(validatedRequest, organization);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, FailedToInviteUsersError.Code);
|
||||||
|
|
||||||
|
await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));
|
||||||
|
|
||||||
|
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
|
||||||
|
await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats);
|
||||||
|
|
||||||
|
await RevertPasswordManagerChangesAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
return new Failure<InviteOrganizationUsersResponse>(
|
||||||
|
new FailedToInviteUsersError(
|
||||||
|
new InviteOrganizationUsersResponse(validatedRequest.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Success<InviteOrganizationUsersResponse>(
|
||||||
|
new InviteOrganizationUsersResponse(
|
||||||
|
invitedOrganizationUsers: organizationUserToInviteEntities.Select(x => x.OrganizationUser).ToArray(),
|
||||||
|
organizationId: organization!.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<OrganizationUserInvite>> FilterExistingUsersAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var existingEmails = new HashSet<string>(await organizationUserRepository.SelectKnownEmailsAsync(
|
||||||
|
request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return request.Invites
|
||||||
|
.Where(invite => !existingEmails.Contains(invite.Email))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0)
|
||||||
|
{
|
||||||
|
// When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add.
|
||||||
|
// However, this might lead to a problem if we don't actually update stripe but throw any ways.
|
||||||
|
// stripe could not be updated, and then we would decrement the number of seats in stripe accidentally.
|
||||||
|
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd;
|
||||||
|
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove);
|
||||||
|
|
||||||
|
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
|
||||||
|
|
||||||
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
|
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertSecretsManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization, int? initialSmSeats)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
|
||||||
|
{
|
||||||
|
var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(
|
||||||
|
organization: organization,
|
||||||
|
plan: validatedResult.Value.InviteOrganization.Plan,
|
||||||
|
autoscaling: false)
|
||||||
|
{
|
||||||
|
SmSeats = initialSmSeats
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishReferenceEventAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult,
|
||||||
|
Organization organization) =>
|
||||||
|
await referenceEventService.RaiseEventAsync(
|
||||||
|
new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext)
|
||||||
|
{
|
||||||
|
Users = validatedResult.Value.Invites.Length
|
||||||
|
});
|
||||||
|
|
||||||
|
private async Task SendInvitesAsync(IEnumerable<CreateOrganizationUser> users, Organization organization) =>
|
||||||
|
await sendOrganizationInvitesCommand.SendInvitesAsync(
|
||||||
|
new SendInvitesRequest(
|
||||||
|
users.Select(x => x.OrganizationUser),
|
||||||
|
organization));
|
||||||
|
|
||||||
|
private async Task SendAdditionalEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ownerEmails = await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization);
|
||||||
|
|
||||||
|
await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
|
||||||
|
validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats!.Value, ownerEmails);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, IssueNotifyingOwnersOfSeatLimitReached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<string>> GetOwnerEmailAddressesAsync(InviteOrganization organization)
|
||||||
|
{
|
||||||
|
var providerOrganization = await providerOrganizationRepository
|
||||||
|
.GetByOrganizationId(organization.OrganizationId);
|
||||||
|
|
||||||
|
if (providerOrganization == null)
|
||||||
|
{
|
||||||
|
return (await organizationUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(organization.OrganizationId, OrganizationUserType.Owner))
|
||||||
|
.Select(x => x.Email)
|
||||||
|
.Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await providerUserRepository
|
||||||
|
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed))
|
||||||
|
.Select(u => u.Email).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdjustSecretsManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
|
||||||
|
{
|
||||||
|
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(validatedResult.Value.SecretsManagerSubscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
|
||||||
|
|
||||||
|
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
|
||||||
|
|
||||||
|
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
|
||||||
|
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
|
|
||||||
|
await referenceEventService.RaiseEventAsync(
|
||||||
|
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
|
||||||
|
{
|
||||||
|
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
|
||||||
|
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
|
||||||
|
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
|
||||||
|
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Object for associating the <see cref="OrganizationUser"/> with their assigned collections
|
||||||
|
/// <see cref="CollectionAccessSelection"/> and Group Ids.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateOrganizationUser
|
||||||
|
{
|
||||||
|
public OrganizationUser OrganizationUser { get; set; }
|
||||||
|
public CollectionAccessSelection[] Collections { get; set; } = [];
|
||||||
|
public Guid[] Groups { get; set; } = [];
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public static class CreateOrganizationUserExtensions
|
||||||
|
{
|
||||||
|
public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite,
|
||||||
|
DateTimeOffset performedAt,
|
||||||
|
InviteOrganization organization) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OrganizationUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.OrganizationId,
|
||||||
|
Email = organizationUserInvite.Email.ToLowerInvariant(),
|
||||||
|
Type = organizationUserInvite.Type,
|
||||||
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
AccessSecretsManager = organizationUserInvite.AccessSecretsManager,
|
||||||
|
ExternalId = string.IsNullOrWhiteSpace(organizationUserInvite.ExternalId) ? null : organizationUserInvite.ExternalId,
|
||||||
|
CreationDate = performedAt.UtcDateTime,
|
||||||
|
RevisionDate = performedAt.UtcDateTime
|
||||||
|
},
|
||||||
|
Collections = organizationUserInvite.AssignedCollections,
|
||||||
|
Groups = organizationUserInvite.Groups
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public static class InviteOrganizationUserErrorMessages
|
||||||
|
{
|
||||||
|
public const string InvalidEmailErrorMessage = "The email address is not valid.";
|
||||||
|
public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.";
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersRequest
|
||||||
|
{
|
||||||
|
public OrganizationUserInvite[] Invites { get; } = [];
|
||||||
|
public InviteOrganization InviteOrganization { get; }
|
||||||
|
public Guid PerformedBy { get; }
|
||||||
|
public DateTimeOffset PerformedAt { get; }
|
||||||
|
|
||||||
|
public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites,
|
||||||
|
InviteOrganization inviteOrganization,
|
||||||
|
Guid performedBy,
|
||||||
|
DateTimeOffset performedAt)
|
||||||
|
{
|
||||||
|
Invites = invites;
|
||||||
|
InviteOrganization = inviteOrganization;
|
||||||
|
PerformedBy = performedBy;
|
||||||
|
PerformedAt = performedAt;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersResponse(Guid organizationId)
|
||||||
|
{
|
||||||
|
public IEnumerable<OrganizationUser> InvitedUsers { get; } = [];
|
||||||
|
public Guid OrganizationId { get; } = organizationId;
|
||||||
|
|
||||||
|
public InviteOrganizationUsersResponse(InviteOrganizationUsersValidationRequest usersValidationRequest)
|
||||||
|
: this(usersValidationRequest.InviteOrganization.OrganizationId)
|
||||||
|
{
|
||||||
|
InvitedUsers = usersValidationRequest.Invites.Select(x => new OrganizationUser { Email = x.Email });
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersResponse(IEnumerable<OrganizationUser> invitedOrganizationUsers, Guid organizationId)
|
||||||
|
: this(organizationId)
|
||||||
|
{
|
||||||
|
InvitedUsers = invitedOrganizationUsers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
public OrganizationUser InvitedUser { get; init; }
|
||||||
|
|
||||||
|
public ScimInviteOrganizationUsersResponse()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScimInviteOrganizationUsersResponse(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var userToInvite = request.Invites.First();
|
||||||
|
|
||||||
|
InvitedUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Email = userToInvite.Email,
|
||||||
|
ExternalId = userToInvite.ExternalId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
public InviteOrganizationUsersValidationRequest()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request)
|
||||||
|
{
|
||||||
|
Invites = request.Invites;
|
||||||
|
InviteOrganization = request.InviteOrganization;
|
||||||
|
PerformedBy = request.PerformedBy;
|
||||||
|
PerformedAt = request.PerformedAt;
|
||||||
|
OccupiedPmSeats = request.OccupiedPmSeats;
|
||||||
|
OccupiedSmSeats = request.OccupiedSmSeats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request,
|
||||||
|
PasswordManagerSubscriptionUpdate subscriptionUpdate,
|
||||||
|
SecretsManagerSubscriptionUpdate smSubscriptionUpdate)
|
||||||
|
: this(request)
|
||||||
|
{
|
||||||
|
PasswordManagerSubscriptionUpdate = subscriptionUpdate;
|
||||||
|
SecretsManagerSubscriptionUpdate = smSubscriptionUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite[] Invites { get; init; } = [];
|
||||||
|
public InviteOrganization InviteOrganization { get; init; }
|
||||||
|
public Guid PerformedBy { get; init; }
|
||||||
|
public DateTimeOffset PerformedAt { get; init; }
|
||||||
|
public int OccupiedPmSeats { get; init; }
|
||||||
|
public int OccupiedSmSeats { get; init; }
|
||||||
|
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; }
|
||||||
|
public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; }
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user