diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5121d551c..47d3525de 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,13 +4,22 @@ # # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -# DevOps for Actions and other workflow changes -.github/workflows @bitwarden/dept-devops +## Docker files have shared ownership ## +**/Dockerfile +**/*.Dockerfile +**/.dockerignore +**/entrypoint.sh -# DevOps for Docker changes -**/Dockerfile @bitwarden/dept-devops -**/*.Dockerfile @bitwarden/dept-devops -**/.dockerignore @bitwarden/dept-devops +## BRE team owns these workflows ## +.github/workflows/publish.yml @bitwarden/dept-bre + +## These are shared workflows ## +.github/workflows/_move_finalization_db_scripts.yml +.github/workflows/build.yml +.github/workflows/cleanup-after-pr.yml +.github/workflows/cleanup-rc-branch.yml +.github/workflows/release.yml +.github/workflows/repository-management.yml # Database Operations for database changes src/Sql/** @bitwarden/dept-dbops @@ -60,6 +69,6 @@ src/EventsProcessor @bitwarden/team-admin-console-dev src/Admin/Controllers/ToolsController.cs @bitwarden/team-billing-dev src/Admin/Views/Tools @bitwarden/team-billing-dev -# Multiple owners - DO NOT REMOVE (DevOps) +# Multiple owners - DO NOT REMOVE (BRE) **/packages.lock.json Directory.Build.props diff --git a/.github/workflows/_move_finalization_db_scripts.yml b/.github/workflows/_move_finalization_db_scripts.yml index c54e3abb2..6e3825733 100644 --- a/.github/workflows/_move_finalization_db_scripts.yml +++ b/.github/workflows/_move_finalization_db_scripts.yml @@ -1,4 +1,3 @@ ---- name: _move_finalization_db_scripts run-name: Move finalization database scripts @@ -30,7 +29,7 @@ jobs: secrets: "github-pat-bitwarden-devops-bot-repo-scope" - name: Check out branch - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} @@ -54,7 +53,7 @@ jobs: if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} steps: - name: Checkout - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 diff --git a/.github/workflows/automatic-issue-responses.yml b/.github/workflows/automatic-issue-responses.yml index 0e6b9041b..b6a6a1ebf 100644 --- a/.github/workflows/automatic-issue-responses.yml +++ b/.github/workflows/automatic-issue-responses.yml @@ -1,4 +1,3 @@ ---- name: Automatic responses on: issues: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ba3ec22b..6043e1e21 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,3 @@ ---- name: Build on: @@ -19,7 +18,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Set up .NET uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 @@ -68,7 +67,7 @@ jobs: node: true steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Set up .NET uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 @@ -110,7 +109,7 @@ jobs: ls -atlh ../../../ - name: Upload project artifact - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: ${{ matrix.project_name }}.zip path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip @@ -173,7 +172,7 @@ jobs: dotnet: true steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Check branch to publish env: @@ -263,7 +262,7 @@ jobs: -d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish - name: Build Docker image - uses: docker/build-push-action@32945a339266b759abcbdc89316275140b0fc960 # v6.8.0 + uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 with: context: ${{ matrix.base_path }}/${{ matrix.project_name }} file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile @@ -275,14 +274,14 @@ jobs: - name: Scan Docker image id: container-scan - uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4.1.2 + uses: anchore/scan-action@49e50b215b647c5ec97abb66f69af73c46a4ca08 # v5.0.1 with: image: ${{ steps.image-tags.outputs.primary_tag }} fail-build: false output-format: sarif - name: Upload Grype results to GitHub - uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9 + uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} @@ -292,7 +291,7 @@ jobs: needs: build-docker steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Set up .NET uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 @@ -311,7 +310,7 @@ jobs: github.ref == 'refs/heads/hotfix-rc' run: | # Set proper setup image based on branch - case "${{ github.ref }}" in + case "$GITHUB_REF" in "refs/heads/main") SETUP_IMAGE="$_AZ_REGISTRY/setup:dev" ;; @@ -355,7 +354,7 @@ jobs: - name: Upload Docker stub US artifact if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: docker-stub-US.zip path: docker-stub-US.zip @@ -363,7 +362,7 @@ jobs: - name: Upload Docker stub EU artifact if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: docker-stub-EU.zip path: docker-stub-EU.zip @@ -371,7 +370,7 @@ jobs: - name: Upload Docker stub US checksum artifact if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: docker-stub-US-sha256.txt path: docker-stub-US-sha256.txt @@ -379,7 +378,7 @@ jobs: - name: Upload Docker stub EU checksum artifact if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: docker-stub-EU-sha256.txt path: docker-stub-EU-sha256.txt @@ -403,12 +402,12 @@ jobs: GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder" - name: Upload Public API Swagger artifact - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: swagger.json path: swagger.json if-no-files-found: error - + - name: Build Internal API Swagger run: | cd ./src/Api @@ -416,17 +415,17 @@ jobs: dotnet tool restore echo "Publish API" dotnet publish -c "Release" -o obj/build-output/publish - + dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \ ./obj/build-output/publish/Api.dll internal - + cd ../Identity - + echo "Restore Identity tools" dotnet tool restore echo "Publish Identity" dotnet publish -c "Release" -o obj/build-output/publish - + dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \ ./obj/build-output/publish/Identity.dll v1 cd ../.. @@ -437,18 +436,18 @@ jobs: GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder" - name: Upload Internal API Swagger artifact - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: internal.json path: internal.json if-no-files-found: error - name: Upload Identity Swagger artifact - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: identity.json path: identity.json - if-no-files-found: error + if-no-files-found: error build-mssqlmigratorutility: name: Build MSSQL migrator utility @@ -467,7 +466,7 @@ jobs: - win-x64 steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Set up .NET uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 @@ -486,7 +485,7 @@ jobs: - name: Upload project artifact for Windows if: ${{ contains(matrix.target, 'win') == true }} - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: MsSqlMigratorUtility-${{ matrix.target }} path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe @@ -494,7 +493,7 @@ jobs: - name: Upload project artifact if: ${{ contains(matrix.target, 'win') == false }} - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: MsSqlMigratorUtility-${{ matrix.target }} path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility @@ -528,9 +527,9 @@ jobs: workflow_id: 'build-unified.yml', ref: 'main', inputs: { - server_branch: '${{ github.ref }}' + server_branch: process.env.GITHUB_REF } - }) + }); trigger-k8s-deploy: name: Trigger k8s deploy @@ -565,7 +564,7 @@ jobs: tag: 'main' } }) - + trigger-ee-updates: name: Trigger Ephemeral Environment updates if: github.ref != 'refs/heads/main' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') @@ -595,7 +594,7 @@ jobs: workflow_id: '_update_ephemeral_tags.yml', ref: 'main', inputs: { - ephemeral_env_branch: '${{ github.head_ref }}' + ephemeral_env_branch: process.env.GITHUB_HEAD_REF } }) diff --git a/.github/workflows/cleanup-after-pr.yml b/.github/workflows/cleanup-after-pr.yml index 1bed3542d..c36dc4a03 100644 --- a/.github/workflows/cleanup-after-pr.yml +++ b/.github/workflows/cleanup-after-pr.yml @@ -1,4 +1,3 @@ ---- name: Container registry cleanup on: diff --git a/.github/workflows/cleanup-ephemeral-environment.yml b/.github/workflows/cleanup-ephemeral-environment.yml new file mode 100644 index 000000000..d5c34a7bb --- /dev/null +++ b/.github/workflows/cleanup-ephemeral-environment.yml @@ -0,0 +1,59 @@ +name: Ephemeral environment cleanup + +on: + pull_request: + types: [unlabeled] + +jobs: + validate-pr: + name: Validate PR + runs-on: ubuntu-24.04 + outputs: + config-exists: ${{ steps.validate-config.outputs.config-exists }} + steps: + - name: Checkout PR + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + - name: Validate config exists in path + id: validate-config + run: | + if [[ -f "ephemeral-environments/$GITHUB_HEAD_REF.yaml" ]]; then + echo "Ephemeral environment config found in path, continuing." + echo "config-exists=true" >> $GITHUB_OUTPUT + fi + + + cleanup-config: + name: Cleanup ephemeral environment + runs-on: ubuntu-24.04 + needs: validate-pr + if: ${{ needs.validate-pr.outputs.config-exists }} + 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 cleanup + 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: '_ephemeral_environment_pr_manager.yml', + ref: 'main', + inputs: { + ephemeral_env_branch: process.env.GITHUB_HEAD_REF, + cleanup_config: true, + project: 'server' + } + }) diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index 3b3c2d55d..e037c18f9 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -1,4 +1,3 @@ ---- name: Cleanup RC Branch on: @@ -24,7 +23,7 @@ jobs: secrets: "github-pat-bitwarden-devops-bot-repo-scope" - name: Checkout main - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: ref: main token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 101e5730d..855241fdb 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Collect id: collect diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml index 160ee15b9..11d565493 100644 --- a/.github/workflows/enforce-labels.yml +++ b/.github/workflows/enforce-labels.yml @@ -1,4 +1,3 @@ ---- name: Enforce PR labels on: @@ -7,13 +6,13 @@ on: types: [labeled, unlabeled, opened, reopened, synchronize] jobs: enforce-label: - if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') }} + if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') || contains(github.event.*.labels.*.name, 'ephemeral-environment') }} name: Enforce label runs-on: ubuntu-22.04 steps: - name: Check for label run: | - echo "PRs with the hold or needs-qa labels cannot be merged" - echo "### :x: PRs with the hold or needs-qa labels cannot be merged" >> $GITHUB_STEP_SUMMARY + echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" + echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY exit 1 diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index 3bbc7e74f..95d57180d 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -1,7 +1,6 @@ # Runs if there are changes to the paths: list. # Starts a matrix job to check for modified files, then sets output based on the results. # The input decides if the label job is ran, adding a label to the PR. ---- name: Protect files on: @@ -29,7 +28,7 @@ jobs: label: "DB-migrations-changed" steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3c45f84b7..4454ea1f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,3 @@ ---- name: Publish run-name: Publish ${{ inputs.publish_type }} @@ -99,7 +98,7 @@ jobs: echo "Github Release Option: $RELEASE_OPTION" - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Set up project name id: setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c63302cbc..9d5dcb74d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,3 @@ ---- name: Release run-name: Release ${{ inputs.release_type }} @@ -37,7 +36,7 @@ jobs: fi - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Check release version id: version diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 29860b868..eb4187c59 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out target ref - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: ref: ${{ inputs.target_ref }} @@ -62,7 +62,7 @@ jobs: version: ${{ inputs.version_number_override }} - name: Check out branch - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: ref: main @@ -150,7 +150,7 @@ jobs: needs: bump_version steps: - name: Check out main branch - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: ref: main diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 0f4d060ba..8703bac5e 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -26,12 +26,12 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: ref: ${{ github.event.pull_request.head.sha }} - name: Scan with Checkmarx - uses: checkmarx/ast-github-action@9fda5a4a2c297608117a5a56af424502a9192e57 # 2.0.34 + uses: checkmarx/ast-github-action@f0869bd1a37fddc06499a096101e6c900e815d81 # 2.0.36 env: INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" with: @@ -46,7 +46,7 @@ jobs: --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub - uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9 + uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 with: sarif_file: cx_result.sarif @@ -66,7 +66,7 @@ jobs: distribution: "zulu" - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index d3dc92cd7..f8a25288f 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -1,4 +1,3 @@ ---- name: Staleness on: workflow_dispatch: diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 325f10b94..7a38b0f3b 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -1,4 +1,3 @@ ---- name: Database testing on: @@ -36,7 +35,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Set up .NET uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 @@ -55,7 +54,7 @@ jobs: # I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready - name: Sleep run: sleep 15s - + - name: Checking pending model changes (MySQL) working-directory: "util/MySqlMigrations" run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"' @@ -114,7 +113,7 @@ jobs: BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" shell: pwsh - + - name: Print MySQL Logs if: failure() run: 'docker logs $(docker ps --quiet --filter "name=mysql")' @@ -147,7 +146,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Set up .NET uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 @@ -164,7 +163,7 @@ jobs: shell: pwsh - name: Upload DACPAC - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: sql.dacpac path: Sql.dacpac @@ -190,7 +189,7 @@ jobs: shell: pwsh - name: Report validation results - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: report.xml path: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 216130a21..bd9e358df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Set up .NET uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 @@ -77,7 +77,7 @@ jobs: fail-on-error: true - name: Upload to codecov.io - uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 if: ${{ needs.check-test-secrets.outputs.available == 'true' }} env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/Directory.Build.props b/Directory.Build.props index 46a12ea3d..5cd12bfb7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2024.10.0 + 2024.10.1 Bit.$(MSBuildProjectName) enable diff --git a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs index 5e73505b9..1323205b9 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs @@ -57,17 +57,15 @@ public class UsersController : Controller [HttpGet("")] public async Task Get( Guid organizationId, - [FromQuery] string filter, - [FromQuery] int? count, - [FromQuery] int? startIndex) + [FromQuery] GetUsersQueryParamModel model) { - var usersListQueryResult = await _getUsersListQuery.GetUsersListAsync(organizationId, filter, count, startIndex); + var usersListQueryResult = await _getUsersListQuery.GetUsersListAsync(organizationId, model); var scimListResponseModel = new ScimListResponseModel { Resources = usersListQueryResult.userList.Select(u => new ScimUserResponseModel(u)).ToList(), - ItemsPerPage = count.GetValueOrDefault(usersListQueryResult.userList.Count()), + ItemsPerPage = model.Count, TotalResults = usersListQueryResult.totalResults, - StartIndex = startIndex.GetValueOrDefault(1), + StartIndex = model.StartIndex, }; return Ok(scimListResponseModel); } diff --git a/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs b/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs new file mode 100644 index 000000000..27d7b6d9a --- /dev/null +++ b/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +public class GetUsersQueryParamModel +{ + public string Filter { get; init; } = string.Empty; + + [Range(1, int.MaxValue)] + public int Count { get; init; } = 50; + + [Range(1, int.MaxValue)] + public int StartIndex { get; init; } = 1; +} diff --git a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs index 1bea930f1..9bcbcbdaf 100644 --- a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs @@ -13,11 +13,16 @@ public class GetUsersListQuery : IGetUsersListQuery _organizationUserRepository = organizationUserRepository; } - public async Task<(IEnumerable userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex) + public async Task<(IEnumerable userList, int totalResults)> GetUsersListAsync(Guid organizationId, GetUsersQueryParamModel userQueryParams) { string emailFilter = null; string usernameFilter = null; string externalIdFilter = null; + + int count = userQueryParams.Count; + int startIndex = userQueryParams.StartIndex; + string filter = userQueryParams.Filter; + if (!string.IsNullOrWhiteSpace(filter)) { var filterLower = filter.ToLowerInvariant(); @@ -56,11 +61,11 @@ public class GetUsersListQuery : IGetUsersListQuery } totalResults = userList.Count; } - else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue) + else if (string.IsNullOrWhiteSpace(filter)) { userList = orgUsers.OrderBy(ou => ou.Email) - .Skip(startIndex.Value - 1) - .Take(count.Value) + .Skip(startIndex - 1) + .Take(count) .ToList(); totalResults = orgUsers.Count; } diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs index 265c6a8e7..f584cb8e7 100644 --- a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs @@ -4,5 +4,5 @@ namespace Bit.Scim.Users.Interfaces; public interface IGetUsersListQuery { - Task<(IEnumerable userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex); + Task<(IEnumerable userList, int totalResults)> GetUsersListAsync(Guid organizationId, GetUsersQueryParamModel userQueryParams); } diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs index c0e4f3eb7..1c9b0c112 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs @@ -236,6 +236,46 @@ public class UsersControllerTests : IClassFixture, IAsyn AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); } + [Fact] + public async Task GetList_SearchUserNameWithoutOptionalParameters_Success() + { + string filter = "userName eq user2@example.com"; + int? itemsPerPage = null; + int? startIndex = null; + var expectedResponse = new ScimListResponseModel + { + ItemsPerPage = 50, //default value + TotalResults = 1, + StartIndex = 1, //default value + Resources = new List + { + new ScimUserResponseModel + { + Id = ScimApplicationFactory.TestOrganizationUserId2, + DisplayName = "Test User 2", + ExternalId = "UB", + Active = true, + Emails = new List + { + new BaseScimUserModel.EmailModel { Primary = true, Type = "work", Value = "user2@example.com" } + }, + Groups = new List(), + Name = new BaseScimUserModel.NameModel("Test User 2"), + UserName = "user2@example.com", + Schemas = new List { ScimConstants.Scim2SchemaUser } + } + }, + Schemas = new List { ScimConstants.Scim2SchemaListResponse } + }; + + var context = await _factory.UsersGetListAsync(ScimApplicationFactory.TestOrganizationId1, filter, itemsPerPage, startIndex); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + var responseModel = JsonSerializer.Deserialize>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); + } + [Fact] public async Task Post_Success() { diff --git a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs index b7497e281..9352e5c20 100644 --- a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs @@ -24,7 +24,7 @@ public class GetUsersListQueryTests .GetManyDetailsByOrganizationAsync(organizationId) .Returns(organizationUserUserDetails); - var result = await sutProvider.Sut.GetUsersListAsync(organizationId, null, count, startIndex); + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Count = count, StartIndex = startIndex }); await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); @@ -49,7 +49,7 @@ public class GetUsersListQueryTests .GetManyDetailsByOrganizationAsync(organizationId) .Returns(organizationUserUserDetails); - var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Filter = filter }); await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); @@ -71,7 +71,7 @@ public class GetUsersListQueryTests .GetManyDetailsByOrganizationAsync(organizationId) .Returns(organizationUserUserDetails); - var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Filter = filter }); await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); @@ -96,7 +96,7 @@ public class GetUsersListQueryTests .GetManyDetailsByOrganizationAsync(organizationId) .Returns(organizationUserUserDetails); - var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Filter = filter }); await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); @@ -120,7 +120,7 @@ public class GetUsersListQueryTests .GetManyDetailsByOrganizationAsync(organizationId) .Returns(organizationUserUserDetails); - var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Filter = filter }); await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); diff --git a/dev/.gitignore b/dev/.gitignore index 6134bc261..39b657f45 100644 --- a/dev/.gitignore +++ b/dev/.gitignore @@ -5,7 +5,6 @@ secrets.json # Docker container configurations .env authsources.php -directory.ldif # Development certificates identity_server_dev.crt diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index fd316f8ea..c02d3c872 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -59,7 +59,7 @@ services: container_name: bw-mysql ports: - "3306:3306" - command: + command: - --default-authentication-plugin=mysql_native_password - --innodb-print-all-deadlocks=ON environment: @@ -84,20 +84,6 @@ services: profiles: - idp - open-ldap: - image: osixia/openldap:1.5.0 - command: --copy-service - environment: - LDAP_ORGANISATION: "Bitwarden" - LDAP_DOMAIN: "bitwarden.com" - volumes: - - ./directory.ldif:/container/service/slapd/assets/config/bootstrap/ldif/output.ldif - ports: - - "389:389" - - "636:636" - profiles: - - ldap - reverse-proxy: image: nginx:alpine container_name: reverse-proxy diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 70c09a539..efab8620c 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -11,7 +11,6 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.Exceptions; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -236,7 +235,8 @@ public class OrganizationsController : Controller if (organization.UseSecretsManager && !StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager) { - throw new BadRequestException("Plan does not support Secrets Manager"); + TempData["Error"] = "Plan does not support Secrets Manager"; + return RedirectToAction("Edit", new { id }); } await _organizationRepository.ReplaceAsync(organization); diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 04079138d..4ba22130f 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -181,7 +181,6 @@ public class OrganizationEditModel : OrganizationViewModel */ public object GetPlansHelper() => StaticStore.Plans - .Where(p => p.SupportsSecretsManager) .Select(p => { var plan = new diff --git a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml index db2e2c601..f3853e16a 100644 --- a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml @@ -1,4 +1,6 @@ -@model OrganizationViewModel +@inject Bit.Core.Services.IFeatureService FeatureService +@model OrganizationViewModel +
Id
@Model.Organization.Id
@@ -53,8 +55,19 @@
Administrators manage all collections
@(Model.Organization.AllowAdminAccessToAllCollectionItems ? "On" : "Off")
-
Limit collection creation to administrators
-
@(Model.Organization.LimitCollectionCreationDeletion ? "On" : "Off")
+ @if (!FeatureService.IsEnabled(Bit.Core.FeatureFlagKeys.LimitCollectionCreationDeletionSplit)) + { +
Limit collection creation to administrators
+
@(Model.Organization.LimitCollectionCreationDeletion ? "On" : "Off")
+ } + else + { +
Limit collection creation to administrators
+
@(Model.Organization.LimitCollectionCreation ? "On" : "Off")
+ +
Limit collection deletion to administrators
+
@(Model.Organization.LimitCollectionDeletion ? "On" : "Off")
+ }

Secrets Manager

diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index af7a162d8..b9afde272 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -101,7 +101,7 @@ public class OrganizationDomainController : Controller throw new NotFoundException(); } - organizationDomain = await _verifyOrganizationDomainCommand.VerifyOrganizationDomainAsync(organizationDomain); + organizationDomain = await _verifyOrganizationDomainCommand.UserVerifyOrganizationDomainAsync(organizationDomain); return new OrganizationDomainResponseModel(organizationDomain); } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 6bcf75b35..0b3811618 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -124,7 +124,11 @@ public class OrganizationsController : Controller var userId = _userService.GetProperUserId(User).Value; var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId, OrganizationUserStatusType.Confirmed); - var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o)); + + var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId); + var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); + + var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); return new ListResponseModel(responses); } @@ -516,9 +520,16 @@ public class OrganizationsController : Controller } [HttpPut("{id}/collection-management")] - [SelfHosted(NotSelfHostedOnly = true)] public async Task PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model) { + if ( + _globalSettings.SelfHosted && + !_featureService.IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) + ) + { + throw new BadRequestException("Only allowed when not self hosted."); + } + var organization = await _organizationRepository.GetByIdAsync(id); if (organization == null) { @@ -530,7 +541,7 @@ public class OrganizationsController : Controller throw new NotFoundException(); } - await _organizationService.UpdateAsync(model.ToOrganization(organization), eventType: EventType.Organization_CollectionManagement_Updated); + await _organizationService.UpdateAsync(model.ToOrganization(organization, _featureService), eventType: EventType.Organization_CollectionManagement_Updated); return new OrganizationResponseModel(organization); } } diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index b3be852db..7bfd13c40 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -25,7 +25,6 @@ public class PoliciesController : Controller { private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; - private readonly IOrganizationService _organizationService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IUserService _userService; private readonly ICurrentContext _currentContext; @@ -36,7 +35,6 @@ public class PoliciesController : Controller public PoliciesController( IPolicyRepository policyRepository, IPolicyService policyService, - IOrganizationService organizationService, IOrganizationUserRepository organizationUserRepository, IUserService userService, ICurrentContext currentContext, @@ -46,7 +44,6 @@ public class PoliciesController : Controller { _policyRepository = policyRepository; _policyService = policyService; - _organizationService = organizationService; _organizationUserRepository = organizationUserRepository; _userService = userService; _currentContext = currentContext; @@ -185,7 +182,7 @@ public class PoliciesController : Controller } var userId = _userService.GetProperUserId(User); - await _policyService.SaveAsync(policy, _organizationService, userId); + await _policyService.SaveAsync(policy, userId); return new PolicyResponseModel(policy); } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 08b4e4b06..7808b564a 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -55,6 +55,9 @@ public class OrganizationResponseModel : ResponseModel SmServiceAccounts = organization.SmServiceAccounts; MaxAutoscaleSmSeats = organization.MaxAutoscaleSmSeats; MaxAutoscaleSmServiceAccounts = organization.MaxAutoscaleSmServiceAccounts; + LimitCollectionCreation = organization.LimitCollectionCreation; + LimitCollectionDeletion = organization.LimitCollectionDeletion; + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; } @@ -98,6 +101,9 @@ public class OrganizationResponseModel : ResponseModel public int? SmServiceAccounts { get; set; } public int? MaxAutoscaleSmSeats { get; set; } public int? MaxAutoscaleSmServiceAccounts { get; set; } + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + // Deperectated: https://bitwarden.atlassian.net/browse/PM-10863 public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 17ebfc095..1fcaba5f9 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -15,7 +15,10 @@ public class ProfileOrganizationResponseModel : ResponseModel { public ProfileOrganizationResponseModel(string str) : base(str) { } - public ProfileOrganizationResponseModel(OrganizationUserOrganizationDetails organization) : this("profileOrganization") + public ProfileOrganizationResponseModel( + OrganizationUserOrganizationDetails organization, + IEnumerable organizationIdsManagingUser) + : this("profileOrganization") { Id = organization.OrganizationId; Name = organization.Name; @@ -62,8 +65,12 @@ public class ProfileOrganizationResponseModel : ResponseModel FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete; FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil; AccessSecretsManager = organization.AccessSecretsManager; + LimitCollectionCreation = organization.LimitCollectionCreation; + LimitCollectionDeletion = organization.LimitCollectionDeletion; + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; + UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId); if (organization.SsoConfig != null) { @@ -120,6 +127,20 @@ public class ProfileOrganizationResponseModel : ResponseModel public DateTime? FamilySponsorshipValidUntil { get; set; } public bool? FamilySponsorshipToDelete { get; set; } public bool AccessSecretsManager { get; set; } + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } + /// + /// Indicates if the organization manages the user. + /// + /// + /// An organization manages 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. + /// + /// + /// False if the Account Deprovisioning feature flag is disabled. + /// + public bool UserIsManagedByOrganization { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index 46819f886..92498834d 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -44,6 +44,9 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo ProviderId = organization.ProviderId; ProviderName = organization.ProviderName; ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier; + LimitCollectionCreation = organization.LimitCollectionCreation; + LimitCollectionDeletion = organization.LimitCollectionDeletion; + // https://bitwarden.atlassian.net/browse/PM-10863 LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; } diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index 2d83bd705..71e03a547 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -18,18 +17,15 @@ public class PoliciesController : Controller { private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; - private readonly IOrganizationService _organizationService; private readonly ICurrentContext _currentContext; public PoliciesController( IPolicyRepository policyRepository, IPolicyService policyService, - IOrganizationService organizationService, ICurrentContext currentContext) { _policyRepository = policyRepository; _policyService = policyService; - _organizationService = organizationService; _currentContext = currentContext; } @@ -96,7 +92,7 @@ public class PoliciesController : Controller { policy = model.ToPolicy(policy); } - await _policyService.SaveAsync(policy, _organizationService, null); + await _policyService.SaveAsync(policy, null); var response = new PolicyResponseModel(policy); return new JsonResult(response); } diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index cf74460fc..a0c01752a 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -443,11 +443,11 @@ public class AccountsController : Controller var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, twoFactorEnabled, - hasPremiumFromOrg, managedByOrganizationId); + hasPremiumFromOrg, organizationIdsManagingActiveUser); return response; } @@ -457,7 +457,9 @@ public class AccountsController : Controller var userId = _userService.GetProperUserId(User); var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value, OrganizationUserStatusType.Confirmed); - var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o)); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value); + + var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); return new ListResponseModel(responseData); } @@ -475,9 +477,9 @@ public class AccountsController : Controller var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); - var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, managedByOrganizationId); + var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser); return response; } @@ -494,9 +496,9 @@ public class AccountsController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); - var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId); + var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser); return response; } @@ -647,9 +649,9 @@ public class AccountsController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); - var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId); + var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser); return new PaymentResponseModel { UserProfile = profile, @@ -937,14 +939,9 @@ public class AccountsController : Controller } } - private async Task GetManagedByOrganizationIdAsync(User user) + private async Task> GetOrganizationIdsManagingUserAsync(Guid userId) { - if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - return null; - } - - var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id); - return organizationManagingUser?.Id; + var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId); + return organizationManagingUser.Select(o => o.Id); } } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 5371186b1..75ae2fb89 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -201,7 +201,10 @@ public class OrganizationsController( var organizationDetails = await organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed); - return new ProfileOrganizationResponseModel(organizationDetails); + var organizationManagingActiveUser = await userService.GetOrganizationsManagingUserAsync(userId); + var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); + + return new ProfileOrganizationResponseModel(organizationDetails, organizationIdsManagingActiveUser); } [HttpPost("{id:guid}/seat")] diff --git a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs index 624eab1fe..b5f9ab2f5 100644 --- a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs +++ b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs @@ -3,8 +3,11 @@ namespace Bit.Api.Billing.Models.Responses; public record OrganizationMetadataResponse( + bool IsEligibleForSelfHost, bool IsOnSecretsManagerStandalone) { public static OrganizationMetadataResponse From(OrganizationMetadata metadata) - => new(metadata.IsOnSecretsManagerStandalone); + => new( + metadata.IsEligibleForSelfHost, + metadata.IsOnSecretsManagerStandalone); } diff --git a/src/Api/Controllers/PushController.cs b/src/Api/Controllers/PushController.cs index c83eb200b..383980510 100644 --- a/src/Api/Controllers/PushController.cs +++ b/src/Api/Controllers/PushController.cs @@ -46,7 +46,7 @@ public class PushController : Controller public async Task PostDelete([FromBody] PushDeviceRequestModel model) { CheckUsage(); - await _pushRegistrationService.DeleteRegistrationAsync(Prefix(model.Id), model.Type); + await _pushRegistrationService.DeleteRegistrationAsync(Prefix(model.Id)); } [HttpPut("add-organization")] @@ -54,7 +54,7 @@ public class PushController : Controller { CheckUsage(); await _pushRegistrationService.AddUserRegistrationOrganizationAsync( - model.Devices.Select(d => new KeyValuePair(Prefix(d.Id), d.Type)), + model.Devices.Select(d => Prefix(d.Id)), Prefix(model.OrganizationId)); } @@ -63,7 +63,7 @@ public class PushController : Controller { CheckUsage(); await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync( - model.Devices.Select(d => new KeyValuePair(Prefix(d.Id), d.Type)), + model.Devices.Select(d => Prefix(d.Id)), Prefix(model.OrganizationId)); } diff --git a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs index 68e87b522..a5a6f1f74 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs @@ -1,15 +1,29 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Services; namespace Bit.Api.Models.Request.Organizations; public class OrganizationCollectionManagementUpdateRequestModel { + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 public bool LimitCreateDeleteOwnerAdmin { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } - public virtual Organization ToOrganization(Organization existingOrganization) + public virtual Organization ToOrganization(Organization existingOrganization, IFeatureService featureService) { - existingOrganization.LimitCollectionCreationDeletion = LimitCreateDeleteOwnerAdmin; + if (featureService.IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit)) + { + existingOrganization.LimitCollectionCreation = LimitCollectionCreation; + existingOrganization.LimitCollectionDeletion = LimitCollectionDeletion; + } + else + { + existingOrganization.LimitCollectionCreationDeletion = LimitCreateDeleteOwnerAdmin || LimitCollectionCreation || LimitCollectionDeletion; + } + existingOrganization.AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems; return existingOrganization; } diff --git a/src/Api/Models/Response/ProfileResponseModel.cs b/src/Api/Models/Response/ProfileResponseModel.cs index f5d0382e5..a6ed4ebfa 100644 --- a/src/Api/Models/Response/ProfileResponseModel.cs +++ b/src/Api/Models/Response/ProfileResponseModel.cs @@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel IEnumerable providerUserOrganizationDetails, bool twoFactorEnabled, bool premiumFromOrganization, - Guid? managedByOrganizationId) : base("profile") + IEnumerable organizationIdsManagingUser) : base("profile") { if (user == null) { @@ -37,11 +37,10 @@ public class ProfileResponseModel : ResponseModel UsesKeyConnector = user.UsesKeyConnector; AvatarColor = user.AvatarColor; CreationDate = user.CreationDate; - Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o)); + Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); ProviderOrganizations = providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po)); - ManagedByOrganizationId = managedByOrganizationId; } public ProfileResponseModel() : base("profile") @@ -63,7 +62,6 @@ public class ProfileResponseModel : ResponseModel public bool UsesKeyConnector { get; set; } public string AvatarColor { get; set; } public DateTime CreationDate { get; set; } - public Guid? ManagedByOrganizationId { get; set; } public IEnumerable Organizations { get; set; } public IEnumerable Providers { get; set; } public IEnumerable ProviderOrganizations { get; set; } diff --git a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs index 5d11b39ea..c26d5b595 100644 --- a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs @@ -1,5 +1,6 @@ #nullable enable using System.Diagnostics; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -101,7 +102,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler o.Id); var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, - managedByOrganizationId, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, + organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); return response; } - - private async Task GetManagedByOrganizationIdAsync(User user, IEnumerable organizationUserDetails) - { - if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) || - !organizationUserDetails.Any(o => o.Enabled && o.UseSso)) - { - return null; - } - - var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id); - return organizationManagingUser?.Id; - } } diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index 2170a5232..ce5f4562d 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -21,7 +21,7 @@ public class SyncResponseModel : ResponseModel User user, bool userTwoFactorEnabled, bool userHasPremiumFromOrganization, - Guid? managedByOrganizationId, + IEnumerable organizationIdsManagingUser, IEnumerable organizationUserDetails, IEnumerable providerUserDetails, IEnumerable providerUserOrganizationDetails, @@ -35,7 +35,7 @@ public class SyncResponseModel : ResponseModel : base("sync") { Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, - providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId); + providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser); Folders = folders.Select(f => new FolderResponseModel(f)); Ciphers = ciphers.Select(c => new CipherDetailsResponseModel(c, globalSettings, collectionCiphersDict)); Collections = collections?.Select( diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 547db9076..c556dfe60 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -7,6 +7,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; +using Bit.Core.Services; using Bit.Core.Tools.Entities; using Bit.Core.Utilities; @@ -93,7 +94,20 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, /// If set to false, any organization member can create a collection, and any member can delete a collection that /// they have Can Manage permissions for. /// - public bool LimitCollectionCreationDeletion { get; set; } + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + // Deprecated by https://bitwarden.atlassian.net/browse/PM-10863. This + // was replaced with `LimitCollectionCreation` and + // `LimitCollectionDeletion`. + public bool LimitCollectionCreationDeletion + { + get => LimitCollectionCreation || LimitCollectionDeletion; + set + { + LimitCollectionCreation = value; + LimitCollectionDeletion = value; + } + } /// /// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console. @@ -265,7 +279,7 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, return providers[provider]; } - public void UpdateFromLicense(OrganizationLicense license) + public void UpdateFromLicense(OrganizationLicense license, IFeatureService featureService) { // The following properties are intentionally excluded from being updated: // - Id - self-hosted org will have its own unique Guid @@ -300,7 +314,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, UseSecretsManager = license.UseSecretsManager; SmSeats = license.SmSeats; SmServiceAccounts = license.SmServiceAccounts; - LimitCollectionCreationDeletion = license.LimitCollectionCreationDeletion; - AllowAdminAccessToAllCollectionItems = license.AllowAdminAccessToAllCollectionItems; + + if (!featureService.IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit)) + { + LimitCollectionCreationDeletion = license.LimitCollectionCreationDeletion; + AllowAdminAccessToAllCollectionItems = license.AllowAdminAccessToAllCollectionItems; + } } } diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index 0e1786cf5..bdde3e424 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -16,3 +16,30 @@ public enum PolicyType : byte ActivateAutofill = 11, AutomaticAppLogIn = 12, } + +public static class PolicyTypeExtensions +{ + /// + /// Returns the name of the policy for display to the user. + /// Do not include the word "policy" in the return value. + /// + public static string GetName(this PolicyType type) + { + return type switch + { + PolicyType.TwoFactorAuthentication => "Require two-step login", + PolicyType.MasterPassword => "Master password requirements", + PolicyType.PasswordGenerator => "Password generator", + PolicyType.SingleOrg => "Single organization", + PolicyType.RequireSso => "Require single sign-on authentication", + PolicyType.PersonalOwnership => "Remove individual vault", + PolicyType.DisableSend => "Remove Send", + PolicyType.SendOptions => "Send options", + PolicyType.ResetPassword => "Account recovery administration", + PolicyType.MaximumVaultTimeout => "Vault timeout", + PolicyType.DisablePersonalVaultExport => "Remove individual vault export", + PolicyType.ActivateAutofill => "Active auto-fill", + PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications", + }; + } +} diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs index 07db80d43..a91b96083 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs @@ -21,6 +21,9 @@ public class OrganizationAbility UseResetPassword = organization.UseResetPassword; UseCustomPermissions = organization.UseCustomPermissions; UsePolicies = organization.UsePolicies; + LimitCollectionCreation = organization.LimitCollectionCreation; + LimitCollectionDeletion = organization.LimitCollectionDeletion; + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; } @@ -37,6 +40,9 @@ public class OrganizationAbility public bool UseResetPassword { get; set; } public bool UseCustomPermissions { get; set; } public bool UsePolicies { get; set; } + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index cdd73cba7..435369e77 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -54,6 +54,9 @@ public class OrganizationUserOrganizationDetails public bool UsePasswordManager { get; set; } public int? SmSeats { get; set; } public int? SmServiceAccounts { get; set; } + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index d21ba9183..1fa547d98 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -144,6 +144,9 @@ public class SelfHostedOrganizationDetails : Organization RevisionDate = RevisionDate, MaxAutoscaleSeats = MaxAutoscaleSeats, OwnersNotifiedOfAutoscaling = OwnersNotifiedOfAutoscaling, + LimitCollectionCreation = LimitCollectionCreation, + LimitCollectionDeletion = LimitCollectionDeletion, + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 LimitCollectionCreationDeletion = LimitCollectionCreationDeletion, AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems, Status = Status diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index d3d831f51..a2ac62253 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -40,6 +40,8 @@ public class ProviderUserOrganizationDetails [JsonConverter(typeof(HtmlEncodingStringConverter))] public string ProviderName { get; set; } public PlanType PlanType { get; set; } + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommand.cs index be8ed0e64..192ab3b79 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/CreateOrganizationDomainCommand.cs @@ -6,7 +6,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; -using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; @@ -14,21 +13,15 @@ public class CreateOrganizationDomainCommand : ICreateOrganizationDomainCommand { private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IEventService _eventService; - private readonly IDnsResolverService _dnsResolverService; - private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; public CreateOrganizationDomainCommand( IOrganizationDomainRepository organizationDomainRepository, IEventService eventService, - IDnsResolverService dnsResolverService, - ILogger logger, IGlobalSettings globalSettings) { _organizationDomainRepository = organizationDomainRepository; _eventService = eventService; - _dnsResolverService = dnsResolverService; - _logger = logger; _globalSettings = globalSettings; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/Interfaces/IVerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/Interfaces/IVerifyOrganizationDomainCommand.cs index b62a9f932..6b5beb11f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/Interfaces/IVerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/Interfaces/IVerifyOrganizationDomainCommand.cs @@ -4,5 +4,6 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfa public interface IVerifyOrganizationDomainCommand { - Task VerifyOrganizationDomainAsync(OrganizationDomain organizationDomain); + Task UserVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain); + Task SystemVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 5f9476db8..8e1a4d573 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; @@ -13,34 +14,85 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IDnsResolverService _dnsResolverService; private readonly IEventService _eventService; + private readonly IGlobalSettings _globalSettings; private readonly ILogger _logger; public VerifyOrganizationDomainCommand( IOrganizationDomainRepository organizationDomainRepository, IDnsResolverService dnsResolverService, IEventService eventService, + IGlobalSettings globalSettings, ILogger logger) { _organizationDomainRepository = organizationDomainRepository; _dnsResolverService = dnsResolverService; _eventService = eventService; + _globalSettings = globalSettings; _logger = logger; } - public async Task VerifyOrganizationDomainAsync(OrganizationDomain domain) + + public async Task UserVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain) { + var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain); + + await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, + domainVerificationResult.VerifiedDate != null + ? EventType.OrganizationDomain_Verified + : EventType.OrganizationDomain_NotVerified); + + await _organizationDomainRepository.ReplaceAsync(domainVerificationResult); + + return domainVerificationResult; + } + + public async Task SystemVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain) + { + organizationDomain.SetJobRunCount(); + + var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain); + + if (domainVerificationResult.VerifiedDate is not null) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain"); + + await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, + EventType.OrganizationDomain_Verified, + EventSystemUser.DomainVerification); + } + else + { + domainVerificationResult.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval); + + await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, + EventType.OrganizationDomain_NotVerified, + EventSystemUser.DomainVerification); + + _logger.LogInformation(Constants.BypassFiltersEventId, + "Verification for organization {OrgId} with domain {Domain} failed", + domainVerificationResult.OrganizationId, domainVerificationResult.DomainName); + } + + await _organizationDomainRepository.ReplaceAsync(domainVerificationResult); + + return domainVerificationResult; + } + + private async Task VerifyOrganizationDomainAsync(OrganizationDomain domain) + { + domain.SetLastCheckedDate(); + if (domain.VerifiedDate is not null) { - domain.SetLastCheckedDate(); await _organizationDomainRepository.ReplaceAsync(domain); throw new ConflictException("Domain has already been verified."); } var claimedDomain = await _organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName); - if (claimedDomain.Any()) + + if (claimedDomain.Count > 0) { - domain.SetLastCheckedDate(); await _organizationDomainRepository.ReplaceAsync(domain); throw new ConflictException("The domain is not available to be claimed."); } @@ -58,11 +110,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand domain.DomainName, e.Message); } - domain.SetLastCheckedDate(); - await _organizationDomainRepository.ReplaceAsync(domain); - - await _eventService.LogOrganizationDomainEventAsync(domain, - domain.VerifiedDate != null ? EventType.OrganizationDomain_Verified : EventType.OrganizationDomain_NotVerified); return domain; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index 09444306e..e6d56ea87 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -162,12 +162,12 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand } } - private async Task>> GetUserDeviceIdsAsync(Guid userId) + private async Task> GetUserDeviceIdsAsync(Guid userId) { var devices = await _deviceRepository.GetManyByUserIdAsync(userId); return devices .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) - .Select(d => new KeyValuePair(d.Id.ToString(), d.Type)); + .Select(d => d.Id.ToString()); } private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs new file mode 100644 index 000000000..6aef9f248 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs @@ -0,0 +1,43 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +/// +/// Defines behavior and functionality for a given PolicyType. +/// +public interface IPolicyValidator +{ + /// + /// The PolicyType that this definition relates to. + /// + public PolicyType Type { get; } + + /// + /// PolicyTypes that must be enabled before this policy can be enabled, if any. + /// These dependencies will be checked when this policy is enabled and when any required policy is disabled. + /// + public IEnumerable RequiredPolicies { get; } + + /// + /// Validates a policy before saving it. + /// Do not use this for simple dependencies between different policies - see instead. + /// Implementation is optional; by default it will not perform any validation. + /// + /// The policy update request + /// The current policy, if any + /// A validation error if validation was unsuccessful, otherwise an empty string + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy); + + /// + /// Performs side effects after a policy is validated but before it is saved. + /// For example, this can be used to remove non-compliant users from the organization. + /// Implementation is optional; by default it will not perform any side effects. + /// + /// The policy update request + /// The current policy, if any + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs new file mode 100644 index 000000000..5bfdfc6aa --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public interface ISavePolicyCommand +{ + Task SaveAsync(PolicyUpdate policy); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs new file mode 100644 index 000000000..01ffce2cc --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -0,0 +1,129 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; + +public class SavePolicyCommand : ISavePolicyCommand +{ + private readonly IApplicationCacheService _applicationCacheService; + private readonly IEventService _eventService; + private readonly IPolicyRepository _policyRepository; + private readonly IReadOnlyDictionary _policyValidators; + private readonly TimeProvider _timeProvider; + + public SavePolicyCommand( + IApplicationCacheService applicationCacheService, + IEventService eventService, + IPolicyRepository policyRepository, + IEnumerable policyValidators, + TimeProvider timeProvider) + { + _applicationCacheService = applicationCacheService; + _eventService = eventService; + _policyRepository = policyRepository; + _timeProvider = timeProvider; + + var policyValidatorsDict = new Dictionary(); + foreach (var policyValidator in policyValidators) + { + if (!policyValidatorsDict.TryAdd(policyValidator.Type, policyValidator)) + { + throw new Exception($"Duplicate PolicyValidator for {policyValidator.Type} policy."); + } + } + + _policyValidators = policyValidatorsDict; + } + + public async Task SaveAsync(PolicyUpdate policyUpdate) + { + var org = await _applicationCacheService.GetOrganizationAbilityAsync(policyUpdate.OrganizationId); + if (org == null) + { + throw new BadRequestException("Organization not found"); + } + + if (!org.UsePolicies) + { + throw new BadRequestException("This organization cannot use policies."); + } + + if (_policyValidators.TryGetValue(policyUpdate.Type, out var validator)) + { + await RunValidatorAsync(validator, policyUpdate); + } + + var policy = await _policyRepository.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + ?? new Policy + { + OrganizationId = policyUpdate.OrganizationId, + Type = policyUpdate.Type, + CreationDate = _timeProvider.GetUtcNow().UtcDateTime + }; + + policy.Enabled = policyUpdate.Enabled; + policy.Data = policyUpdate.Data; + policy.RevisionDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _policyRepository.UpsertAsync(policy); + await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); + } + + private async Task RunValidatorAsync(IPolicyValidator validator, PolicyUpdate policyUpdate) + { + var savedPolicies = await _policyRepository.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId); + // Note: policies may be missing from this dict if they have never been enabled + var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); + var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); + + // If enabling this policy - check that all policy requirements are satisfied + if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled) + { + var missingRequiredPolicyTypes = validator.RequiredPolicies + .Where(requiredPolicyType => + savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true }) + .ToList(); + + if (missingRequiredPolicyTypes.Count != 0) + { + throw new BadRequestException($"Turn on the {missingRequiredPolicyTypes.First().GetName()} policy because it is required for the {validator.Type.GetName()} policy."); + } + } + + // If disabling this policy - ensure it's not required by any other policy + if (currentPolicy is { Enabled: true } && !policyUpdate.Enabled) + { + var dependentPolicyTypes = _policyValidators.Values + .Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyUpdate.Type)) + .Select(otherValidator => otherValidator.Type) + .Where(otherPolicyType => savedPoliciesDict.ContainsKey(otherPolicyType) && + savedPoliciesDict[otherPolicyType].Enabled) + .ToList(); + + switch (dependentPolicyTypes) + { + case { Count: 1 }: + throw new BadRequestException($"Turn off the {dependentPolicyTypes.First().GetName()} policy because it requires the {validator.Type.GetName()} policy."); + case { Count: > 1 }: + throw new BadRequestException($"Turn off all of the policies that require the {validator.Type.GetName()} policy."); + } + } + + // Run other validation + var validationError = await validator.ValidateAsync(policyUpdate, currentPolicy); + if (!string.IsNullOrEmpty(validationError)) + { + throw new BadRequestException(validationError); + } + + // Run side effects + await validator.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs new file mode 100644 index 000000000..117a7ec73 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs @@ -0,0 +1,28 @@ +#nullable enable + +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +/// +/// A request for SavePolicyCommand to update a policy +/// +public record PolicyUpdate +{ + public Guid OrganizationId { get; set; } + public PolicyType Type { get; set; } + public string? Data { get; set; } + public bool Enabled { get; set; } + + public T GetDataModel() where T : IPolicyDataModel, new() + { + return CoreHelpers.LoadClassFromJsonData(Data); + } + + public void SetDataModel(T dataModel) where T : IPolicyDataModel, new() + { + Data = CoreHelpers.ClassToJsonData(dataModel); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs new file mode 100644 index 000000000..81096ef60 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.Services.Implementations; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public static class PolicyServiceCollectionExtensions +{ + public static void AddPolicyServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/MaximumVaultTimeoutPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/MaximumVaultTimeoutPolicyValidator.cs new file mode 100644 index 000000000..bfd4dcfe0 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/MaximumVaultTimeoutPolicyValidator.cs @@ -0,0 +1,15 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class MaximumVaultTimeoutPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.MaximumVaultTimeout; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/PolicyValidatorHelpers.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/PolicyValidatorHelpers.cs new file mode 100644 index 000000000..1bbaf1aa1 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/PolicyValidatorHelpers.cs @@ -0,0 +1,33 @@ +#nullable enable + +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public static class PolicyValidatorHelpers +{ + /// + /// Validate that given Member Decryption Options are not enabled. + /// Used for validation when disabling a policy that is required by certain Member Decryption Options. + /// + /// The Member Decryption Options that require the policy to be enabled. + /// A validation error if validation was unsuccessful, otherwise an empty string + public static string ValidateDecryptionOptionsNotEnabled(this SsoConfig? ssoConfig, + MemberDecryptionType[] decryptionOptions) + { + if (ssoConfig is not { Enabled: true }) + { + return ""; + } + + return ssoConfig.GetData().MemberDecryptionType switch + { + MemberDecryptionType.KeyConnector when decryptionOptions.Contains(MemberDecryptionType.KeyConnector) + => "Key Connector is enabled and requires this policy.", + MemberDecryptionType.TrustedDeviceEncryption when decryptionOptions.Contains(MemberDecryptionType + .TrustedDeviceEncryption) => "Trusted device encryption is on and requires this policy.", + _ => "" + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidator.cs new file mode 100644 index 000000000..2082d4305 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidator.cs @@ -0,0 +1,38 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class RequireSsoPolicyValidator : IPolicyValidator +{ + private readonly ISsoConfigRepository _ssoConfigRepository; + + public RequireSsoPolicyValidator(ISsoConfigRepository ssoConfigRepository) + { + _ssoConfigRepository = ssoConfigRepository; + } + + public PolicyType Type => PolicyType.RequireSso; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (policyUpdate is not { Enabled: true }) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); + return ssoConfig.ValidateDecryptionOptionsNotEnabled([ + MemberDecryptionType.KeyConnector, + MemberDecryptionType.TrustedDeviceEncryption + ]); + } + + return ""; + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidator.cs new file mode 100644 index 000000000..1126c4b92 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidator.cs @@ -0,0 +1,36 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class ResetPasswordPolicyValidator : IPolicyValidator +{ + private readonly ISsoConfigRepository _ssoConfigRepository; + public PolicyType Type => PolicyType.ResetPassword; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + + public ResetPasswordPolicyValidator(ISsoConfigRepository ssoConfigRepository) + { + _ssoConfigRepository = ssoConfigRepository; + } + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (policyUpdate is not { Enabled: true } || + policyUpdate.GetDataModel().AutoEnrollEnabled == false) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); + return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.TrustedDeviceEncryption]); + } + + return ""; + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs new file mode 100644 index 000000000..3e1f8d26c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs @@ -0,0 +1,101 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class SingleOrgPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.SingleOrg; + + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly ICurrentContext _currentContext; + private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + + public SingleOrgPolicyValidator( + IOrganizationUserRepository organizationUserRepository, + IMailService mailService, + IOrganizationRepository organizationRepository, + ISsoConfigRepository ssoConfigRepository, + ICurrentContext currentContext, + IRemoveOrganizationUserCommand removeOrganizationUserCommand) + { + _organizationUserRepository = organizationUserRepository; + _mailService = mailService; + _organizationRepository = organizationRepository; + _ssoConfigRepository = ssoConfigRepository; + _currentContext = currentContext; + _removeOrganizationUserCommand = removeOrganizationUserCommand; + } + + public IEnumerable RequiredPolicies => []; + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) + { + await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); + } + } + + private async Task RemoveNonCompliantUsersAsync(Guid organizationId) + { + // Remove non-compliant users + var savingUserId = _currentContext.UserId; + // Note: must get OrganizationUserUserDetails so that Email is always populated from the User object + var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var org = await _organizationRepository.GetByIdAsync(organizationId); + if (org == null) + { + throw new NotFoundException("Organization not found."); + } + + var removableOrgUsers = orgUsers.Where(ou => + ou.Status != OrganizationUserStatusType.Invited && + ou.Status != OrganizationUserStatusType.Revoked && + ou.Type != OrganizationUserType.Owner && + ou.Type != OrganizationUserType.Admin && + ou.UserId != savingUserId + ).ToList(); + + var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync( + removableOrgUsers.Select(ou => ou.UserId!.Value)); + foreach (var orgUser in removableOrgUsers) + { + if (userOrgs.Any(ou => ou.UserId == orgUser.UserId + && ou.OrganizationId != org.Id + && ou.Status != OrganizationUserStatusType.Invited)) + { + await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, + savingUserId); + + await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync( + org.DisplayName(), orgUser.Email); + } + } + } + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (policyUpdate is not { Enabled: true }) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); + return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + } + + return ""; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs new file mode 100644 index 000000000..ef896bbb9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs @@ -0,0 +1,87 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly ICurrentContext _currentContext; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + + public PolicyType Type => PolicyType.TwoFactorAuthentication; + public IEnumerable RequiredPolicies => []; + + public TwoFactorAuthenticationPolicyValidator( + IOrganizationUserRepository organizationUserRepository, + IMailService mailService, + IOrganizationRepository organizationRepository, + ICurrentContext currentContext, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IRemoveOrganizationUserCommand removeOrganizationUserCommand) + { + _organizationUserRepository = organizationUserRepository; + _mailService = mailService; + _organizationRepository = organizationRepository; + _currentContext = currentContext; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _removeOrganizationUserCommand = removeOrganizationUserCommand; + } + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) + { + await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); + } + } + + private async Task RemoveNonCompliantUsersAsync(Guid organizationId) + { + var org = await _organizationRepository.GetByIdAsync(organizationId); + var savingUserId = _currentContext.UserId; + + var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); + var removableOrgUsers = orgUsers.Where(ou => + ou.Status != OrganizationUserStatusType.Invited && ou.Status != OrganizationUserStatusType.Revoked && + ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Admin && + ou.UserId != savingUserId); + + // Reorder by HasMasterPassword to prioritize checking users without a master if they have 2FA enabled + foreach (var orgUser in removableOrgUsers.OrderBy(ou => ou.HasMasterPassword)) + { + var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == orgUser.Id) + .twoFactorIsEnabled; + if (!userTwoFactorEnabled) + { + if (!orgUser.HasMasterPassword) + { + throw new BadRequestException( + "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page."); + } + + await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, + savingUserId); + + await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync( + org!.DisplayName(), orgUser.Email); + } + } + } + + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 9c14c4fbd..5b274d3f8 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -19,7 +19,7 @@ public interface IOrganizationRepository : IRepository Task> GetOwnerEmailAddressesById(Guid organizationId); /// - /// Gets the organization that has a claimed domain matching the user's email domain. + /// Gets the organizations that have a verified domain matching the user's email domain. /// - Task GetByClaimedUserDomainAsync(Guid userId); + Task> GetByVerifiedUserEmailDomainAsync(Guid userId); } diff --git a/src/Core/AdminConsole/Services/IPolicyService.cs b/src/Core/AdminConsole/Services/IPolicyService.cs index 6d92a3a4f..16ff2f4fa 100644 --- a/src/Core/AdminConsole/Services/IPolicyService.cs +++ b/src/Core/AdminConsole/Services/IPolicyService.cs @@ -4,13 +4,12 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Services; namespace Bit.Core.AdminConsole.Services; public interface IPolicyService { - Task SaveAsync(Policy policy, IOrganizationService organizationService, Guid? savingUserId); + Task SaveAsync(Policy policy, Guid? savingUserId); /// /// Get the combined master password policy options for the specified user. diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs index b526f27d9..890042b31 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -10,26 +11,29 @@ public class OrganizationDomainService : IOrganizationDomainService { private readonly IOrganizationDomainRepository _domainRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IDnsResolverService _dnsResolverService; private readonly IEventService _eventService; private readonly IMailService _mailService; + private readonly IVerifyOrganizationDomainCommand _verifyOrganizationDomainCommand; + private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; public OrganizationDomainService( IOrganizationDomainRepository domainRepository, IOrganizationUserRepository organizationUserRepository, - IDnsResolverService dnsResolverService, IEventService eventService, IMailService mailService, + IVerifyOrganizationDomainCommand verifyOrganizationDomainCommand, + TimeProvider timeProvider, ILogger logger, IGlobalSettings globalSettings) { _domainRepository = domainRepository; _organizationUserRepository = organizationUserRepository; - _dnsResolverService = dnsResolverService; _eventService = eventService; _mailService = mailService; + _verifyOrganizationDomainCommand = verifyOrganizationDomainCommand; + _timeProvider = timeProvider; _logger = logger; _globalSettings = globalSettings; } @@ -37,7 +41,7 @@ public class OrganizationDomainService : IOrganizationDomainService public async Task ValidateOrganizationsDomainAsync() { //Date should be set 1 hour behind to ensure it selects all domains that should be verified - var runDate = DateTime.UtcNow.AddHours(-1); + var runDate = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-1); var verifiableDomains = await _domainRepository.GetManyByNextRunDateAsync(runDate); @@ -45,43 +49,17 @@ public class OrganizationDomainService : IOrganizationDomainService foreach (var domain in verifiableDomains) { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Attempting verification for organization {OrgId} with domain {Domain}", + domain.OrganizationId, + domain.DomainName); + try { - _logger.LogInformation(Constants.BypassFiltersEventId, "Attempting verification for organization {OrgId} with domain {Domain}", domain.OrganizationId, domain.DomainName); - - var status = await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt); - if (status) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain"); - - // Update entry on OrganizationDomain table - domain.SetLastCheckedDate(); - domain.SetVerifiedDate(); - domain.SetJobRunCount(); - await _domainRepository.ReplaceAsync(domain); - - await _eventService.LogOrganizationDomainEventAsync(domain, EventType.OrganizationDomain_Verified, - EventSystemUser.DomainVerification); - } - else - { - // Update entry on OrganizationDomain table - domain.SetLastCheckedDate(); - domain.SetJobRunCount(); - domain.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval); - await _domainRepository.ReplaceAsync(domain); - - await _eventService.LogOrganizationDomainEventAsync(domain, EventType.OrganizationDomain_NotVerified, - EventSystemUser.DomainVerification); - _logger.LogInformation(Constants.BypassFiltersEventId, "Verification for organization {OrgId} with domain {Domain} failed", - domain.OrganizationId, domain.DomainName); - } + _ = await _verifyOrganizationDomainCommand.SystemVerifyOrganizationDomainAsync(domain); } catch (Exception ex) { - // Update entry on OrganizationDomain table - domain.SetLastCheckedDate(); - domain.SetJobRunCount(); domain.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval); await _domainRepository.ReplaceAsync(domain); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f453a22b4..f44ce686f 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -708,10 +708,16 @@ public class OrganizationService : IOrganizationService UseSecretsManager = license.UseSecretsManager, SmSeats = license.SmSeats, SmServiceAccounts = license.SmServiceAccounts, - LimitCollectionCreationDeletion = license.LimitCollectionCreationDeletion, - AllowAdminAccessToAllCollectionItems = license.AllowAdminAccessToAllCollectionItems, }; + // These fields are being removed from consideration when processing + // licenses. + if (!_featureService.IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit)) + { + organization.LimitCollectionCreationDeletion = license.LimitCollectionCreationDeletion; + organization.AllowAdminAccessToAllCollectionItems = license.AllowAdminAccessToAllCollectionItems; + } + var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); var dir = $"{_globalSettings.LicenseDirectory}/organization"; @@ -1832,12 +1838,12 @@ public class OrganizationService : IOrganizationService } - private async Task>> GetUserDeviceIdsAsync(Guid userId) + private async Task> GetUserDeviceIdsAsync(Guid userId) { var devices = await _deviceRepository.GetManyByUserIdAsync(userId); return devices .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) - .Select(d => new KeyValuePair(d.Id.ToString(), d.Type)); + .Select(d => d.Id.ToString()); } public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index 7e689f034..6ab90afe0 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -2,6 +2,8 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -27,6 +29,8 @@ public class PolicyService : IPolicyService private readonly IMailService _mailService; private readonly GlobalSettings _globalSettings; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IFeatureService _featureService; + private readonly ISavePolicyCommand _savePolicyCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; public PolicyService( @@ -39,6 +43,8 @@ public class PolicyService : IPolicyService IMailService mailService, GlobalSettings globalSettings, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IFeatureService featureService, + ISavePolicyCommand savePolicyCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand) { _applicationCacheService = applicationCacheService; @@ -50,11 +56,28 @@ public class PolicyService : IPolicyService _mailService = mailService; _globalSettings = globalSettings; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _featureService = featureService; + _savePolicyCommand = savePolicyCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand; } - public async Task SaveAsync(Policy policy, IOrganizationService organizationService, Guid? savingUserId) + public async Task SaveAsync(Policy policy, Guid? savingUserId) { + if (_featureService.IsEnabled(FeatureFlagKeys.Pm13322AddPolicyDefinitions)) + { + // Transitional mapping - this will be moved to callers once the feature flag is removed + var policyUpdate = new PolicyUpdate + { + OrganizationId = policy.OrganizationId, + Type = policy.Type, + Enabled = policy.Enabled, + Data = policy.Data + }; + + await _savePolicyCommand.SaveAsync(policyUpdate); + return; + } + var org = await _organizationRepository.GetByIdAsync(policy.OrganizationId); if (org == null) { @@ -88,7 +111,7 @@ public class PolicyService : IPolicyService return; } - await EnablePolicyAsync(policy, org, organizationService, savingUserId); + await EnablePolicyAsync(policy, org, savingUserId); } public async Task GetMasterPasswordPolicyForUserAsync(User user) @@ -262,7 +285,7 @@ public class PolicyService : IPolicyService await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); } - private async Task EnablePolicyAsync(Policy policy, Organization org, IOrganizationService organizationService, Guid? savingUserId) + private async Task EnablePolicyAsync(Policy policy, Organization org, Guid? savingUserId) { var currentPolicy = await _policyRepository.GetByIdAsync(policy.Id); if (!currentPolicy?.Enabled ?? true) diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index fdf7e278e..532f00039 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -20,7 +20,6 @@ public class SsoConfigService : ISsoConfigService private readonly IPolicyService _policyService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IOrganizationService _organizationService; private readonly IEventService _eventService; public SsoConfigService( @@ -29,7 +28,6 @@ public class SsoConfigService : ISsoConfigService IPolicyService policyService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - IOrganizationService organizationService, IEventService eventService) { _ssoConfigRepository = ssoConfigRepository; @@ -37,7 +35,6 @@ public class SsoConfigService : ISsoConfigService _policyService = policyService; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; - _organizationService = organizationService; _eventService = eventService; } @@ -71,20 +68,20 @@ public class SsoConfigService : ISsoConfigService singleOrgPolicy.Enabled = true; - await _policyService.SaveAsync(singleOrgPolicy, _organizationService, null); + await _policyService.SaveAsync(singleOrgPolicy, null); var resetPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.ResetPassword) ?? new Policy { OrganizationId = config.OrganizationId, Type = PolicyType.ResetPassword, }; resetPolicy.Enabled = true; resetPolicy.SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); - await _policyService.SaveAsync(resetPolicy, _organizationService, null); + await _policyService.SaveAsync(resetPolicy, null); var ssoRequiredPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso) ?? new Policy { OrganizationId = config.OrganizationId, Type = PolicyType.RequireSso, }; ssoRequiredPolicy.Enabled = true; - await _policyService.SaveAsync(ssoRequiredPolicy, _organizationService, null); + await _policyService.SaveAsync(ssoRequiredPolicy, null); } await LogEventsAsync(config, oldConfig); diff --git a/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs b/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs index b6a58f82f..7bfef8a93 100644 --- a/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs +++ b/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs @@ -3,17 +3,14 @@ public enum ProviderMigrationProgress { Started = 1, - ClientsMigrated = 2, - TeamsPlanConfigured = 3, - EnterprisePlanConfigured = 4, - CustomerSetup = 5, - SubscriptionSetup = 6, - CreditApplied = 7, - Completed = 8, - - Reversing = 9, - ReversedClientMigrations = 10, - RemovedProviderPlans = 11 + NoClients = 2, + ClientsMigrated = 3, + TeamsPlanConfigured = 4, + EnterprisePlanConfigured = 5, + CustomerSetup = 6, + SubscriptionSetup = 7, + CreditApplied = 8, + Completed = 9, } public class ProviderMigrationTracker diff --git a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs index 0da384ae2..9ca515a26 100644 --- a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs @@ -41,7 +41,18 @@ public class ProviderMigrator( await migrationTrackerCache.StartTracker(provider); - await MigrateClientsAsync(providerId); + var organizations = await GetClientsAsync(provider.Id); + + if (organizations.Count == 0) + { + logger.LogInformation("CB: Skipping migration for provider ({ProviderID}) with no clients", providerId); + + await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.NoClients); + + return; + } + + await MigrateClientsAsync(providerId, organizations); await ConfigureTeamsPlanAsync(providerId); @@ -65,6 +76,16 @@ public class ProviderMigrator( return null; } + if (providerTracker.Progress == ProviderMigrationProgress.NoClients) + { + return new ProviderMigrationResult + { + ProviderId = providerTracker.ProviderId, + ProviderName = providerTracker.ProviderName, + Result = providerTracker.Progress.ToString() + }; + } + var clientTrackers = await Task.WhenAll(providerTracker.OrganizationIds.Select(organizationId => migrationTrackerCache.GetTracker(providerId, organizationId))); @@ -99,12 +120,10 @@ public class ProviderMigrator( #region Steps - private async Task MigrateClientsAsync(Guid providerId) + private async Task MigrateClientsAsync(Guid providerId, List organizations) { logger.LogInformation("CB: Migrating clients for provider ({ProviderID})", providerId); - var organizations = await GetEnabledClientsAsync(providerId); - var organizationIds = organizations.Select(organization => organization.Id); await migrationTrackerCache.SetOrganizationIds(providerId, organizationIds); @@ -129,7 +148,7 @@ public class ProviderMigrator( { logger.LogInformation("CB: Configuring Teams plan for provider ({ProviderID})", providerId); - var organizations = await GetEnabledClientsAsync(providerId); + var organizations = await GetClientsAsync(providerId); var teamsSeats = organizations .Where(IsTeams) @@ -172,7 +191,7 @@ public class ProviderMigrator( { logger.LogInformation("CB: Configuring Enterprise plan for provider ({ProviderID})", providerId); - var organizations = await GetEnabledClientsAsync(providerId); + var organizations = await GetClientsAsync(providerId); var enterpriseSeats = organizations .Where(IsEnterprise) @@ -215,7 +234,7 @@ public class ProviderMigrator( { if (string.IsNullOrEmpty(provider.GatewayCustomerId)) { - var organizations = await GetEnabledClientsAsync(provider.Id); + var organizations = await GetClientsAsync(provider.Id); var sampleOrganization = organizations.FirstOrDefault(organization => !string.IsNullOrEmpty(organization.GatewayCustomerId)); @@ -299,28 +318,43 @@ public class ProviderMigrator( private async Task ApplyCreditAsync(Provider provider) { - var organizations = await GetEnabledClientsAsync(provider.Id); + var organizations = await GetClientsAsync(provider.Id); var organizationCustomers = await Task.WhenAll(organizations.Select(organization => stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId))); var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance); - var legacyOrganizations = organizations.Where(organization => - organization.PlanType is + await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId, + new CustomerBalanceTransactionCreateOptions + { + Amount = organizationCancellationCredit, + Currency = "USD", + Description = "Unused, prorated time for client organization subscriptions." + }); + + var migrationRecords = await Task.WhenAll(organizations.Select(organization => + clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id))); + + var legacyOrganizationMigrationRecords = migrationRecords.Where(migrationRecord => + migrationRecord.PlanType is PlanType.EnterpriseAnnually2020 or - PlanType.EnterpriseMonthly2020 or - PlanType.TeamsAnnually2020 or - PlanType.TeamsMonthly2020); + PlanType.TeamsAnnually2020); - var legacyOrganizationCredit = legacyOrganizations.Sum(organization => organization.Seats ?? 0); + var legacyOrganizationCredit = legacyOrganizationMigrationRecords.Sum(migrationRecord => migrationRecord.Seats) * 12 * -100; - await stripeAdapter.CustomerUpdateAsync(provider.GatewayCustomerId, new CustomerUpdateOptions + if (legacyOrganizationCredit < 0) { - Balance = organizationCancellationCredit + legacyOrganizationCredit - }); + await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId, + new CustomerBalanceTransactionCreateOptions + { + Amount = legacyOrganizationCredit, + Currency = "USD", + Description = "1 year rebate for legacy client organizations." + }); + } - logger.LogInformation("CB: Applied {Credit} credit to provider ({ProviderID})", organizationCancellationCredit, provider.Id); + logger.LogInformation("CB: Applied {Credit} credit to provider ({ProviderID})", organizationCancellationCredit + legacyOrganizationCredit, provider.Id); await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CreditApplied); } @@ -340,13 +374,12 @@ public class ProviderMigrator( #region Utilities - private async Task> GetEnabledClientsAsync(Guid providerId) + private async Task> GetClientsAsync(Guid providerId) { var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); return (await Task.WhenAll(providerOrganizations.Select(providerOrganization => organizationRepository.GetByIdAsync(providerOrganization.OrganizationId)))) - .Where(organization => organization.Enabled) .ToList(); } diff --git a/src/Core/Billing/Models/OrganizationMetadata.cs b/src/Core/Billing/Models/OrganizationMetadata.cs index decc35ffd..136964d7c 100644 --- a/src/Core/Billing/Models/OrganizationMetadata.cs +++ b/src/Core/Billing/Models/OrganizationMetadata.cs @@ -1,8 +1,10 @@ namespace Bit.Core.Billing.Models; public record OrganizationMetadata( + bool IsEligibleForSelfHost, bool IsOnSecretsManagerStandalone) { public static OrganizationMetadata Default() => new( - IsOnSecretsManagerStandalone: default); + IsEligibleForSelfHost: false, + IsOnSecretsManagerStandalone: false); } diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 3c5938cab..7db886203 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; @@ -26,6 +27,7 @@ public class OrganizationBillingService( IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : IOrganizationBillingService @@ -69,14 +71,11 @@ public class OrganizationBillingService( var subscription = await subscriberService.GetSubscription(organization); - if (customer == null || subscription == null) - { - return OrganizationMetadata.Default(); - } + var isEligibleForSelfHost = await IsEligibleForSelfHost(organization, subscription); var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription); - return new OrganizationMetadata(isOnSecretsManagerStandalone); + return new OrganizationMetadata(isEligibleForSelfHost, isOnSecretsManagerStandalone); } public async Task UpdatePaymentMethod( @@ -340,11 +339,38 @@ public class OrganizationBillingService( return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); } + private async Task IsEligibleForSelfHost( + Organization organization, + Subscription? organizationSubscription) + { + if (organization.Status != OrganizationStatusType.Managed) + { + return organization.Plan.Contains("Families") || + organization.Plan.Contains("Enterprise") && IsActive(organizationSubscription); + } + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + var providerSubscription = await subscriberService.GetSubscriptionOrThrow(provider); + + return organization.Plan.Contains("Enterprise") && IsActive(providerSubscription); + + bool IsActive(Subscription? subscription) => subscription?.Status is + StripeConstants.SubscriptionStatus.Active or + StripeConstants.SubscriptionStatus.Trialing or + StripeConstants.SubscriptionStatus.PastDue; + } + private static bool IsOnSecretsManagerStandalone( Organization organization, - Customer customer, - Subscription subscription) + Customer? customer, + Subscription? subscription) { + if (customer == null || subscription == null) + { + return false; + } + var plan = StaticStore.GetPlan(organization.PlanType); if (!plan.SupportsSecretsManager) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1fa73fcb3..ecbe190cc 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -146,6 +146,8 @@ public static class FeatureFlagKeys public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string AccessIntelligence = "pm-13227-access-intelligence"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; + public const string Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions"; + public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split"; public static List GetAllKeys() { diff --git a/src/Core/Models/Api/Request/PushDeviceRequestModel.cs b/src/Core/Models/Api/Request/PushDeviceRequestModel.cs index e1866b6f2..8b97dcc36 100644 --- a/src/Core/Models/Api/Request/PushDeviceRequestModel.cs +++ b/src/Core/Models/Api/Request/PushDeviceRequestModel.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using Bit.Core.Enums; namespace Bit.Core.Models.Api; @@ -7,6 +6,4 @@ public class PushDeviceRequestModel { [Required] public string Id { get; set; } - [Required] - public DeviceType Type { get; set; } } diff --git a/src/Core/Models/Api/Request/PushUpdateRequestModel.cs b/src/Core/Models/Api/Request/PushUpdateRequestModel.cs index 9f7ed5f28..f8c2d296f 100644 --- a/src/Core/Models/Api/Request/PushUpdateRequestModel.cs +++ b/src/Core/Models/Api/Request/PushUpdateRequestModel.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using Bit.Core.Enums; namespace Bit.Core.Models.Api; @@ -8,9 +7,9 @@ public class PushUpdateRequestModel public PushUpdateRequestModel() { } - public PushUpdateRequestModel(IEnumerable> devices, string organizationId) + public PushUpdateRequestModel(IEnumerable deviceIds, string organizationId) { - Devices = devices.Select(d => new PushDeviceRequestModel { Id = d.Key, Type = d.Value }); + Devices = deviceIds.Select(d => new PushDeviceRequestModel { Id = d }); OrganizationId = organizationId; } diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Models/Business/OrganizationLicense.cs index ebc1a083f..ea5127364 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Models/Business/OrganizationLicense.cs @@ -53,8 +53,11 @@ public class OrganizationLicense : ILicense UseSecretsManager = org.UseSecretsManager; SmSeats = org.SmSeats; SmServiceAccounts = org.SmServiceAccounts; + + // Deprecated. Left for backwards compatibility with old license versions. LimitCollectionCreationDeletion = org.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = org.AllowAdminAccessToAllCollectionItems; + // if (subscriptionInfo?.Subscription == null) { @@ -138,8 +141,12 @@ public class OrganizationLicense : ILicense public bool UseSecretsManager { get; set; } public int? SmSeats { get; set; } public int? SmServiceAccounts { get; set; } + + // Deprecated. Left for backwards compatibility with old license versions. public bool LimitCollectionCreationDeletion { get; set; } = true; public bool AllowAdminAccessToAllCollectionItems { get; set; } = true; + // + public bool Trial { get; set; } public LicenseType? LicenseType { get; set; } public string Hash { get; set; } @@ -150,7 +157,8 @@ public class OrganizationLicense : ILicense /// Represents the current version of the license format. Should be updated whenever new fields are added. /// /// Intentionally set one version behind to allow self hosted users some time to update before - /// getting out of date license errors + /// getting out of date license errors + /// public const int CurrentLicenseFileVersion = 14; private bool ValidLicenseVersion { @@ -368,10 +376,11 @@ public class OrganizationLicense : ILicense } /* - * Version 14 added LimitCollectionCreationDeletion and Version 15 added AllowAdminAccessToAllCollectionItems, - * however these are just user settings and it is not worth failing validation if they mismatch. - * They are intentionally excluded. - */ + * Version 14 added LimitCollectionCreationDeletion and Version + * 15 added AllowAdminAccessToAllCollectionItems, however they + * are no longer used and are intentionally excluded from + * validation. + */ return valid; } diff --git a/src/Core/Models/Data/InstallationDeviceEntity.cs b/src/Core/Models/Data/InstallationDeviceEntity.cs index 3186efc66..a3d960b24 100644 --- a/src/Core/Models/Data/InstallationDeviceEntity.cs +++ b/src/Core/Models/Data/InstallationDeviceEntity.cs @@ -37,4 +37,25 @@ public class InstallationDeviceEntity : ITableEntity { return deviceId != null && deviceId.Length == 73 && deviceId[36] == '_'; } + public static bool TryParse(string deviceId, out InstallationDeviceEntity installationDeviceEntity) + { + installationDeviceEntity = null; + var installationId = Guid.Empty; + var deviceIdGuid = Guid.Empty; + if (!IsInstallationDeviceId(deviceId)) + { + return false; + } + var parts = deviceId.Split("_"); + if (parts.Length < 2) + { + return false; + } + if (!Guid.TryParse(parts[0], out installationId) || !Guid.TryParse(parts[1], out deviceIdGuid)) + { + return false; + } + installationDeviceEntity = new InstallationDeviceEntity(installationId, deviceIdGuid); + return true; + } } diff --git a/src/Core/NotificationCenter/Commands/MarkNotificationDeletedCommand.cs b/src/Core/NotificationCenter/Commands/MarkNotificationDeletedCommand.cs index fed9fd046..2ca7aa905 100644 --- a/src/Core/NotificationCenter/Commands/MarkNotificationDeletedCommand.cs +++ b/src/Core/NotificationCenter/Commands/MarkNotificationDeletedCommand.cs @@ -49,11 +49,11 @@ public class MarkNotificationDeletedCommand : IMarkNotificationDeletedCommand if (notificationStatus == null) { - notificationStatus = new NotificationStatus() + notificationStatus = new NotificationStatus { NotificationId = notificationId, UserId = _currentContext.UserId.Value, - DeletedDate = DateTime.Now + DeletedDate = DateTime.UtcNow }; await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus, diff --git a/src/Core/NotificationCenter/Commands/MarkNotificationReadCommand.cs b/src/Core/NotificationCenter/Commands/MarkNotificationReadCommand.cs index 936866050..400e44463 100644 --- a/src/Core/NotificationCenter/Commands/MarkNotificationReadCommand.cs +++ b/src/Core/NotificationCenter/Commands/MarkNotificationReadCommand.cs @@ -49,11 +49,11 @@ public class MarkNotificationReadCommand : IMarkNotificationReadCommand if (notificationStatus == null) { - notificationStatus = new NotificationStatus() + notificationStatus = new NotificationStatus { NotificationId = notificationId, UserId = _currentContext.UserId.Value, - ReadDate = DateTime.Now + ReadDate = DateTime.UtcNow }; await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, notificationStatus, diff --git a/src/Core/NotificationHub/INotificationHubClientProxy.cs b/src/Core/NotificationHub/INotificationHubClientProxy.cs new file mode 100644 index 000000000..82b4d3959 --- /dev/null +++ b/src/Core/NotificationHub/INotificationHubClientProxy.cs @@ -0,0 +1,8 @@ +using Microsoft.Azure.NotificationHubs; + +namespace Bit.Core.NotificationHub; + +public interface INotificationHubProxy +{ + Task<(INotificationHubClient Client, NotificationOutcome Outcome)[]> SendTemplateNotificationAsync(IDictionary properties, string tagExpression); +} diff --git a/src/Core/NotificationHub/INotificationHubPool.cs b/src/Core/NotificationHub/INotificationHubPool.cs new file mode 100644 index 000000000..7c383d7b9 --- /dev/null +++ b/src/Core/NotificationHub/INotificationHubPool.cs @@ -0,0 +1,9 @@ +using Microsoft.Azure.NotificationHubs; + +namespace Bit.Core.NotificationHub; + +public interface INotificationHubPool +{ + NotificationHubClient ClientFor(Guid comb); + INotificationHubProxy AllClients { get; } +} diff --git a/src/Core/NotificationHub/NotificationHubClientProxy.cs b/src/Core/NotificationHub/NotificationHubClientProxy.cs new file mode 100644 index 000000000..815ac8836 --- /dev/null +++ b/src/Core/NotificationHub/NotificationHubClientProxy.cs @@ -0,0 +1,26 @@ +using Microsoft.Azure.NotificationHubs; + +namespace Bit.Core.NotificationHub; + +public class NotificationHubClientProxy : INotificationHubProxy +{ + private readonly IEnumerable _clients; + + public NotificationHubClientProxy(IEnumerable clients) + { + _clients = clients; + } + + private async Task<(INotificationHubClient, T)[]> ApplyToAllClientsAsync(Func> action) + { + var tasks = _clients.Select(async c => (c, await action(c))); + return await Task.WhenAll(tasks); + } + + // partial proxy of INotificationHubClient implementation + // Note: Any other methods that are needed can simply be delegated as done here. + public async Task<(INotificationHubClient Client, NotificationOutcome Outcome)[]> SendTemplateNotificationAsync(IDictionary properties, string tagExpression) + { + return await ApplyToAllClientsAsync(async c => await c.SendTemplateNotificationAsync(properties, tagExpression)); + } +} diff --git a/src/Core/NotificationHub/NotificationHubConnection.cs b/src/Core/NotificationHub/NotificationHubConnection.cs new file mode 100644 index 000000000..3a1437f70 --- /dev/null +++ b/src/Core/NotificationHub/NotificationHubConnection.cs @@ -0,0 +1,128 @@ +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Azure.NotificationHubs; + +class NotificationHubConnection +{ + public string HubName { get; init; } + public string ConnectionString { get; init; } + public bool EnableSendTracing { get; init; } + private NotificationHubClient _hubClient; + /// + /// Gets the NotificationHubClient for this connection. + /// + /// If the client is null, it will be initialized. + /// + /// Exception if the connection is invalid. + /// + public NotificationHubClient HubClient + { + get + { + if (_hubClient == null) + { + if (!IsValid) + { + throw new Exception("Invalid notification hub settings"); + } + Init(); + } + return _hubClient; + } + private set + { + _hubClient = value; + } + } + /// + /// Gets the start date for registration. + /// + /// If null, registration is always disabled. + /// + public DateTime? RegistrationStartDate { get; init; } + /// + /// Gets the end date for registration. + /// + /// If null, registration has no end date. + /// + public DateTime? RegistrationEndDate { get; init; } + /// + /// Gets whether all data needed to generate a connection to Notification Hub is present. + /// + public bool IsValid + { + get + { + { + var invalid = string.IsNullOrWhiteSpace(HubName) || string.IsNullOrWhiteSpace(ConnectionString); + return !invalid; + } + } + } + + public string LogString + { + get + { + return $"HubName: {HubName}, EnableSendTracing: {EnableSendTracing}, RegistrationStartDate: {RegistrationStartDate}, RegistrationEndDate: {RegistrationEndDate}"; + } + } + + /// + /// Gets whether registration is enabled for the given comb ID. + /// This is based off of the generation time encoded in the comb ID. + /// + /// + /// + public bool RegistrationEnabled(Guid comb) + { + var combTime = CoreHelpers.DateFromComb(comb); + return RegistrationEnabled(combTime); + } + + /// + /// Gets whether registration is enabled for the given time. + /// + /// The time to check + /// + public bool RegistrationEnabled(DateTime queryTime) + { + if (queryTime >= RegistrationEndDate || RegistrationStartDate == null) + { + return false; + } + + return RegistrationStartDate < queryTime; + } + + private NotificationHubConnection() { } + + /// + /// Creates a new NotificationHubConnection from the given settings. + /// + /// + /// + public static NotificationHubConnection From(GlobalSettings.NotificationHubSettings settings) + { + return new() + { + HubName = settings.HubName, + ConnectionString = settings.ConnectionString, + EnableSendTracing = settings.EnableSendTracing, + // Comb time is not precise enough for millisecond accuracy + RegistrationStartDate = settings.RegistrationStartDate.HasValue ? Truncate(settings.RegistrationStartDate.Value, TimeSpan.FromMilliseconds(10)) : null, + RegistrationEndDate = settings.RegistrationEndDate + }; + } + + private NotificationHubConnection Init() + { + HubClient = NotificationHubClient.CreateClientFromConnectionString(ConnectionString, HubName, EnableSendTracing); + return this; + } + + private static DateTime Truncate(DateTime dateTime, TimeSpan resolution) + { + return dateTime.AddTicks(-(dateTime.Ticks % resolution.Ticks)); + } +} diff --git a/src/Core/NotificationHub/NotificationHubPool.cs b/src/Core/NotificationHub/NotificationHubPool.cs new file mode 100644 index 000000000..7448aad5b --- /dev/null +++ b/src/Core/NotificationHub/NotificationHubPool.cs @@ -0,0 +1,62 @@ +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Azure.NotificationHubs; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.NotificationHub; + +public class NotificationHubPool : INotificationHubPool +{ + private List _connections { get; } + private readonly IEnumerable _clients; + private readonly ILogger _logger; + public NotificationHubPool(ILogger logger, GlobalSettings globalSettings) + { + _logger = logger; + _connections = FilterInvalidHubs(globalSettings.NotificationHubPool.NotificationHubs); + _clients = _connections.GroupBy(c => c.ConnectionString).Select(g => g.First().HubClient); + } + + private List FilterInvalidHubs(IEnumerable hubs) + { + List result = new(); + _logger.LogDebug("Filtering {HubCount} notification hubs", hubs.Count()); + foreach (var hub in hubs) + { + var connection = NotificationHubConnection.From(hub); + if (!connection.IsValid) + { + _logger.LogWarning("Invalid notification hub settings: {HubName}", hub.HubName ?? "hub name missing"); + continue; + } + _logger.LogDebug("Adding notification hub: {ConnectionLogString}", connection.LogString); + result.Add(connection); + } + + return result; + } + + + /// + /// Gets the NotificationHubClient for the given comb ID. + /// + /// + /// + /// Thrown when no notification hub is found for a given comb. + public NotificationHubClient ClientFor(Guid comb) + { + var possibleConnections = _connections.Where(c => c.RegistrationEnabled(comb)).ToArray(); + if (possibleConnections.Length == 0) + { + throw new InvalidOperationException($"No valid notification hubs are available for the given comb ({comb}).\n" + + $"The comb's datetime is {CoreHelpers.DateFromComb(comb)}." + + $"Hub start and end times are configured as follows:\n" + + string.Join("\n", _connections.Select(c => $"Hub {c.HubName} - Start: {c.RegistrationStartDate}, End: {c.RegistrationEndDate}"))); + } + var resolvedConnection = possibleConnections[CoreHelpers.BinForComb(comb, possibleConnections.Length)]; + _logger.LogTrace("Resolved notification hub for comb {Comb} out of {HubCount} hubs.\n{ConnectionInfo}", comb, possibleConnections.Length, resolvedConnection.LogString); + return resolvedConnection.HubClient; + } + + public INotificationHubProxy AllClients { get { return new NotificationHubClientProxy(_clients); } } +} diff --git a/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs similarity index 84% rename from src/Core/Services/Implementations/NotificationHubPushNotificationService.cs rename to src/Core/NotificationHub/NotificationHubPushNotificationService.cs index 480f0dfa9..6143676de 100644 --- a/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs +++ b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs @@ -6,45 +6,31 @@ using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Data; using Bit.Core.Repositories; -using Bit.Core.Settings; +using Bit.Core.Services; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; -using Microsoft.Azure.NotificationHubs; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.NotificationHub; public class NotificationHubPushNotificationService : IPushNotificationService { private readonly IInstallationDeviceRepository _installationDeviceRepository; - private readonly GlobalSettings _globalSettings; private readonly IHttpContextAccessor _httpContextAccessor; - private readonly List _clients = []; private readonly bool _enableTracing = false; + private readonly INotificationHubPool _notificationHubPool; private readonly ILogger _logger; public NotificationHubPushNotificationService( IInstallationDeviceRepository installationDeviceRepository, - GlobalSettings globalSettings, + INotificationHubPool notificationHubPool, IHttpContextAccessor httpContextAccessor, ILogger logger) { _installationDeviceRepository = installationDeviceRepository; - _globalSettings = globalSettings; _httpContextAccessor = httpContextAccessor; - - foreach (var hub in globalSettings.NotificationHubs) - { - var client = NotificationHubClient.CreateClientFromConnectionString( - hub.ConnectionString, - hub.HubName, - hub.EnableSendTracing); - _clients.Add(client); - - _enableTracing = _enableTracing || hub.EnableSendTracing; - } - + _notificationHubPool = notificationHubPool; _logger = logger; } @@ -264,30 +250,23 @@ public class NotificationHubPushNotificationService : IPushNotificationService private async Task SendPayloadAsync(string tag, PushType type, object payload) { - var tasks = new List>(); - foreach (var client in _clients) - { - var task = client.SendTemplateNotificationAsync( - new Dictionary - { - { "type", ((byte)type).ToString() }, - { "payload", JsonSerializer.Serialize(payload) } - }, tag); - tasks.Add(task); - } - - await Task.WhenAll(tasks); + var results = await _notificationHubPool.AllClients.SendTemplateNotificationAsync( + new Dictionary + { + { "type", ((byte)type).ToString() }, + { "payload", JsonSerializer.Serialize(payload) } + }, tag); if (_enableTracing) { - for (var i = 0; i < tasks.Count; i++) + foreach (var (client, outcome) in results) { - if (_clients[i].EnableTestSend) + if (!client.EnableTestSend) { - var outcome = await tasks[i]; - _logger.LogInformation("Azure Notification Hub Tracking ID: {id} | {type} push notification with {success} successes and {failure} failures with a payload of {@payload} and result of {@results}", - outcome.TrackingId, type, outcome.Success, outcome.Failure, payload, outcome.Results); + continue; } + _logger.LogInformation("Azure Notification Hub Tracking ID: {Id} | {Type} push notification with {Success} successes and {Failure} failures with a payload of {@Payload} and result of {@Results}", + outcome.TrackingId, type, outcome.Success, outcome.Failure, payload, outcome.Results); } } } diff --git a/src/Core/Services/Implementations/NotificationHubPushRegistrationService.cs b/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs similarity index 64% rename from src/Core/Services/Implementations/NotificationHubPushRegistrationService.cs rename to src/Core/NotificationHub/NotificationHubPushRegistrationService.cs index 87df60e8e..ae32babf4 100644 --- a/src/Core/Services/Implementations/NotificationHubPushRegistrationService.cs +++ b/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs @@ -1,50 +1,34 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Azure.NotificationHubs; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.NotificationHub; public class NotificationHubPushRegistrationService : IPushRegistrationService { private readonly IInstallationDeviceRepository _installationDeviceRepository; private readonly GlobalSettings _globalSettings; + private readonly INotificationHubPool _notificationHubPool; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; - private Dictionary _clients = []; public NotificationHubPushRegistrationService( IInstallationDeviceRepository installationDeviceRepository, GlobalSettings globalSettings, + INotificationHubPool notificationHubPool, IServiceProvider serviceProvider, ILogger logger) { _installationDeviceRepository = installationDeviceRepository; _globalSettings = globalSettings; + _notificationHubPool = notificationHubPool; _serviceProvider = serviceProvider; _logger = logger; - - // Is this dirty to do in the ctor? - void addHub(NotificationHubType type) - { - var hubRegistration = globalSettings.NotificationHubs.FirstOrDefault( - h => h.HubType == type && h.EnableRegistration); - if (hubRegistration != null) - { - var client = NotificationHubClient.CreateClientFromConnectionString( - hubRegistration.ConnectionString, - hubRegistration.HubName, - hubRegistration.EnableSendTracing); - _clients.Add(type, client); - } - } - - addHub(NotificationHubType.General); - addHub(NotificationHubType.iOS); - addHub(NotificationHubType.Android); } public async Task CreateOrUpdateRegistrationAsync(string pushToken, string deviceId, string userId, @@ -117,7 +101,7 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService BuildInstallationTemplate(installation, "badgeMessage", badgeMessageTemplate ?? messageTemplate, userId, identifier); - await GetClient(type).CreateOrUpdateInstallationAsync(installation); + await ClientFor(GetComb(deviceId)).CreateOrUpdateInstallationAsync(installation); if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId)) { await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId)); @@ -152,11 +136,11 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService installation.Templates.Add(fullTemplateId, template); } - public async Task DeleteRegistrationAsync(string deviceId, DeviceType deviceType) + public async Task DeleteRegistrationAsync(string deviceId) { try { - await GetClient(deviceType).DeleteInstallationAsync(deviceId); + await ClientFor(GetComb(deviceId)).DeleteInstallationAsync(deviceId); if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId)) { await _installationDeviceRepository.DeleteAsync(new InstallationDeviceEntity(deviceId)); @@ -168,31 +152,31 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService } } - public async Task AddUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId) + public async Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) { - await PatchTagsForUserDevicesAsync(devices, UpdateOperationType.Add, $"organizationId:{organizationId}"); - if (devices.Any() && InstallationDeviceEntity.IsInstallationDeviceId(devices.First().Key)) + await PatchTagsForUserDevicesAsync(deviceIds, UpdateOperationType.Add, $"organizationId:{organizationId}"); + if (deviceIds.Any() && InstallationDeviceEntity.IsInstallationDeviceId(deviceIds.First())) { - var entities = devices.Select(e => new InstallationDeviceEntity(e.Key)); + var entities = deviceIds.Select(e => new InstallationDeviceEntity(e)); await _installationDeviceRepository.UpsertManyAsync(entities.ToList()); } } - public async Task DeleteUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId) + public async Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) { - await PatchTagsForUserDevicesAsync(devices, UpdateOperationType.Remove, + await PatchTagsForUserDevicesAsync(deviceIds, UpdateOperationType.Remove, $"organizationId:{organizationId}"); - if (devices.Any() && InstallationDeviceEntity.IsInstallationDeviceId(devices.First().Key)) + if (deviceIds.Any() && InstallationDeviceEntity.IsInstallationDeviceId(deviceIds.First())) { - var entities = devices.Select(e => new InstallationDeviceEntity(e.Key)); + var entities = deviceIds.Select(e => new InstallationDeviceEntity(e)); await _installationDeviceRepository.UpsertManyAsync(entities.ToList()); } } - private async Task PatchTagsForUserDevicesAsync(IEnumerable> devices, UpdateOperationType op, + private async Task PatchTagsForUserDevicesAsync(IEnumerable deviceIds, UpdateOperationType op, string tag) { - if (!devices.Any()) + if (!deviceIds.Any()) { return; } @@ -212,11 +196,11 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService operation.Path += $"/{tag}"; } - foreach (var device in devices) + foreach (var deviceId in deviceIds) { try { - await GetClient(device.Value).PatchInstallationAsync(device.Key, new List { operation }); + await ClientFor(GetComb(deviceId)).PatchInstallationAsync(deviceId, new List { operation }); } catch (Exception e) when (e.InnerException == null || !e.InnerException.Message.Contains("(404) Not Found")) { @@ -225,53 +209,29 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService } } - private NotificationHubClient GetClient(DeviceType deviceType) + private NotificationHubClient ClientFor(Guid deviceId) { - var hubType = NotificationHubType.General; - switch (deviceType) + return _notificationHubPool.ClientFor(deviceId); + } + + private Guid GetComb(string deviceId) + { + var deviceIdString = deviceId; + InstallationDeviceEntity installationDeviceEntity; + Guid deviceIdGuid; + if (InstallationDeviceEntity.TryParse(deviceIdString, out installationDeviceEntity)) { - case DeviceType.Android: - hubType = NotificationHubType.Android; - break; - case DeviceType.iOS: - hubType = NotificationHubType.iOS; - break; - case DeviceType.ChromeExtension: - case DeviceType.FirefoxExtension: - case DeviceType.OperaExtension: - case DeviceType.EdgeExtension: - case DeviceType.VivaldiExtension: - case DeviceType.SafariExtension: - hubType = NotificationHubType.GeneralBrowserExtension; - break; - case DeviceType.WindowsDesktop: - case DeviceType.MacOsDesktop: - case DeviceType.LinuxDesktop: - hubType = NotificationHubType.GeneralDesktop; - break; - case DeviceType.ChromeBrowser: - case DeviceType.FirefoxBrowser: - case DeviceType.OperaBrowser: - case DeviceType.EdgeBrowser: - case DeviceType.IEBrowser: - case DeviceType.UnknownBrowser: - case DeviceType.SafariBrowser: - case DeviceType.VivaldiBrowser: - hubType = NotificationHubType.GeneralWeb; - break; - default: - break; + // Strip off the installation id (PartitionId). RowKey is the ID in the Installation's table. + deviceIdString = installationDeviceEntity.RowKey; } - if (!_clients.ContainsKey(hubType)) + if (Guid.TryParse(deviceIdString, out deviceIdGuid)) { - _logger.LogWarning("No hub client for '{0}'. Using general hub instead.", hubType); - hubType = NotificationHubType.General; - if (!_clients.ContainsKey(hubType)) - { - throw new Exception("No general hub client found."); - } } - return _clients[hubType]; + else + { + throw new Exception($"Invalid device id {deviceId}."); + } + return deviceIdGuid; } } diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs index 62c46460a..1f8c6604b 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs @@ -17,15 +17,18 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman private readonly ILicensingService _licensingService; private readonly IGlobalSettings _globalSettings; private readonly IOrganizationService _organizationService; + private readonly IFeatureService _featureService; public UpdateOrganizationLicenseCommand( ILicensingService licensingService, IGlobalSettings globalSettings, - IOrganizationService organizationService) + IOrganizationService organizationService, + IFeatureService featureService) { _licensingService = licensingService; _globalSettings = globalSettings; _organizationService = organizationService; + _featureService = featureService; } public async Task UpdateLicenseAsync(SelfHostedOrganizationDetails selfHostedOrganization, @@ -59,7 +62,8 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman private async Task UpdateOrganizationAsync(SelfHostedOrganizationDetails selfHostedOrganizationDetails, OrganizationLicense license) { var organization = selfHostedOrganizationDetails.ToOrganization(); - organization.UpdateFromLicense(license); + + organization.UpdateFromLicense(license, _featureService); await _organizationService.ReplaceAndUpdateCacheAsync(organization); } diff --git a/src/Core/Services/IPushRegistrationService.cs b/src/Core/Services/IPushRegistrationService.cs index 83bbed485..985246de0 100644 --- a/src/Core/Services/IPushRegistrationService.cs +++ b/src/Core/Services/IPushRegistrationService.cs @@ -6,7 +6,7 @@ public interface IPushRegistrationService { Task CreateOrUpdateRegistrationAsync(string pushToken, string deviceId, string userId, string identifier, DeviceType type); - Task DeleteRegistrationAsync(string deviceId, DeviceType type); - Task AddUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId); - Task DeleteUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId); + Task DeleteRegistrationAsync(string deviceId); + Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId); + Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId); } diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index bb57f1cd0..a288e1cbe 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -10,6 +10,8 @@ public interface IStripeAdapter Task CustomerUpdateAsync(string id, Stripe.CustomerUpdateOptions options = null); Task CustomerDeleteAsync(string id); Task> CustomerListPaymentMethods(string id, CustomerListPaymentMethodsOptions options = null); + Task CustomerBalanceTransactionCreate(string customerId, + CustomerBalanceTransactionCreateOptions options); Task SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions); Task SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null); Task> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index b15f5153e..65bec5ea9 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -90,14 +90,20 @@ public interface IUserService /// Indicates if the user is managed by any organization. /// /// - /// A managed 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. + /// A user is considered managed by an organization if their email domain matches one of the verified domains of that organization, and the user is a member of it. + /// The organization must be enabled and able to have verified domains. /// + /// + /// False if the Account Deprovisioning feature flag is disabled. + /// Task IsManagedByAnyOrganizationAsync(Guid userId); /// - /// Gets the organization that manages the user. + /// Gets the organizations that manage the user. /// + /// + /// An empty collection if the Account Deprovisioning feature flag is disabled. + /// /// - Task GetOrganizationManagingUserAsync(Guid userId); + Task> GetOrganizationsManagingUserAsync(Guid userId); } diff --git a/src/Core/Services/Implementations/DeviceService.cs b/src/Core/Services/Implementations/DeviceService.cs index 9d8315f69..5b1e4b0f0 100644 --- a/src/Core/Services/Implementations/DeviceService.cs +++ b/src/Core/Services/Implementations/DeviceService.cs @@ -38,13 +38,13 @@ public class DeviceService : IDeviceService public async Task ClearTokenAsync(Device device) { await _deviceRepository.ClearPushTokenAsync(device.Id); - await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString(), device.Type); + await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); } public async Task DeleteAsync(Device device) { await _deviceRepository.DeleteAsync(device); - await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString(), device.Type); + await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); } public async Task UpdateDevicesTrustAsync(string currentDeviceIdentifier, diff --git a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs b/src/Core/Services/Implementations/MultiServicePushNotificationService.cs index 92e29908f..00be72c98 100644 --- a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs +++ b/src/Core/Services/Implementations/MultiServicePushNotificationService.cs @@ -1,61 +1,31 @@ using Bit.Core.Auth.Entities; using Bit.Core.Enums; -using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Tools.Entities; -using Bit.Core.Utilities; using Bit.Core.Vault.Entities; -using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Bit.Core.Services; public class MultiServicePushNotificationService : IPushNotificationService { - private readonly List _services = new List(); + private readonly IEnumerable _services; private readonly ILogger _logger; public MultiServicePushNotificationService( - IHttpClientFactory httpFactory, - IDeviceRepository deviceRepository, - IInstallationDeviceRepository installationDeviceRepository, - GlobalSettings globalSettings, - IHttpContextAccessor httpContextAccessor, + [FromKeyedServices("implementation")] IEnumerable services, ILogger logger, - ILogger relayLogger, - ILogger hubLogger) + GlobalSettings globalSettings) { - if (globalSettings.SelfHosted) - { - if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && - globalSettings.Installation?.Id != null && - CoreHelpers.SettingHasValue(globalSettings.Installation?.Key)) - { - _services.Add(new RelayPushNotificationService(httpFactory, deviceRepository, globalSettings, - httpContextAccessor, relayLogger)); - } - if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) && - CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications)) - { - _services.Add(new NotificationsApiPushNotificationService( - httpFactory, globalSettings, httpContextAccessor, hubLogger)); - } - } - else - { - var generalHub = globalSettings.NotificationHubs?.FirstOrDefault(h => h.HubType == NotificationHubType.General); - if (CoreHelpers.SettingHasValue(generalHub?.ConnectionString)) - { - _services.Add(new NotificationHubPushNotificationService(installationDeviceRepository, - globalSettings, httpContextAccessor, hubLogger)); - } - if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString)) - { - _services.Add(new AzureQueuePushNotificationService(globalSettings, httpContextAccessor)); - } - } + _services = services; _logger = logger; + _logger.LogInformation("Hub services: {Services}", _services.Count()); + globalSettings?.NotificationHubPool?.NotificationHubs?.ForEach(hub => + { + _logger.LogInformation("HubName: {HubName}, EnableSendTracing: {EnableSendTracing}, RegistrationStartDate: {RegistrationStartDate}, RegistrationEndDate: {RegistrationEndDate}", hub.HubName, hub.EnableSendTracing, hub.RegistrationStartDate, hub.RegistrationEndDate); + }); } public Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds) diff --git a/src/Core/Services/Implementations/RelayPushRegistrationService.cs b/src/Core/Services/Implementations/RelayPushRegistrationService.cs index d9df7d04d..d0f7736e9 100644 --- a/src/Core/Services/Implementations/RelayPushRegistrationService.cs +++ b/src/Core/Services/Implementations/RelayPushRegistrationService.cs @@ -38,37 +38,36 @@ public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegi await SendAsync(HttpMethod.Post, "push/register", requestModel); } - public async Task DeleteRegistrationAsync(string deviceId, DeviceType type) + public async Task DeleteRegistrationAsync(string deviceId) { var requestModel = new PushDeviceRequestModel { Id = deviceId, - Type = type, }; await SendAsync(HttpMethod.Post, "push/delete", requestModel); } public async Task AddUserRegistrationOrganizationAsync( - IEnumerable> devices, string organizationId) + IEnumerable deviceIds, string organizationId) { - if (!devices.Any()) + if (!deviceIds.Any()) { return; } - var requestModel = new PushUpdateRequestModel(devices, organizationId); + var requestModel = new PushUpdateRequestModel(deviceIds, organizationId); await SendAsync(HttpMethod.Put, "push/add-organization", requestModel); } public async Task DeleteUserRegistrationOrganizationAsync( - IEnumerable> devices, string organizationId) + IEnumerable deviceIds, string organizationId) { - if (!devices.Any()) + if (!deviceIds.Any()) { return; } - var requestModel = new PushUpdateRequestModel(devices, organizationId); + var requestModel = new PushUpdateRequestModel(deviceIds, organizationId); await SendAsync(HttpMethod.Put, "push/delete-organization", requestModel); } } diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 100a47f75..e5fee63b9 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -18,6 +18,7 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.PriceService _priceService; private readonly Stripe.SetupIntentService _setupIntentService; private readonly Stripe.TestHelpers.TestClockService _testClockService; + private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; public StripeAdapter() { @@ -34,6 +35,7 @@ public class StripeAdapter : IStripeAdapter _priceService = new Stripe.PriceService(); _setupIntentService = new SetupIntentService(); _testClockService = new Stripe.TestHelpers.TestClockService(); + _customerBalanceTransactionService = new CustomerBalanceTransactionService(); } public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options) @@ -63,6 +65,10 @@ public class StripeAdapter : IStripeAdapter return paymentMethods.Data; } + public async Task CustomerBalanceTransactionCreate(string customerId, + CustomerBalanceTransactionCreateOptions options) + => await _customerBalanceTransactionService.CreateAsync(customerId, options); + public Task SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions options) { return _subscriptionService.CreateAsync(options); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 413437a59..f2e1d183d 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1267,18 +1267,24 @@ public class UserService : UserManager, IUserService, IDisposable public async Task IsManagedByAnyOrganizationAsync(Guid userId) { - var managingOrganization = await GetOrganizationManagingUserAsync(userId); - return managingOrganization != null; + var managingOrganizations = await GetOrganizationsManagingUserAsync(userId); + return managingOrganizations.Any(); } - public async Task GetOrganizationManagingUserAsync(Guid userId) + public async Task> GetOrganizationsManagingUserAsync(Guid userId) { - // Users can only be managed by an Organization that is enabled and can have organization domains - var organization = await _organizationRepository.GetByClaimedUserDomainAsync(userId); + if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) + { + return Enumerable.Empty(); + } + // Get all organizations that have verified the user's email domain. + var organizationsWithVerifiedUserEmailDomain = await _organizationRepository.GetByVerifiedUserEmailDomainAsync(userId); + + // Organizations must be enabled and able to have verified domains. // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). // Verified domains were tied to SSO, so we currently check the "UseSso" organization ability. - return (organization is { Enabled: true, UseSso: true }) ? organization : null; + return organizationsWithVerifiedUserEmailDomain.Where(organization => organization is { Enabled: true, UseSso: true }); } /// diff --git a/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs b/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs index fcd088924..f6279c946 100644 --- a/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs +++ b/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs @@ -4,7 +4,7 @@ namespace Bit.Core.Services; public class NoopPushRegistrationService : IPushRegistrationService { - public Task AddUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId) + public Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) { return Task.FromResult(0); } @@ -15,12 +15,12 @@ public class NoopPushRegistrationService : IPushRegistrationService return Task.FromResult(0); } - public Task DeleteRegistrationAsync(string deviceId, DeviceType deviceType) + public Task DeleteRegistrationAsync(string deviceId) { return Task.FromResult(0); } - public Task DeleteUserRegistrationOrganizationAsync(IEnumerable> devices, string organizationId) + public Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) { return Task.FromResult(0); } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index f99fb3b57..793b6ac1c 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -1,5 +1,4 @@ using Bit.Core.Auth.Settings; -using Bit.Core.Enums; using Bit.Core.Settings.LoggingSettings; namespace Bit.Core.Settings; @@ -65,7 +64,7 @@ public class GlobalSettings : IGlobalSettings public virtual SentrySettings Sentry { get; set; } = new SentrySettings(); public virtual SyslogSettings Syslog { get; set; } = new SyslogSettings(); public virtual ILogLevelSettings MinLogLevel { get; set; } = new LogLevelSettings(); - public virtual List NotificationHubs { get; set; } = new(); + public virtual NotificationHubPoolSettings NotificationHubPool { get; set; } = new(); public virtual YubicoSettings Yubico { get; set; } = new YubicoSettings(); public virtual DuoSettings Duo { get; set; } = new DuoSettings(); public virtual BraintreeSettings Braintree { get; set; } = new BraintreeSettings(); @@ -424,7 +423,7 @@ public class GlobalSettings : IGlobalSettings public string ConnectionString { get => _connectionString; - set => _connectionString = value.Trim('"'); + set => _connectionString = value?.Trim('"'); } public string HubName { get; set; } /// @@ -433,10 +432,32 @@ public class GlobalSettings : IGlobalSettings /// public bool EnableSendTracing { get; set; } = false; /// - /// At least one hub configuration should have registration enabled, preferably the General hub as a safety net. + /// The date and time at which registration will be enabled. + /// + /// **This value should not be updated once set, as it is used to determine installation location of devices.** + /// + /// If null, registration is disabled. + /// /// - public bool EnableRegistration { get; set; } - public NotificationHubType HubType { get; set; } + public DateTime? RegistrationStartDate { get; set; } + /// + /// The date and time at which registration will be disabled. + /// + /// **This value should not be updated once set, as it is used to determine installation location of devices.** + /// + /// If null, hub registration has no yet known expiry. + /// + public DateTime? RegistrationEndDate { get; set; } + } + + public class NotificationHubPoolSettings + { + /// + /// List of Notification Hub settings to use for sending push notifications. + /// + /// Note that hubs on the same namespace share active device limits, so multiple namespaces should be used to increase capacity. + /// + public List NotificationHubs { get; set; } = new(); } public class YubicoSettings diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index d900c82e2..af985914c 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -76,6 +76,39 @@ public static class CoreHelpers return new Guid(guidArray); } + internal static DateTime DateFromComb(Guid combGuid) + { + var guidArray = combGuid.ToByteArray(); + var daysArray = new byte[4]; + var msecsArray = new byte[4]; + + Array.Copy(guidArray, guidArray.Length - 6, daysArray, 2, 2); + Array.Copy(guidArray, guidArray.Length - 4, msecsArray, 0, 4); + + Array.Reverse(daysArray); + Array.Reverse(msecsArray); + + var days = BitConverter.ToInt32(daysArray, 0); + var msecs = BitConverter.ToInt32(msecsArray, 0); + + var time = TimeSpan.FromDays(days) + TimeSpan.FromMilliseconds(msecs * 3.333333); + return new DateTime(_baseDateTicks + time.Ticks, DateTimeKind.Utc); + } + + internal static long BinForComb(Guid combGuid, int binCount) + { + // From System.Web.Util.HashCodeCombiner + uint CombineHashCodes(uint h1, byte h2) + { + return (uint)(((h1 << 5) + h1) ^ h2); + } + var guidArray = combGuid.ToByteArray(); + var randomArray = new byte[10]; + Array.Copy(guidArray, 0, randomArray, 0, 10); + var hash = randomArray.Aggregate((uint)randomArray.Length, CombineHashCodes); + return hash % binCount; + } + public static string CleanCertificateThumbprint(string thumbprint) { // Clean possible garbage characters from thumbprint copy/paste diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index 584dd78d6..cb506d86e 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -12,8 +12,4 @@ - - - - diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index bdc2fb4ca..20fdf8315 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -168,7 +168,7 @@ public class OrganizationRepository : Repository, IOrganizat commandType: CommandType.StoredProcedure); } - public async Task GetByClaimedUserDomainAsync(Guid userId) + public async Task> GetByVerifiedUserEmailDomainAsync(Guid userId) { using (var connection = new SqlConnection(ConnectionString)) { @@ -177,7 +177,7 @@ public class OrganizationRepository : Repository, IOrganizat new { UserId = userId }, commandType: CommandType.StoredProcedure); - return result.SingleOrDefault(); + return result.ToList(); } } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs index 288b5c6a9..d7f83d829 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs @@ -9,10 +9,6 @@ namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; public class Organization : Core.AdminConsole.Entities.Organization { - // Shadow properties - to be introduced by https://bitwarden.atlassian.net/browse/PM-10863 - public bool LimitCollectionCreation { get => LimitCollectionCreationDeletion; set => LimitCollectionCreationDeletion = value; } - public bool LimitCollectionDeletion { get => LimitCollectionCreationDeletion; set => LimitCollectionCreationDeletion = value; } - public virtual ICollection Ciphers { get; set; } public virtual ICollection OrganizationUsers { get; set; } public virtual ICollection Groups { get; set; } @@ -42,9 +38,6 @@ public class OrganizationMapperProfile : Profile .ForMember(org => org.ApiKeys, opt => opt.Ignore()) .ForMember(org => org.Connections, opt => opt.Ignore()) .ForMember(org => org.Domains, opt => opt.Ignore()) - // Shadow properties - to be introduced by https://bitwarden.atlassian.net/browse/PM-10863 - .ForMember(org => org.LimitCollectionCreation, opt => opt.Ignore()) - .ForMember(org => org.LimitCollectionDeletion, opt => opt.Ignore()) .ReverseMap(); CreateProjection() diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 96c9a912e..b3ee25488 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -99,6 +99,9 @@ public class OrganizationRepository : Repository GetByClaimedUserDomainAsync(Guid userId) + public async Task> GetByVerifiedUserEmailDomainAsync(Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) { @@ -291,7 +294,7 @@ public class OrganizationRepository : Repository(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); @@ -265,16 +267,30 @@ public static class ServiceCollectionExtensions } services.AddSingleton(); - if (globalSettings.SelfHosted && - CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && - globalSettings.Installation?.Id != null && - CoreHelpers.SettingHasValue(globalSettings.Installation?.Key)) + if (globalSettings.SelfHosted) { - services.AddSingleton(); + if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && + globalSettings.Installation?.Id != null && + CoreHelpers.SettingHasValue(globalSettings.Installation?.Key)) + { + services.AddKeyedSingleton("implementation"); + services.AddSingleton(); + } + if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) && + CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications)) + { + services.AddKeyedSingleton("implementation"); + } } else if (!globalSettings.SelfHosted) { + services.AddSingleton(); services.AddSingleton(); + services.AddKeyedSingleton("implementation"); + if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString)) + { + services.AddKeyedSingleton("implementation"); + } } else { diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadByUserId.sql index 37971870c..907a31c7f 100644 --- a/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadByUserId.sql +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadByUserId.sql @@ -4,6 +4,21 @@ AS BEGIN SET NOCOUNT ON + SELECT + CC.* + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] S ON S.[Id] = CC.[CollectionId] + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[OrganizationId] = S.[OrganizationId] AND OU.[UserId] = @UserId + INNER JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = S.[Id] AND CU.[OrganizationUserId] = OU.[Id] + WHERE + OU.[Status] = 2 + + UNION ALL + SELECT CC.* FROM @@ -12,18 +27,13 @@ BEGIN [dbo].[Collection] S ON S.[Id] = CC.[CollectionId] INNER JOIN [dbo].[OrganizationUser] OU ON OU.[OrganizationId] = S.[OrganizationId] AND OU.[UserId] = @UserId + INNER JOIN + [dbo].[GroupUser] GU ON GU.[OrganizationUserId] = OU.[Id] + INNER JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = CC.[CollectionId] AND CG.[GroupId] = GU.[GroupId] LEFT JOIN [dbo].[CollectionUser] CU ON CU.[CollectionId] = S.[Id] AND CU.[OrganizationUserId] = OU.[Id] - LEFT JOIN - [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] - LEFT JOIN - [dbo].[Group] G ON G.[Id] = GU.[GroupId] - LEFT JOIN - [dbo].[CollectionGroup] CG ON CG.[CollectionId] = CC.[CollectionId] AND CG.[GroupId] = GU.[GroupId] WHERE - OU.[Status] = 2 -- Confirmed - AND ( - CU.[CollectionId] IS NOT NULL - OR CG.[CollectionId] IS NOT NULL - ) + OU.[Status] = 2 + AND CU.[CollectionId] IS NULL END diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs index 1ff4b519c..352f089db 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs @@ -229,13 +229,13 @@ public class OrganizationDomainControllerTests sutProvider.GetDependency() .GetDomainByIdOrganizationIdAsync(organizationDomain.Id, organizationDomain.OrganizationId) .Returns(organizationDomain); - sutProvider.GetDependency().VerifyOrganizationDomainAsync(organizationDomain) + sutProvider.GetDependency().UserVerifyOrganizationDomainAsync(organizationDomain) .Returns(new OrganizationDomain()); var result = await sutProvider.Sut.Verify(organizationDomain.OrganizationId, organizationDomain.Id); await sutProvider.GetDependency().Received(1) - .VerifyOrganizationDomainAsync(organizationDomain); + .UserVerifyOrganizationDomainAsync(organizationDomain); Assert.IsType(result); } diff --git a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs index 70ca59940..b46fd307e 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs @@ -52,7 +52,7 @@ public class OrganizationBillingControllerTests { sutProvider.GetDependency().AccessMembersTab(organizationId).Returns(true); sutProvider.GetDependency().GetMetadata(organizationId) - .Returns(new OrganizationMetadata(true)); + .Returns(new OrganizationMetadata(true, true)); var result = await sutProvider.Sut.GetMetadataAsync(organizationId); @@ -60,6 +60,7 @@ public class OrganizationBillingControllerTests var organizationMetadataResponse = ((Ok)result).Value; + Assert.True(organizationMetadataResponse.IsEligibleForSelfHost); Assert.True(organizationMetadataResponse.IsOnSecretsManagerStandalone); } diff --git a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs index b94162be8..3336c0f4d 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Bit.Api.Vault.AuthorizationHandlers.Collections; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -32,7 +33,10 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true); + // `LimitCollectonCreationDeletionSplit` feature flag state isn't + // relevant for this test. The flag is never checked for in this + // test. This is asserted below. + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -44,11 +48,12 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanCreateAsync_WhenUser_WithLimitCollectionCreationDeletionFalse_Success( + public async Task CanCreateAsync_WhenUser_WithLimitCollectionCreationFalse_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -57,7 +62,7 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; - ArrangeOrganizationAbility(sutProvider, organization, false); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, false, false); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -66,16 +71,49 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) + .Returns(false); await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanCreateAsync_WhenUser_WithLimitCollectionCreationFalse_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = OrganizationUserType.User; + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, false, false); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Create }, + new ClaimsPrincipal(), + collections); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) + .Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task CanCreateAsync_WhenMissingPermissions_NoSuccess( + public async Task CanCreateAsync_WhenMissingPermissions_WithLimitCollectionCreationDeletionSplitFeatureDisabled_NoSuccess( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -92,7 +130,7 @@ public class BulkCollectionAuthorizationHandlerTests ManageUsers = false }; - ArrangeOrganizationAbility(sutProvider, organization, true); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -102,21 +140,61 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.False(context.HasSucceeded); + } + + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task CanCreateAsync_WhenMissingPermissions_WithLimitCollectionCreationDeletionSplitFeatureEnabled_NoSuccess( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions + { + EditAnyCollection = false, + DeleteAnyCollection = false, + ManageGroups = false, + ManageUsers = false + }; + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Create }, + new ClaimsPrincipal(), + collections); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanCreateAsync_WhenMissingOrgAccess_NoSuccess( + public async Task CanCreateAsync_WhenMissingOrgAccess_WithLimitCollectionCreationDeletionSplitDisabled_NoSuccess( Guid userId, CurrentContextOrganization organization, List collections, SutProvider sutProvider) { collections.ForEach(c => c.OrganizationId = organization.Id); - ArrangeOrganizationAbility(sutProvider, organization, true); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -127,8 +205,38 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanCreateAsync_WhenMissingOrgAccess_WithLimitCollectionCreationDeletionSplitEnabled_NoSuccess( + Guid userId, + CurrentContextOrganization organization, + List collections, + SutProvider sutProvider) + { + collections.ForEach(c => c.OrganizationId = organization.Id); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Create }, + new ClaimsPrincipal(), + collections + ); + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } @@ -904,7 +1012,10 @@ public class BulkCollectionAuthorizationHandlerTests DeleteAnyCollection = true }; - ArrangeOrganizationAbility(sutProvider, organization, true); + // `LimitCollectonCreationDeletionSplit` feature flag state isn't + // relevant for this test. The flag is never checked for in this + // test. This is asserted below. + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -916,6 +1027,7 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } @@ -931,7 +1043,10 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true); + // `LimitCollectonCreationDeletionSplit` feature flag state isn't + // relevant for this test. The flag is never checked for in this + // test. This is asserted below. + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -943,11 +1058,12 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionFalse_WithCanManagePermission_Success( + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -957,11 +1073,12 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, false); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, false, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); foreach (var c in collections) { @@ -975,6 +1092,41 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, false, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } @@ -982,7 +1134,7 @@ public class BulkCollectionAuthorizationHandlerTests [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.User)] - public async Task CanDeleteAsync_LimitCollectionCreationDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success( + public async Task CanDeleteAsync_LimitCollectionDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -993,11 +1145,12 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, false, false); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, false, false, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); foreach (var c in collections) { @@ -1011,13 +1164,15 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] - public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success( + [BitAutoData(OrganizationUserType.User)] + public async Task CanDeleteAsync_LimitCollectionDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -1028,11 +1183,12 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true, false); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, false, false, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); foreach (var c in collections) { @@ -1046,13 +1202,14 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] - public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithoutCanManagePermission_Failure( + public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -1063,12 +1220,87 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true, false); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.True(context.HasSucceeded); + } + + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.True(context.HasSucceeded); + } + + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithoutCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Failure( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); foreach (var c in collections) { @@ -1082,11 +1314,50 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.False(context.HasSucceeded); + } + + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithoutCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Failure( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); + + foreach (var c in collections) + { + c.Manage = false; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsTrue_Failure( + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsTrue_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Failure( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -1096,12 +1367,13 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); foreach (var c in collections) { @@ -1115,11 +1387,12 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_Failure( + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsTrue_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Failure( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -1129,12 +1402,13 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true, false); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); foreach (var c in collections) { @@ -1148,13 +1422,88 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Failure( + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) + .Returns(false); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Failure( + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) + .Returns(true); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task CanDeleteAsync_WhenMissingPermissions_NoSuccess( + public async Task CanDeleteAsync_WhenMissingPermissions_WithLimitCollectionCreationDeletionSplitFeatureDisabled_NoSuccess( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -1171,7 +1520,7 @@ public class BulkCollectionAuthorizationHandlerTests ManageUsers = false }; - ArrangeOrganizationAbility(sutProvider, organization, true); + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -1181,14 +1530,54 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); await sutProvider.Sut.HandleAsync(context); + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.False(context.HasSucceeded); + } + + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task CanDeleteAsync_WhenMissingPermissions_WithLimitCollectionCreationDeletionSplitFeatureEnabled_NoSuccess( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions + { + EditAnyCollection = false, + DeleteAnyCollection = false, + ManageGroups = false, + ManageUsers = false + }; + + ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenMissingOrgAccess_NoSuccess( + public async Task CanDeleteAsync_WhenMissingOrgAccess_WithLimitCollectionCreationDeletionSplitFeatureDisabled_NoSuccess( Guid userId, ICollection collections, SutProvider sutProvider) @@ -1202,8 +1591,34 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanDeleteAsync_WhenMissingOrgAccess_WithLimitCollectionCreationDeletionSplitFeatureEnabled_NoSuccess( + Guid userId, + ICollection collections, + SutProvider sutProvider) + { + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections + ); + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); + + await sutProvider.Sut.HandleAsync(context); + + sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } @@ -1224,6 +1639,7 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); Assert.True(context.HasFailed); sutProvider.GetDependency().DidNotReceiveWithAnyArgs(); + sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); } [Theory, BitAutoData, CollectionCustomization] @@ -1247,10 +1663,11 @@ public class BulkCollectionAuthorizationHandlerTests var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(context)); Assert.Equal("Requested collections must belong to the same organization.", exception.Message); sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetOrganization(default); + sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); } [Theory, BitAutoData, CollectionCustomization] - public async Task HandleRequirementAsync_Provider_Success( + public async Task HandleRequirementAsync_Provider_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( SutProvider sutProvider, ICollection collections) { @@ -1286,6 +1703,63 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().GetOrganizationAbilitiesAsync() .Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(true); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); + + var context = new AuthorizationHandlerContext( + new[] { op }, + new ClaimsPrincipal(), + collections + ); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + await sutProvider.GetDependency().Received().ProviderUserForOrgAsync(orgId); + + // Recreate the SUT to reset the mocks/dependencies between tests + sutProvider.Recreate(); + } + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task HandleRequirementAsync_Provider_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( + SutProvider sutProvider, + ICollection collections) + { + var actingUserId = Guid.NewGuid(); + var orgId = collections.First().OrganizationId; + + var organizationAbilities = new Dictionary + { + { collections.First().OrganizationId, + new OrganizationAbility + { + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + AllowAdminAccessToAllCollectionItems = true + } + } + }; + + var operationsToTest = new[] + { + BulkCollectionOperations.Create, + BulkCollectionOperations.Read, + BulkCollectionOperations.ReadAccess, + BulkCollectionOperations.Update, + BulkCollectionOperations.ModifyUserAccess, + BulkCollectionOperations.ModifyGroupAccess, + BulkCollectionOperations.Delete, + }; + + foreach (var op in operationsToTest) + { + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(orgId).Returns((CurrentContextOrganization)null); + sutProvider.GetDependency().GetOrganizationAbilitiesAsync() + .Returns(organizationAbilities); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(true); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); var context = new AuthorizationHandlerContext( new[] { op }, @@ -1336,14 +1810,37 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.GetDependency().Received(1).GetManyByUserIdAsync(Arg.Any()); } - private static void ArrangeOrganizationAbility( + private static void ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled( SutProvider sutProvider, - CurrentContextOrganization organization, bool limitCollectionCreationDeletion, + CurrentContextOrganization organization, + bool limitCollectionCreation, + bool limitCollectionDeletion, bool allowAdminAccessToAllCollectionItems = true) { var organizationAbility = new OrganizationAbility(); organizationAbility.Id = organization.Id; - organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; + + organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreation || limitCollectionDeletion; + + organizationAbility.AllowAdminAccessToAllCollectionItems = allowAdminAccessToAllCollectionItems; + + sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); + } + + private static void ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled( + SutProvider sutProvider, + CurrentContextOrganization organization, + bool limitCollectionCreation, + bool limitCollectionDeletion, + bool allowAdminAccessToAllCollectionItems = true) + { + var organizationAbility = new OrganizationAbility(); + organizationAbility.Id = organization.Id; + + organizationAbility.LimitCollectionCreation = limitCollectionCreation; + organizationAbility.LimitCollectionDeletion = limitCollectionDeletion; + organizationAbility.AllowAdminAccessToAllCollectionItems = allowAdminAccessToAllCollectionItems; sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) diff --git a/test/Common/AutoFixture/ControllerCustomization.cs b/test/Common/AutoFixture/ControllerCustomization.cs index f695f86b5..91fffbf09 100644 --- a/test/Common/AutoFixture/ControllerCustomization.cs +++ b/test/Common/AutoFixture/ControllerCustomization.cs @@ -1,6 +1,5 @@ using AutoFixture; using Microsoft.AspNetCore.Mvc; -using Org.BouncyCastle.Security; namespace Bit.Test.Common.AutoFixture; @@ -15,7 +14,7 @@ public class ControllerCustomization : ICustomization { if (!controllerType.IsAssignableTo(typeof(Controller))) { - throw new InvalidParameterException($"{nameof(controllerType)} must derive from {typeof(Controller).Name}"); + throw new Exception($"{nameof(controllerType)} must derive from {typeof(Controller).Name}"); } _controllerType = controllerType; diff --git a/test/Common/AutoFixture/SutProvider.cs b/test/Common/AutoFixture/SutProvider.cs index ac953965b..fefe6c3eb 100644 --- a/test/Common/AutoFixture/SutProvider.cs +++ b/test/Common/AutoFixture/SutProvider.cs @@ -127,7 +127,6 @@ public class SutProvider : ISutProvider return _sutProvider.GetDependency(parameterInfo.ParameterType, ""); } - // This is the equivalent of _fixture.Create, but no overload for // Create(Type type) exists. var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType, diff --git a/test/Common/AutoFixture/SutProviderExtensions.cs b/test/Common/AutoFixture/SutProviderExtensions.cs index 1fdf22653..bdc860416 100644 --- a/test/Common/AutoFixture/SutProviderExtensions.cs +++ b/test/Common/AutoFixture/SutProviderExtensions.cs @@ -1,6 +1,7 @@ using AutoFixture; using Bit.Core.Services; using Bit.Core.Settings; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using RichardSzalay.MockHttp; @@ -47,4 +48,19 @@ public static class SutProviderExtensions .SetDependency(mockHttpClientFactory) .Create(); } + + /// + /// Configures SutProvider to use FakeTimeProvider. + /// It is registered under both the TimeProvider type and the FakeTimeProvider type + /// so that it can be retrieved in a type-safe manner with GetDependency. + /// This can be chained with other builder methods; make sure to call + /// before use. + /// + public static SutProvider WithFakeTimeProvider(this SutProvider sutProvider) + { + var fakeTimeProvider = new FakeTimeProvider(); + return sutProvider + .SetDependency((TimeProvider)fakeTimeProvider) + .SetDependency(fakeTimeProvider); + } } diff --git a/test/Common/Common.csproj b/test/Common/Common.csproj index 7b9fbe42d..2f11798ce 100644 --- a/test/Common/Common.csproj +++ b/test/Common/Common.csproj @@ -5,6 +5,7 @@
+ diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs index f70fd579e..09b112c43 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs @@ -9,10 +9,12 @@ namespace Bit.Core.Test.AdminConsole.AutoFixture; internal class PolicyCustomization : ICustomization { public PolicyType Type { get; set; } + public bool Enabled { get; set; } - public PolicyCustomization(PolicyType type) + public PolicyCustomization(PolicyType type, bool enabled) { Type = type; + Enabled = enabled; } public void Customize(IFixture fixture) @@ -20,21 +22,23 @@ internal class PolicyCustomization : ICustomization fixture.Customize(composer => composer .With(o => o.OrganizationId, Guid.NewGuid()) .With(o => o.Type, Type) - .With(o => o.Enabled, true)); + .With(o => o.Enabled, Enabled)); } } public class PolicyAttribute : CustomizeAttribute { private readonly PolicyType _type; + private readonly bool _enabled; - public PolicyAttribute(PolicyType type) + public PolicyAttribute(PolicyType type, bool enabled = true) { _type = type; + _enabled = enabled; } public override ICustomization GetCustomization(ParameterInfo parameter) { - return new PolicyCustomization(_type); + return new PolicyCustomization(_type, _enabled); } } diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs new file mode 100644 index 000000000..dff9b5717 --- /dev/null +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.Test.AdminConsole.AutoFixture; + +internal class PolicyUpdateCustomization(PolicyType type, bool enabled) : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.Type, type) + .With(o => o.Enabled, enabled)); + } +} + +public class PolicyUpdateAttribute(PolicyType type, bool enabled = true) : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) + { + return new PolicyUpdateCustomization(type, enabled); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index 416d86c5d..d61ded28b 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -15,7 +15,7 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationDomains; public class VerifyOrganizationDomainCommandTests { [Theory, BitAutoData] - public async Task VerifyOrganizationDomain_ShouldThrowConflict_WhenDomainHasBeenClaimed(Guid id, + public async Task UserVerifyOrganizationDomain_ShouldThrowConflict_WhenDomainHasBeenClaimed(Guid id, SutProvider sutProvider) { var expected = new OrganizationDomain @@ -30,14 +30,14 @@ public class VerifyOrganizationDomainCommandTests .GetByIdAsync(id) .Returns(expected); - var requestAction = async () => await sutProvider.Sut.VerifyOrganizationDomainAsync(expected); + var requestAction = async () => await sutProvider.Sut.UserVerifyOrganizationDomainAsync(expected); var exception = await Assert.ThrowsAsync(requestAction); Assert.Contains("Domain has already been verified.", exception.Message); } [Theory, BitAutoData] - public async Task VerifyOrganizationDomain_ShouldThrowConflict_WhenDomainHasBeenClaimedByAnotherOrganization(Guid id, + public async Task UserVerifyOrganizationDomain_ShouldThrowConflict_WhenDomainHasBeenClaimedByAnotherOrganization(Guid id, SutProvider sutProvider) { var expected = new OrganizationDomain @@ -54,14 +54,14 @@ public class VerifyOrganizationDomainCommandTests .GetClaimedDomainsByDomainNameAsync(expected.DomainName) .Returns(new List { expected }); - var requestAction = async () => await sutProvider.Sut.VerifyOrganizationDomainAsync(expected); + var requestAction = async () => await sutProvider.Sut.UserVerifyOrganizationDomainAsync(expected); var exception = await Assert.ThrowsAsync(requestAction); Assert.Contains("The domain is not available to be claimed.", exception.Message); } [Theory, BitAutoData] - public async Task VerifyOrganizationDomain_ShouldVerifyDomainUpdateAndLogEvent_WhenTxtRecordExists(Guid id, + public async Task UserVerifyOrganizationDomain_ShouldVerifyDomainUpdateAndLogEvent_WhenTxtRecordExists(Guid id, SutProvider sutProvider) { var expected = new OrganizationDomain @@ -81,7 +81,7 @@ public class VerifyOrganizationDomainCommandTests .ResolveAsync(expected.DomainName, Arg.Any()) .Returns(true); - var result = await sutProvider.Sut.VerifyOrganizationDomainAsync(expected); + var result = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(expected); Assert.NotNull(result.VerifiedDate); await sutProvider.GetDependency().Received(1) @@ -91,7 +91,7 @@ public class VerifyOrganizationDomainCommandTests } [Theory, BitAutoData] - public async Task VerifyOrganizationDomain_ShouldNotSetVerifiedDate_WhenTxtRecordDoesNotExist(Guid id, + public async Task UserVerifyOrganizationDomain_ShouldNotSetVerifiedDate_WhenTxtRecordDoesNotExist(Guid id, SutProvider sutProvider) { var expected = new OrganizationDomain @@ -111,10 +111,30 @@ public class VerifyOrganizationDomainCommandTests .ResolveAsync(expected.DomainName, Arg.Any()) .Returns(false); - var result = await sutProvider.Sut.VerifyOrganizationDomainAsync(expected); + var result = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(expected); Assert.Null(result.VerifiedDate); await sutProvider.GetDependency().Received(1) .LogOrganizationDomainEventAsync(Arg.Any(), EventType.OrganizationDomain_NotVerified); } + + + [Theory, BitAutoData] + public async Task SystemVerifyOrganizationDomain_CallsEventServiceWithUpdatedJobRunCount(SutProvider sutProvider) + { + var domain = new OrganizationDomain() + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + CreationDate = DateTime.UtcNow, + DomainName = "test.com", + Txt = "btw+12345", + }; + + _ = await sutProvider.Sut.SystemVerifyOrganizationDomainAsync(domain); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(1) + .LogOrganizationDomainEventAsync(default, EventType.OrganizationDomain_NotVerified, + EventSystemUser.DomainVerification); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorFixtures.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorFixtures.cs new file mode 100644 index 000000000..ba4741d8b --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorFixtures.cs @@ -0,0 +1,43 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using NSubstitute; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class FakeSingleOrgPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.SingleOrg; + public IEnumerable RequiredPolicies => Array.Empty(); + + public readonly Func> ValidateAsyncMock = Substitute.For>>(); + public readonly Action OnSaveSideEffectsAsyncMock = Substitute.For>(); + + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + return ValidateAsyncMock(policyUpdate, currentPolicy); + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + OnSaveSideEffectsAsyncMock(policyUpdate, currentPolicy); + return Task.FromResult(0); + } +} +public class FakeRequireSsoPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.RequireSso; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} +public class FakeVaultTimeoutPolicyValidator : IPolicyValidator +{ + public PolicyType Type => PolicyType.MaximumVaultTimeout; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(0); +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorHelpersTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorHelpersTests.cs new file mode 100644 index 000000000..99f99706f --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidatorHelpersTests.cs @@ -0,0 +1,64 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class PolicyValidatorHelpersTests +{ + [Fact] + public void ValidateDecryptionOptionsNotEnabled_RequiredByKeyConnector_ValidationError() + { + var ssoConfig = new SsoConfig(); + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + + Assert.Contains("Key Connector is enabled", result); + } + + [Fact] + public void ValidateDecryptionOptionsNotEnabled_RequiredByTDE_ValidationError() + { + var ssoConfig = new SsoConfig(); + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption }); + + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.TrustedDeviceEncryption]); + + Assert.Contains("Trusted device encryption is on", result); + } + + [Fact] + public void ValidateDecryptionOptionsNotEnabled_NullSsoConfig_NoValidationError() + { + var ssoConfig = new SsoConfig(); + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + + Assert.True(string.IsNullOrEmpty(result)); + } + + [Fact] + public void ValidateDecryptionOptionsNotEnabled_RequiredOptionNotEnabled_NoValidationError() + { + var ssoConfig = new SsoConfig(); + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.TrustedDeviceEncryption]); + + Assert.True(string.IsNullOrEmpty(result)); + } + + [Fact] + public void ValidateDecryptionOptionsNotEnabled_SsoConfigDisabled_NoValidationError() + { + var ssoConfig = new SsoConfig(); + ssoConfig.Enabled = false; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + var result = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + + Assert.True(string.IsNullOrEmpty(result)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs new file mode 100644 index 000000000..d3af765f7 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs @@ -0,0 +1,75 @@ +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +[SutProviderCustomize] +public class RequireSsoPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_KeyConnectorEnabled_ValidationError( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = true }; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_TdeEnabled_ValidationError( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = true }; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption }); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.Contains("Trusted device encryption is on", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_DecryptionOptionsNotEnabled_Success( + [PolicyUpdate(PolicyType.ResetPassword, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.ResetPassword)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = false }; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.True(string.IsNullOrEmpty(result)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs new file mode 100644 index 000000000..83939406b --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs @@ -0,0 +1,71 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class ResetPasswordPolicyValidatorTests +{ + [Theory] + [BitAutoData(true, false)] + [BitAutoData(false, true)] + [BitAutoData(false, false)] + public async Task ValidateAsync_DisablingPolicy_TdeEnabled_ValidationError( + bool policyEnabled, + bool autoEnrollEnabled, + [PolicyUpdate(PolicyType.ResetPassword)] PolicyUpdate policyUpdate, + [Policy(PolicyType.ResetPassword)] Policy policy, + SutProvider sutProvider) + { + policyUpdate.Enabled = policyEnabled; + policyUpdate.SetDataModel(new ResetPasswordDataModel + { + AutoEnrollEnabled = autoEnrollEnabled + }); + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = true }; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption }); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.Contains("Trusted device encryption is on and requires this policy.", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_TdeNotEnabled_Success( + [PolicyUpdate(PolicyType.ResetPassword, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.ResetPassword)] Policy policy, + SutProvider sutProvider) + { + policyUpdate.SetDataModel(new ResetPasswordDataModel + { + AutoEnrollEnabled = false + }); + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = false }; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.True(string.IsNullOrEmpty(result)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs new file mode 100644 index 000000000..76ee57484 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs @@ -0,0 +1,129 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class SingleOrgPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_KeyConnectorEnabled_ValidationError( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = true }; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_KeyConnectorNotEnabled_Success( + [PolicyUpdate(PolicyType.ResetPassword, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.ResetPassword)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = policyUpdate.OrganizationId; + + var ssoConfig = new SsoConfig { Enabled = false }; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns(ssoConfig); + + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy); + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy policy, + Guid savingUserId, + Guid nonCompliantUserId, + Organization organization, SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + + var compliantUser1 = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = new Guid(), + Email = "user1@example.com" + }; + + var compliantUser2 = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = new Guid(), + Email = "user2@example.com" + }; + + var nonCompliantUser = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantUserId, + Email = "user3@example.com" + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([compliantUser1, compliantUser2, nonCompliantUser]); + + var otherOrganizationUser = new OrganizationUser + { + OrganizationId = new Guid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Contains(nonCompliantUserId))) + .Returns([otherOrganizationUser]); + + sutProvider.GetDependency().UserId.Returns(savingUserId); + sutProvider.GetDependency().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + await sutProvider.GetDependency() + .Received(1) + .RemoveUserAsync(policyUpdate.OrganizationId, nonCompliantUser.Id, savingUserId); + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), + "user3@example.com"); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs new file mode 100644 index 000000000..4dce13174 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs @@ -0,0 +1,209 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class TwoFactorAuthenticationPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( + Organization organization, + [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, + [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var orgUserDetailUserInvited = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user1@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + var orgUserDetailUserAcceptedWith2FA = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user2@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + var orgUserDetailUserAcceptedWithout2FA = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user3@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + var orgUserDetailAdmin = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Admin, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "admin@test.com", + Name = "ADMIN", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns(new List + { + orgUserDetailUserInvited, + orgUserDetailUserAcceptedWith2FA, + orgUserDetailUserAcceptedWithout2FA, + orgUserDetailAdmin + }); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() + { + (orgUserDetailUserInvited, false), + (orgUserDetailUserAcceptedWith2FA, true), + (orgUserDetailUserAcceptedWithout2FA, false), + (orgUserDetailAdmin, false), + }); + + var savingUserId = Guid.NewGuid(); + sutProvider.GetDependency().UserId.Returns(savingUserId); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + var removeOrganizationUserCommand = sutProvider.GetDependency(); + + await removeOrganizationUserCommand.Received() + .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWithout2FA.Id, savingUserId); + await sutProvider.GetDependency().Received() + .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWithout2FA.Email); + + await removeOrganizationUserCommand.DidNotReceive() + .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserInvited.Id, savingUserId); + await sutProvider.GetDependency().DidNotReceive() + .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserInvited.Email); + await removeOrganizationUserCommand.DidNotReceive() + .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWith2FA.Id, savingUserId); + await sutProvider.GetDependency().DidNotReceive() + .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWith2FA.Email); + await removeOrganizationUserCommand.DidNotReceive() + .RemoveUserAsync(policy.OrganizationId, orgUserDetailAdmin.Id, savingUserId); + await sutProvider.GetDependency().DidNotReceive() + .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailAdmin.Email); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_UsersToBeRemovedDontHaveMasterPasswords_Throws( + Organization organization, + [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, + [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + + var orgUserDetailUserWith2FAAndMP = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user1@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + var orgUserDetailUserWith2FANoMP = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user2@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + var orgUserDetailUserWithout2FA = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "user3@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + var orgUserDetailAdmin = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Admin, + // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync + Email = "admin@test.com", + Name = "ADMIN", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policy.OrganizationId) + .Returns(new List + { + orgUserDetailUserWith2FAAndMP, + orgUserDetailUserWith2FANoMP, + orgUserDetailUserWithout2FA, + orgUserDetailAdmin + }); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(ids => + ids.Contains(orgUserDetailUserWith2FANoMP.UserId.Value) + && ids.Contains(orgUserDetailUserWithout2FA.UserId.Value) + && ids.Contains(orgUserDetailAdmin.UserId.Value))) + .Returns(new List<(Guid userId, bool hasTwoFactor)>() + { + (orgUserDetailUserWith2FANoMP.UserId.Value, true), + (orgUserDetailUserWithout2FA.UserId.Value, false), + (orgUserDetailAdmin.UserId.Value, false), + }); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy)); + + Assert.Contains("Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RemoveUserAsync(organizationId: default, organizationUserId: default, deletingUserId: default); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs new file mode 100644 index 000000000..342ede9c8 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -0,0 +1,330 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; +using EventType = Bit.Core.Enums.EventType; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class SavePolicyCommandTests +{ + [Theory, BitAutoData] + public async Task SaveAsync_NewPolicy_Success([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns(""); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]); + + var creationDate = sutProvider.GetDependency().Start; + + await sutProvider.Sut.SaveAsync(policyUpdate); + + await fakePolicyValidator.ValidateAsyncMock.Received(1).Invoke(policyUpdate, null); + fakePolicyValidator.OnSaveSideEffectsAsyncMock.Received(1).Invoke(policyUpdate, null); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + await sutProvider.GetDependency().Received(1).UpsertAsync(Arg.Is(p => + p.CreationDate == creationDate && + p.RevisionDate == creationDate)); + } + + [Theory, BitAutoData] + public async Task SaveAsync_ExistingPolicy_Success( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy currentPolicy) + { + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns(""); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Store mutable properties separately to assert later + var id = currentPolicy.Id; + var organizationId = currentPolicy.OrganizationId; + var type = currentPolicy.Type; + var creationDate = currentPolicy.CreationDate; + var revisionDate = sutProvider.GetDependency().Start; + + await sutProvider.Sut.SaveAsync(policyUpdate); + + await fakePolicyValidator.ValidateAsyncMock.Received(1).Invoke(policyUpdate, currentPolicy); + fakePolicyValidator.OnSaveSideEffectsAsyncMock.Received(1).Invoke(policyUpdate, currentPolicy); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + // Additional assertions to ensure certain properties have or have not been updated + await sutProvider.GetDependency().Received(1).UpsertAsync(Arg.Is(p => + p.Id == id && + p.OrganizationId == organizationId && + p.Type == type && + p.CreationDate == creationDate && + p.RevisionDate == revisionDate)); + } + + [Fact] + public void Constructor_DuplicatePolicyValidators_Throws() + { + var exception = Assert.Throws(() => + new SavePolicyCommand( + Substitute.For(), + Substitute.For(), + Substitute.For(), + [new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()], + Substitute.For() + )); + Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message); + } + + [Theory, BitAutoData] + public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest(PolicyUpdate policyUpdate) + { + var sutProvider = SutProviderFactory(); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(Task.FromResult(null)); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest(PolicyUpdate policyUpdate) + { + var sutProvider = SutProviderFactory(); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(new OrganizationAbility + { + Id = policyUpdate.OrganizationId, + UsePolicies = false + }); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("cannot use policies", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyIsNull_Throws( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate) + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyNotEnabled_Throws( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy) + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([singleOrgPolicy]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyEnabled_Success( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy) + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([singleOrgPolicy]); + + await sutProvider.Sut.SaveAsync(policyUpdate); + await AssertPolicySavedAsync(sutProvider, policyUpdate); + } + + [Theory, BitAutoData] + public async Task SaveAsync_DependentPolicyIsEnabled_Throws( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso)] Policy requireSsoPolicy) // depends on Single Org + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("Turn off the Require single sign-on authentication policy because it requires the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_MultipleDependentPoliciesAreEnabled_Throws( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso)] Policy requireSsoPolicy, // depends on Single Org + [Policy(PolicyType.MaximumVaultTimeout)] Policy vaultTimeoutPolicy) // depends on Single Org + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator(), + new FakeVaultTimeoutPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy, vaultTimeoutPolicy]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("Turn off all of the policies that require the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_DependentPolicyNotEnabled_Success( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso, false)] Policy requireSsoPolicy) // depends on Single Org but is not enabled + { + var sutProvider = SutProviderFactory([ + new FakeRequireSsoPolicyValidator(), + new FakeSingleOrgPolicyValidator() + ]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy]); + + await sutProvider.Sut.SaveAsync(policyUpdate); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + } + + [Theory, BitAutoData] + public async Task SaveAsync_ThrowsOnValidationError([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns("Validation error!"); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]); + + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(policyUpdate)); + + Assert.Contains("Validation error!", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + /// + /// Returns a new SutProvider with the PolicyValidators registered in the Sut. + /// + private static SutProvider SutProviderFactory(IEnumerable? policyValidators = null) + { + return new SutProvider() + .WithFakeTimeProvider() + .SetDependency(typeof(IEnumerable), policyValidators ?? []) + .Create(); + } + + private static void ArrangeOrganization(SutProvider sutProvider, PolicyUpdate policyUpdate) + { + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(new OrganizationAbility + { + Id = policyUpdate.OrganizationId, + UsePolicies = true + }); + } + + private static async Task AssertPolicyNotSavedAsync(SutProvider sutProvider) + { + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpsertAsync(default!); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogPolicyEventAsync(default, default); + } + + private static async Task AssertPolicySavedAsync(SutProvider sutProvider, PolicyUpdate policyUpdate) + { + var expectedPolicy = () => Arg.Is(p => + p.Type == policyUpdate.Type && + p.OrganizationId == policyUpdate.OrganizationId && + p.Enabled == policyUpdate.Enabled && + p.Data == policyUpdate.Data); + + await sutProvider.GetDependency().Received(1).UpsertAsync(expectedPolicy()); + + await sutProvider.GetDependency().Received(1) + .LogPolicyEventAsync(expectedPolicy(), EventType.Policy_Updated); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationDomainServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationDomainServiceTests.cs index ddd9accd0..c779e3a1c 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationDomainServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationDomainServiceTests.cs @@ -1,8 +1,7 @@ -using Bit.Core.AdminConsole.Services.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.Entities; -using Bit.Core.Enums; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -36,18 +35,14 @@ public class OrganizationDomainServiceTests Txt = "btw+6789" } }; + sutProvider.GetDependency().GetManyByNextRunDateAsync(default) .ReturnsForAnyArgs(domains); await sutProvider.Sut.ValidateOrganizationsDomainAsync(); - await sutProvider.GetDependency().ReceivedWithAnyArgs(2) - .ResolveAsync(default, default); - await sutProvider.GetDependency().ReceivedWithAnyArgs(2) - .ReplaceAsync(default); - await sutProvider.GetDependency().ReceivedWithAnyArgs(2) - .LogOrganizationDomainEventAsync(default, EventType.OrganizationDomain_NotVerified, - EventSystemUser.DomainVerification); + await sutProvider.GetDependency().ReceivedWithAnyArgs(2) + .SystemVerifyOrganizationDomainAsync(default); } [Theory, BitAutoData] diff --git a/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs b/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs index fb08a32f2..f9bc49bbe 100644 --- a/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs @@ -34,7 +34,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -61,7 +60,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("cannot use policies", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -93,7 +91,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Single Sign-On Authentication policy is enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -124,7 +121,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Maximum Vault Timeout policy is enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -161,7 +157,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Key Connector is enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -189,7 +184,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Single Organization policy not enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -222,7 +216,7 @@ public class PolicyServiceTests var utcNow = DateTime.UtcNow; - await sutProvider.Sut.SaveAsync(policy, Substitute.For(), Guid.NewGuid()); + await sutProvider.Sut.SaveAsync(policy, Guid.NewGuid()); await sutProvider.GetDependency().Received() .LogPolicyEventAsync(policy, EventType.Policy_Updated); @@ -252,7 +246,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Single Organization policy not enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -353,14 +346,13 @@ public class PolicyServiceTests (orgUserDetailAdmin, false), }); - var organizationService = Substitute.For(); var removeOrganizationUserCommand = sutProvider.GetDependency(); var utcNow = DateTime.UtcNow; var savingUserId = Guid.NewGuid(); - await sutProvider.Sut.SaveAsync(policy, organizationService, savingUserId); + await sutProvider.Sut.SaveAsync(policy, savingUserId); await removeOrganizationUserCommand.Received() .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWithout2FA.Id, savingUserId); @@ -468,13 +460,12 @@ public class PolicyServiceTests (orgUserDetailAdmin.UserId.Value, false), }); - var organizationService = Substitute.For(); var removeOrganizationUserCommand = sutProvider.GetDependency(); var savingUserId = Guid.NewGuid(); var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, organizationService, savingUserId)); + () => sutProvider.Sut.SaveAsync(policy, savingUserId)); Assert.Contains("Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -541,13 +532,11 @@ public class PolicyServiceTests (orgUserDetail.UserId.Value, false), }); - var organizationService = Substitute.For(); - var utcNow = DateTime.UtcNow; var savingUserId = Guid.NewGuid(); - await sutProvider.Sut.SaveAsync(policy, organizationService, savingUserId); + await sutProvider.Sut.SaveAsync(policy, savingUserId); await sutProvider.GetDependency().Received() .LogPolicyEventAsync(policy, EventType.Policy_Updated); @@ -590,7 +579,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Trusted device encryption is on and requires this policy.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -626,7 +614,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Trusted device encryption is on and requires this policy.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -659,7 +646,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Single Organization policy not enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); @@ -692,7 +678,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(policy, - Substitute.For(), Guid.NewGuid())); Assert.Contains("Account recovery policy is enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); diff --git a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs index fb566537a..e397c838c 100644 --- a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs +++ b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs @@ -11,7 +11,6 @@ using Bit.Core.Auth.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -342,14 +341,12 @@ public class SsoConfigServiceTests await sutProvider.GetDependency().Received(1) .SaveAsync( Arg.Is(t => t.Type == PolicyType.SingleOrg), - Arg.Any(), null ); await sutProvider.GetDependency().Received(1) .SaveAsync( Arg.Is(t => t.Type == PolicyType.ResetPassword && t.GetDataModel().AutoEnrollEnabled), - Arg.Any(), null ); diff --git a/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs b/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs new file mode 100644 index 000000000..0d7382b3c --- /dev/null +++ b/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs @@ -0,0 +1,205 @@ +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.NotificationHub; + +public class NotificationHubConnectionTests +{ + [Fact] + public void IsValid_ConnectionStringIsNull_ReturnsFalse() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = null, + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + }; + + // Act + var connection = NotificationHubConnection.From(hub); + + // Assert + Assert.False(connection.IsValid); + } + + [Fact] + public void IsValid_HubNameIsNull_ReturnsFalse() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "Endpoint=sb://example.servicebus.windows.net/;", + HubName = null, + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + }; + + // Act + var connection = NotificationHubConnection.From(hub); + + // Assert + Assert.False(connection.IsValid); + } + + [Fact] + public void IsValid_ConnectionStringAndHubNameAreNotNull_ReturnsTrue() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + }; + + // Act + var connection = NotificationHubConnection.From(hub); + + // Assert + Assert.True(connection.IsValid); + } + + [Fact] + public void RegistrationEnabled_QueryTimeIsBeforeStartDate_ReturnsFalse() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow.AddDays(1), + RegistrationEndDate = DateTime.UtcNow.AddDays(2) + }; + var connection = NotificationHubConnection.From(hub); + + // Act + var result = connection.RegistrationEnabled(DateTime.UtcNow); + + // Assert + Assert.False(result); + } + + [Fact] + public void RegistrationEnabled_QueryTimeIsAfterEndDate_ReturnsFalse() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + }; + var connection = NotificationHubConnection.From(hub); + + // Act + var result = connection.RegistrationEnabled(DateTime.UtcNow.AddDays(2)); + + // Assert + Assert.False(result); + } + + [Fact] + public void RegistrationEnabled_NullStartDate_ReturnsFalse() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = null, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + }; + var connection = NotificationHubConnection.From(hub); + + // Act + var result = connection.RegistrationEnabled(DateTime.UtcNow); + + // Assert + Assert.False(result); + } + + [Fact] + public void RegistrationEnabled_QueryTimeIsBetweenStartDateAndEndDate_ReturnsTrue() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + }; + var connection = NotificationHubConnection.From(hub); + + // Act + var result = connection.RegistrationEnabled(DateTime.UtcNow.AddHours(1)); + + // Assert + Assert.True(result); + } + + [Fact] + public void RegistrationEnabled_CombTimeIsBeforeStartDate_ReturnsFalse() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow.AddDays(1), + RegistrationEndDate = DateTime.UtcNow.AddDays(2) + }; + var connection = NotificationHubConnection.From(hub); + + // Act + var result = connection.RegistrationEnabled(CoreHelpers.GenerateComb(Guid.NewGuid(), DateTime.UtcNow)); + + // Assert + Assert.False(result); + } + + [Fact] + public void RegistrationEnabled_CombTimeIsAfterEndDate_ReturnsFalse() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + }; + var connection = NotificationHubConnection.From(hub); + + // Act + var result = connection.RegistrationEnabled(CoreHelpers.GenerateComb(Guid.NewGuid(), DateTime.UtcNow.AddDays(2))); + + // Assert + Assert.False(result); + } + + [Fact] + public void RegistrationEnabled_CombTimeIsBetweenStartDateAndEndDate_ReturnsTrue() + { + // Arrange + var hub = new GlobalSettings.NotificationHubSettings() + { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + }; + var connection = NotificationHubConnection.From(hub); + + // Act + var result = connection.RegistrationEnabled(CoreHelpers.GenerateComb(Guid.NewGuid(), DateTime.UtcNow.AddHours(1))); + + // Assert + Assert.True(result); + } +} diff --git a/test/Core.Test/NotificationHub/NotificationHubPoolTests.cs b/test/Core.Test/NotificationHub/NotificationHubPoolTests.cs new file mode 100644 index 000000000..dd9afb867 --- /dev/null +++ b/test/Core.Test/NotificationHub/NotificationHubPoolTests.cs @@ -0,0 +1,156 @@ +using Bit.Core.NotificationHub; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using static Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.NotificationHub; + +public class NotificationHubPoolTests +{ + [Fact] + public void NotificationHubPool_WarnsOnMissingConnectionString() + { + // Arrange + var globalSettings = new GlobalSettings() + { + NotificationHubPool = new NotificationHubPoolSettings() + { + NotificationHubs = new() { + new() { + ConnectionString = null, + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + } + } + } + }; + var logger = Substitute.For>(); + + // Act + var sut = new NotificationHubPool(logger, globalSettings); + + // Assert + logger.Received().Log(LogLevel.Warning, Arg.Any(), + Arg.Is(o => o.ToString() == "Invalid notification hub settings: hub"), + null, + Arg.Any>()); + } + + [Fact] + public void NotificationHubPool_WarnsOnMissingHubName() + { + // Arrange + var globalSettings = new GlobalSettings() + { + NotificationHubPool = new NotificationHubPoolSettings() + { + NotificationHubs = new() { + new() { + ConnectionString = "connection", + HubName = null, + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1) + } + } + } + }; + var logger = Substitute.For>(); + + // Act + var sut = new NotificationHubPool(logger, globalSettings); + + // Assert + logger.Received().Log(LogLevel.Warning, Arg.Any(), + Arg.Is(o => o.ToString() == "Invalid notification hub settings: hub name missing"), + null, + Arg.Any>()); + } + + [Fact] + public void NotificationHubPool_ClientFor_ThrowsOnNoValidHubs() + { + // Arrange + var globalSettings = new GlobalSettings() + { + NotificationHubPool = new NotificationHubPoolSettings() + { + NotificationHubs = new() { + new() { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = null, + RegistrationEndDate = null, + } + } + } + }; + var logger = Substitute.For>(); + var sut = new NotificationHubPool(logger, globalSettings); + + // Act + Action act = () => sut.ClientFor(Guid.NewGuid()); + + // Assert + Assert.Throws(act); + } + + [Fact] + public void NotificationHubPool_ClientFor_ReturnsClient() + { + // Arrange + var globalSettings = new GlobalSettings() + { + NotificationHubPool = new NotificationHubPoolSettings() + { + NotificationHubs = new() { + new() { + ConnectionString = "Endpoint=sb://example.servicebus.windows.net/;SharedAccessKey=example///example=", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow.AddMinutes(-1), + RegistrationEndDate = DateTime.UtcNow.AddDays(1), + } + } + } + }; + var logger = Substitute.For>(); + var sut = new NotificationHubPool(logger, globalSettings); + + // Act + var client = sut.ClientFor(CoreHelpers.GenerateComb(Guid.NewGuid(), DateTime.UtcNow)); + + // Assert + Assert.NotNull(client); + } + + [Fact] + public void NotificationHubPool_AllClients_ReturnsProxy() + { + // Arrange + var globalSettings = new GlobalSettings() + { + NotificationHubPool = new NotificationHubPoolSettings() + { + NotificationHubs = new() { + new() { + ConnectionString = "connection", + HubName = "hub", + RegistrationStartDate = DateTime.UtcNow, + RegistrationEndDate = DateTime.UtcNow.AddDays(1), + } + } + } + }; + var logger = Substitute.For>(); + var sut = new NotificationHubPool(logger, globalSettings); + + // Act + var proxy = sut.AllClients; + + // Assert + Assert.NotNull(proxy); + } +} diff --git a/test/Core.Test/NotificationHub/NotificationHubProxyTests.cs b/test/Core.Test/NotificationHub/NotificationHubProxyTests.cs new file mode 100644 index 000000000..b2e9c4f9f --- /dev/null +++ b/test/Core.Test/NotificationHub/NotificationHubProxyTests.cs @@ -0,0 +1,40 @@ +using AutoFixture; +using Bit.Core.NotificationHub; +using Bit.Test.Common.AutoFixture; +using Microsoft.Azure.NotificationHubs; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.NotificationHub; + +public class NotificationHubProxyTests +{ + private readonly IEnumerable _clients; + public NotificationHubProxyTests() + { + _clients = new Fixture().WithAutoNSubstitutions().CreateMany(); + } + + public static IEnumerable ClientMethods = + [ + [ + (NotificationHubClientProxy c) => c.SendTemplateNotificationAsync(new Dictionary() { { "key", "value" } }, "tag"), + (INotificationHubClient c) => c.SendTemplateNotificationAsync(Arg.Is>((a) => a.Keys.Count == 1 && a.ContainsKey("key") && a["key"] == "value"), "tag"), + ], + ]; + + [Theory] + [MemberData(nameof(ClientMethods))] + public async void CallsAllClients(Func proxyMethod, Func clientMethod) + { + var clients = _clients.ToArray(); + var proxy = new NotificationHubClientProxy(clients); + + await proxyMethod(proxy); + + foreach (var client in clients) + { + await clientMethod(client.Received()); + } + } +} diff --git a/test/Core.Test/Services/NotificationHubPushNotificationServiceTests.cs b/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs similarity index 81% rename from test/Core.Test/Services/NotificationHubPushNotificationServiceTests.cs rename to test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs index 82594445a..ea9ce5413 100644 --- a/test/Core.Test/Services/NotificationHubPushNotificationServiceTests.cs +++ b/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs @@ -1,32 +1,32 @@ -using Bit.Core.Repositories; +using Bit.Core.NotificationHub; +using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Settings; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Services; +namespace Bit.Core.Test.NotificationHub; public class NotificationHubPushNotificationServiceTests { private readonly NotificationHubPushNotificationService _sut; private readonly IInstallationDeviceRepository _installationDeviceRepository; - private readonly GlobalSettings _globalSettings; + private readonly INotificationHubPool _notificationHubPool; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; public NotificationHubPushNotificationServiceTests() { _installationDeviceRepository = Substitute.For(); - _globalSettings = new GlobalSettings(); _httpContextAccessor = Substitute.For(); + _notificationHubPool = Substitute.For(); _logger = Substitute.For>(); _sut = new NotificationHubPushNotificationService( _installationDeviceRepository, - _globalSettings, + _notificationHubPool, _httpContextAccessor, _logger ); diff --git a/test/Core.Test/Services/NotificationHubPushRegistrationServiceTests.cs b/test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs similarity index 82% rename from test/Core.Test/Services/NotificationHubPushRegistrationServiceTests.cs rename to test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs index a8dd536b8..c5851f279 100644 --- a/test/Core.Test/Services/NotificationHubPushRegistrationServiceTests.cs +++ b/test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs @@ -1,11 +1,11 @@ -using Bit.Core.Repositories; -using Bit.Core.Services; +using Bit.Core.NotificationHub; +using Bit.Core.Repositories; using Bit.Core.Settings; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Services; +namespace Bit.Core.Test.NotificationHub; public class NotificationHubPushRegistrationServiceTests { @@ -15,6 +15,7 @@ public class NotificationHubPushRegistrationServiceTests private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; + private readonly INotificationHubPool _notificationHubPool; public NotificationHubPushRegistrationServiceTests() { @@ -22,10 +23,12 @@ public class NotificationHubPushRegistrationServiceTests _serviceProvider = Substitute.For(); _logger = Substitute.For>(); _globalSettings = new GlobalSettings(); + _notificationHubPool = Substitute.For(); _sut = new NotificationHubPushRegistrationService( _installationDeviceRepository, _globalSettings, + _notificationHubPool, _serviceProvider, _logger ); diff --git a/test/Core.Test/Services/MultiServicePushNotificationServiceTests.cs b/test/Core.Test/Services/MultiServicePushNotificationServiceTests.cs index b1876f1dd..68d6c50a7 100644 --- a/test/Core.Test/Services/MultiServicePushNotificationServiceTests.cs +++ b/test/Core.Test/Services/MultiServicePushNotificationServiceTests.cs @@ -1,10 +1,10 @@ -using Bit.Core.Repositories; +using AutoFixture; using Bit.Core.Services; -using Bit.Core.Settings; -using Microsoft.AspNetCore.Http; +using Bit.Test.Common.AutoFixture; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using GlobalSettingsCustomization = Bit.Test.Common.AutoFixture.GlobalSettings; namespace Bit.Core.Test.Services; @@ -12,35 +12,26 @@ public class MultiServicePushNotificationServiceTests { private readonly MultiServicePushNotificationService _sut; - private readonly IHttpClientFactory _httpFactory; - private readonly IDeviceRepository _deviceRepository; - private readonly IInstallationDeviceRepository _installationDeviceRepository; - private readonly GlobalSettings _globalSettings; - private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; private readonly ILogger _relayLogger; private readonly ILogger _hubLogger; + private readonly IEnumerable _services; + private readonly Settings.GlobalSettings _globalSettings; public MultiServicePushNotificationServiceTests() { - _httpFactory = Substitute.For(); - _deviceRepository = Substitute.For(); - _installationDeviceRepository = Substitute.For(); - _globalSettings = new GlobalSettings(); - _httpContextAccessor = Substitute.For(); _logger = Substitute.For>(); _relayLogger = Substitute.For>(); _hubLogger = Substitute.For>(); + var fixture = new Fixture().WithAutoNSubstitutions().Customize(new GlobalSettingsCustomization()); + _services = fixture.CreateMany(); + _globalSettings = fixture.Create(); + _sut = new MultiServicePushNotificationService( - _httpFactory, - _deviceRepository, - _installationDeviceRepository, - _globalSettings, - _httpContextAccessor, + _services, _logger, - _relayLogger, - _hubLogger + _globalSettings ); } diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 480c7b639..aa2c0a5cc 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -27,7 +27,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; -using NSubstitute.ReceivedExtensions; using Xunit; namespace Bit.Core.Test.Services; @@ -282,45 +281,69 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithManagingEnabledOrganization_ReturnsTrue( - SutProvider sutProvider, Guid userId, Organization organization) + public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningDisabled_ReturnsFalse( + SutProvider sutProvider, Guid userId) { - organization.Enabled = true; - organization.UseSso = true; - - sutProvider.GetDependency() - .GetByClaimedUserDomainAsync(userId) - .Returns(organization); - - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); - Assert.True(result); - } - - [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithManagingDisabledOrganization_ReturnsFalse( - SutProvider sutProvider, Guid userId, Organization organization) - { - organization.Enabled = false; - organization.UseSso = true; - - sutProvider.GetDependency() - .GetByClaimedUserDomainAsync(userId) - .Returns(organization); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(false); var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); Assert.False(result); } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithOrganizationUseSsoFalse_ReturnsFalse( + public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingEnabledOrganization_ReturnsTrue( + SutProvider sutProvider, Guid userId, Organization organization) + { + organization.Enabled = true; + organization.UseSso = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + sutProvider.GetDependency() + .GetByVerifiedUserEmailDomainAsync(userId) + .Returns(new[] { organization }); + + var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingDisabledOrganization_ReturnsFalse( + SutProvider sutProvider, Guid userId, Organization organization) + { + organization.Enabled = false; + organization.UseSso = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + sutProvider.GetDependency() + .GetByVerifiedUserEmailDomainAsync(userId) + .Returns(new[] { organization }); + + var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithOrganizationUseSsoFalse_ReturnsFalse( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = true; organization.UseSso = false; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + sutProvider.GetDependency() - .GetByClaimedUserDomainAsync(userId) - .Returns(organization); + .GetByVerifiedUserEmailDomainAsync(userId) + .Returns(new[] { organization }); var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); Assert.False(result); diff --git a/test/Core.Test/Utilities/CoreHelpersTests.cs b/test/Core.Test/Utilities/CoreHelpersTests.cs index af1156798..2cce276fc 100644 --- a/test/Core.Test/Utilities/CoreHelpersTests.cs +++ b/test/Core.Test/Utilities/CoreHelpersTests.cs @@ -34,33 +34,30 @@ public class CoreHelpersTests // the comb are working properly } - public static IEnumerable GenerateCombCases = new[] - { - new object[] - { + public static IEnumerable GuidSeedCases = [ + [ Guid.Parse("a58db474-43d8-42f1-b4ee-0c17647cd0c0"), // Input Guid new DateTime(2022, 3, 12, 12, 12, 0, DateTimeKind.Utc), // Input Time - Guid.Parse("a58db474-43d8-42f1-b4ee-ae5600c90cc1"), // Expected Comb - }, - new object[] - { + ], + [ Guid.Parse("f776e6ee-511f-4352-bb28-88513002bdeb"), new DateTime(2021, 5, 10, 10, 52, 0, DateTimeKind.Utc), - Guid.Parse("f776e6ee-511f-4352-bb28-ad2400b313c1"), - }, - new object[] - { + ], + [ Guid.Parse("51a25fc7-3cad-497d-8e2f-8d77011648a1"), new DateTime(1999, 2, 26, 16, 53, 13, DateTimeKind.Utc), - Guid.Parse("51a25fc7-3cad-497d-8e2f-8d77011649cd"), - }, - new object[] - { + ], + [ Guid.Parse("bfb8f353-3b32-4a9e-bef6-24fe0b54bfb0"), new DateTime(2024, 10, 20, 1, 32, 16, DateTimeKind.Utc), - Guid.Parse("bfb8f353-3b32-4a9e-bef6-b20f00195780"), - } - }; + ] + ]; + public static IEnumerable GenerateCombCases = GuidSeedCases.Zip([ + Guid.Parse("a58db474-43d8-42f1-b4ee-ae5600c90cc1"), // Expected Comb for each Guid Seed case + Guid.Parse("f776e6ee-511f-4352-bb28-ad2400b313c1"), + Guid.Parse("51a25fc7-3cad-497d-8e2f-8d77011649cd"), + Guid.Parse("bfb8f353-3b32-4a9e-bef6-b20f00195780"), + ]).Select((zip) => new object[] { zip.Item1[0], zip.Item1[1], zip.Item2 }); [Theory] [MemberData(nameof(GenerateCombCases))] @@ -71,6 +68,31 @@ public class CoreHelpersTests Assert.Equal(expectedComb, comb); } + [Theory] + [MemberData(nameof(GuidSeedCases))] + public void DateFromComb_WithComb_Success(Guid inputGuid, DateTime inputTime) + { + var comb = CoreHelpers.GenerateComb(inputGuid, inputTime); + var inverseComb = CoreHelpers.DateFromComb(comb); + + Assert.Equal(inputTime, inverseComb, TimeSpan.FromMilliseconds(4)); + } + + [Theory] + [InlineData("00000000-0000-0000-0000-000000000000", 1, 0)] + [InlineData("00000000-0000-0000-0000-000000000001", 1, 0)] + [InlineData("00000000-0000-0000-0000-000000000000", 500, 430)] + [InlineData("00000000-0000-0000-0000-000000000001", 500, 430)] + [InlineData("10000000-0000-0000-0000-000000000001", 500, 454)] + [InlineData("00000000-0000-0100-0000-000000000001", 500, 19)] + public void BinForComb_Success(string guidString, int nbins, int expectedBin) + { + var guid = Guid.Parse(guidString); + var bin = CoreHelpers.BinForComb(guid, nbins); + + Assert.Equal(expectedBin, bin); + } + /* [Fact] public void ToGuidIdArrayTVP_Success() diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs index eac71e9c2..f6dc4a989 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -97,13 +97,160 @@ public class OrganizationRepositoryTests ResetPasswordKey = "resetpasswordkey1", }); - var user1Response = await organizationRepository.GetByClaimedUserDomainAsync(user1.Id); - var user2Response = await organizationRepository.GetByClaimedUserDomainAsync(user2.Id); - var user3Response = await organizationRepository.GetByClaimedUserDomainAsync(user3.Id); + var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id); + var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id); + var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id); - Assert.NotNull(user1Response); - Assert.Equal(organization.Id, user1Response.Id); - Assert.Null(user2Response); - Assert.Null(user3Response); + Assert.NotEmpty(user1Response); + Assert.Equal(organization.Id, user1Response.First().Id); + Assert.Empty(user2Response); + Assert.Empty(user3Response); + } + + [DatabaseTheory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = user.Email, + Plan = "Test", + PrivateKey = "privatekey", + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey", + }); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + Assert.Empty(result); + } + + [DatabaseTheory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization1 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org 1 {id}", + BillingEmail = user.Email, + Plan = "Test", + PrivateKey = "privatekey1", + }); + + var organization2 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org 2 {id}", + BillingEmail = user.Email, + Plan = "Test", + PrivateKey = "privatekey2", + }); + + var organizationDomain1 = new OrganizationDomain + { + OrganizationId = organization1.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain1.SetNextRunDate(12); + organizationDomain1.SetJobRunCount(); + organizationDomain1.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain1); + + var organizationDomain2 = new OrganizationDomain + { + OrganizationId = organization2.Id, + DomainName = domainName, + Txt = "btw+67890", + }; + organizationDomain2.SetNextRunDate(12); + organizationDomain2.SetJobRunCount(); + organizationDomain2.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain2); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization1.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization2.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey2", + }); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + Assert.Equal(2, result.Count); + Assert.Contains(result, org => org.Id == organization1.Id); + Assert.Contains(result, org => org.Id == organization2.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty( + IOrganizationRepository organizationRepository) + { + var nonExistentUserId = Guid.NewGuid(); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId); + + Assert.Empty(result); } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index 3b102c788..dba511074 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -253,6 +253,9 @@ public class OrganizationUserRepositoryTests Assert.Equal(orgUser1.Permissions, result.Permissions); Assert.Equal(organization.SmSeats, result.SmSeats); Assert.Equal(organization.SmServiceAccounts, result.SmServiceAccounts); + Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation); + Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion); + // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 Assert.Equal(organization.LimitCollectionCreationDeletion, result.LimitCollectionCreationDeletion); Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems); } diff --git a/util/Migrator/DbScripts/2024-10-18-00_CollectionCipher_ReadByUserId.sql b/util/Migrator/DbScripts/2024-10-18-00_CollectionCipher_ReadByUserId.sql new file mode 100644 index 000000000..154740229 --- /dev/null +++ b/util/Migrator/DbScripts/2024-10-18-00_CollectionCipher_ReadByUserId.sql @@ -0,0 +1,39 @@ +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CC.* + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] S ON S.[Id] = CC.[CollectionId] + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[OrganizationId] = S.[OrganizationId] AND OU.[UserId] = @UserId + INNER JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = S.[Id] AND CU.[OrganizationUserId] = OU.[Id] + WHERE + OU.[Status] = 2 + + UNION ALL + + SELECT + CC.* + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] S ON S.[Id] = CC.[CollectionId] + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[OrganizationId] = S.[OrganizationId] AND OU.[UserId] = @UserId + INNER JOIN + [dbo].[GroupUser] GU ON GU.[OrganizationUserId] = OU.[Id] + INNER JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = CC.[CollectionId] AND CG.[GroupId] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = S.[Id] AND CU.[OrganizationUserId] = OU.[Id] + WHERE + OU.[Status] = 2 + AND CU.[CollectionId] IS NULL +END